diff --git a/.all-contributorsrc b/.all-contributorsrc index b8a34941e..b37ccc915 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -224,6 +224,15 @@ "contributions": [ "translation" ] + }, + { + "login": "perfectdelusions", + "name": "eden", + "avatar_url": "https://avatars.githubusercontent.com/u/272893080?v=4", + "profile": "https://github.com/perfectdelusions", + "contributions": [ + "translation" + ] } ], "repoType": "github" diff --git a/CollapseLauncher/Classes/CachesManagement/Honkai/Check.cs b/CollapseLauncher/Classes/CachesManagement/Honkai/Check.cs index 6a883cd9c..fd5d93799 100644 --- a/CollapseLauncher/Classes/CachesManagement/Honkai/Check.cs +++ b/CollapseLauncher/Classes/CachesManagement/Honkai/Check.cs @@ -58,15 +58,6 @@ private async Task> Check(List assetIndex, Cancella return returnAsset; } - private readonly SearchValues _unusedSearchValues = SearchValues.Create([ - "output_log", - "Crashes", - "Verify.txt", - "APM", - "FBData", - "asb.dat" - ], StringComparison.OrdinalIgnoreCase); - private void CheckUnusedAssets(List assetIndex, List returnAsset) { // Directory info and if the directory doesn't exist, return @@ -76,13 +67,25 @@ private void CheckUnusedAssets(List assetIndex, List ret return; } + SearchValues unusedSearchValues = SearchValues + .Create(GameVersionManager.GamePreset.GameInstallFileInfo?.CacheUpdateUnusedFilesIgnoreList + ?? [ + "output_log", + "Crashes", + "Verify.txt", + "APM", + "FBData", + "asb.dat", + "MiHoYoSDK.log" + ], StringComparison.OrdinalIgnoreCase); + // Iterate the file contained in the _gamePath foreach (FileInfo fileInfo in directoryInfo.EnumerateFiles("*", SearchOption.AllDirectories) .EnumerateNoReadOnly()) { ReadOnlySpan filePath = fileInfo.FullName; - if (filePath.ContainsAny(_unusedSearchValues) + if (filePath.ContainsAny(unusedSearchValues) || assetIndex.Exists(x => x.ConcatPath == fileInfo.FullName)) { continue; diff --git a/CollapseLauncher/Classes/EventsManagement/BackgroundActivityManager.cs b/CollapseLauncher/Classes/EventsManagement/BackgroundActivityManager.cs index 6274e3871..a5e4a97d2 100644 --- a/CollapseLauncher/Classes/EventsManagement/BackgroundActivityManager.cs +++ b/CollapseLauncher/Classes/EventsManagement/BackgroundActivityManager.cs @@ -7,11 +7,13 @@ using CollapseLauncher.Plugins; using Hi3Helper; using Hi3Helper.Data; +using Hi3Helper.Shared.Region; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Media; using System; using System.Collections.Generic; +using System.IO; using System.Numerics; // ReSharper disable StringLiteralTypo @@ -67,23 +69,24 @@ private static IconElement GetGamePresetIcon(PresetConfig presetConfig) if (presetConfig is not PluginPresetConfigWrapper pluginPresetConfig) { - return new BitmapIcon - { - UriSource = new Uri(uri) - }; + return Create(uri); } PluginInfo pluginInfo = pluginPresetConfig.PluginInfo; GamePluginIconConverter converter = StaticConverter.Shared; if (converter.Convert(pluginInfo, null!, null!, "") is not IconElement iconElement) { - return new BitmapIcon - { - UriSource = new Uri(uri) - }; + return Create(uri); } return iconElement; + + static BitmapIcon Create(string uri) + => new() + { + UriSource = new Uri(uri), + ShowAsMonochrome = false + }; } private static void AttachEventToNotification(PresetConfig presetConfig, IBackgroundActivity activity, string activityTitle, string activitySubtitle) diff --git a/CollapseLauncher/Classes/Extension/UIElementExtensions.UnsafeAccessorExtensions.cs b/CollapseLauncher/Classes/Extension/UIElementExtensions.UnsafeAccessorExtensions.cs index 345086d69..c25225b89 100644 --- a/CollapseLauncher/Classes/Extension/UIElementExtensions.UnsafeAccessorExtensions.cs +++ b/CollapseLauncher/Classes/Extension/UIElementExtensions.UnsafeAccessorExtensions.cs @@ -1,5 +1,6 @@ using Microsoft.UI.Input; using Microsoft.UI.Xaml; +using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; using WinRT; @@ -34,7 +35,7 @@ internal static T WithCursor(this T element, InputCursor inputCursor) where T /// Check whether a WinRT object has been disposed. /// /// if object is already disposed. Otherwise, . - internal static bool IsObjectDisposed(this IWinRTObject? winRtObject) + internal static bool IsObjectDisposed([NotNullWhen(false)] this IWinRTObject? winRtObject) { if (winRtObject == null) { diff --git a/CollapseLauncher/Classes/Extension/UIElementExtensions.cs b/CollapseLauncher/Classes/Extension/UIElementExtensions.cs index b51517857..a1e5b7ce2 100644 --- a/CollapseLauncher/Classes/Extension/UIElementExtensions.cs +++ b/CollapseLauncher/Classes/Extension/UIElementExtensions.cs @@ -49,8 +49,14 @@ internal static T BindNavigationViewItemText(this T element, object? localeOb localePropertyName, sourceTrigger: UpdateSourceTrigger.PropertyChanged); - if (element is not NavigationViewItem) return element; + return element is not NavigationViewItem ? + element : + element.BindTooltipToLocale(localeObjBinding, localePropertyName); + } + internal static T BindTooltipToLocale(this T element, object? localeObjBinding, string localePropertyName) + where T : DependencyObject + { TextBlock tooltipTextBlock = new(); tooltipTextBlock.BindProperty(TextBlock.TextProperty, localeObjBinding, diff --git a/CollapseLauncher/Classes/GameManagement/ImageBackground/ImageBackgroundManager.FFmpeg.cs b/CollapseLauncher/Classes/GameManagement/ImageBackground/ImageBackgroundManager.FFmpeg.cs index fe1749f85..990586cbb 100644 --- a/CollapseLauncher/Classes/GameManagement/ImageBackground/ImageBackgroundManager.FFmpeg.cs +++ b/CollapseLauncher/Classes/GameManagement/ImageBackground/ImageBackgroundManager.FFmpeg.cs @@ -98,17 +98,18 @@ public bool TryRelinkFFmpegPath() return false; } - // -- If custom FFmpeg path is set but FFmpeg is not available, - // Try to resolve the symbolic link path again. - // -- Check for custom FFmpeg path availability first. If not available, skip. - result = (IsFFmpegAvailable(customFFmpegDirPath, out exception) && - // -- Re-link FFmpeg symbolic link - TryLinkFFmpegLibrary(customFFmpegDirPath, curDir, out exception)) || - // -- If custom FFmpeg path is not avail, then try to find one from EnvVar (this might be a bit expensive). - // If found, the GlobalCustomFFmpegPath will be updated to the found path. - (TryFindFFmpegInstallFromEnvVar(out string? envVarPath, out exception) && TryLinkFFmpegLibrary(envVarPath, curDir, out exception)); - - return result; + // -- 1. Check from custom path first. If it exists, then pass. + if (!string.IsNullOrEmpty(customFFmpegDirPath) && + IsFFmpegAvailable(customFFmpegDirPath, out exception) && + TryLinkFFmpegLibrary(customFFmpegDirPath, curDir, out exception)) + { + return result = true; + } + + // -- 2. Find one from environment variables. If it exists, then pass. + // Otherwise, return false. + return result = TryFindFFmpegInstallFromEnvVar(out string? envVarPath, out exception) && + TryLinkFFmpegLibrary(envVarPath, curDir, out exception); } finally { @@ -180,7 +181,6 @@ internal static bool IsFFmpegAvailable(string? checkOnDirectory, string dllPathAvfilter = Path.Combine(checkOnDirectory, Fields.DllNameAvfilter); string dllPathAvformat = Path.Combine(checkOnDirectory, Fields.DllNameAvformat); string dllPathAvutil = Path.Combine(checkOnDirectory, Fields.DllNameAvutil); - string dllPathPostproc = Path.Combine(checkOnDirectory, Fields.DllNamePostproc); string dllPathSwresample = Path.Combine(checkOnDirectory, Fields.DllNameSwresample); string dllPathSwscale = Path.Combine(checkOnDirectory, Fields.DllNameSwscale); @@ -189,7 +189,6 @@ internal static bool IsFFmpegAvailable(string? checkOnDirectory, FileUtility.IsFileExistOrSymbolicLinkResolved(dllPathAvfilter, out _, out exception) && FileUtility.IsFileExistOrSymbolicLinkResolved(dllPathAvformat, out _, out exception) && FileUtility.IsFileExistOrSymbolicLinkResolved(dllPathAvutil, out _, out exception) && - FileUtility.IsFileExistOrSymbolicLinkResolved(dllPathPostproc, out _, out exception) && FileUtility.IsFileExistOrSymbolicLinkResolved(dllPathSwresample, out _, out exception) && FileUtility.IsFileExistOrSymbolicLinkResolved(dllPathSwscale, out _, out exception); } @@ -201,7 +200,6 @@ internal static string[] GetFFmpegRequiredDllFilenames() => Fields.DllNameAvfilter, Fields.DllNameAvformat, Fields.DllNameAvutil, - Fields.DllNamePostproc, Fields.DllNameSwresample, Fields.DllNameSwscale ]; @@ -262,14 +260,24 @@ public static bool TryLinkFFmpegLibrary( string dllPathSwresample = Path.Combine(sourceDir, Fields.DllNameSwresample); string dllPathSwscale = Path.Combine(sourceDir, Fields.DllNameSwscale); - return CreateSymbolLink(dllPathAvcodec, targetDir, out exception) && - CreateSymbolLink(dllPathAvdevice, targetDir, out exception) && - CreateSymbolLink(dllPathAvfilter, targetDir, out exception) && - CreateSymbolLink(dllPathAvformat, targetDir, out exception) && - CreateSymbolLink(dllPathAvutil, targetDir, out exception) && - CreateSymbolLink(dllPathPostproc, targetDir, out exception) && - CreateSymbolLink(dllPathSwresample, targetDir, out exception) && - CreateSymbolLink(dllPathSwscale, targetDir, out exception); + bool result = + CreateSymbolLink(dllPathAvcodec, targetDir, out exception) && + CreateSymbolLink(dllPathAvdevice, targetDir, out exception) && + CreateSymbolLink(dllPathAvfilter, targetDir, out exception) && + CreateSymbolLink(dllPathAvformat, targetDir, out exception) && + CreateSymbolLink(dllPathAvutil, targetDir, out exception) && + CreateSymbolLink(dllPathSwresample, targetDir, out exception) && + CreateSymbolLink(dllPathSwscale, targetDir, out exception); + + // Additionally, link postproc if it exists. + // Since some non-free/GPL custom build (if used by the user) still requires postproc library to exist if enabled on build. + // Without it, some build might fail to run. + if (result && FileUtility.IsFileExistOrSymbolicLinkResolved(dllPathPostproc, out string? resolvedOptDllPostproc, out exception)) + { + return result && CreateSymbolLink(resolvedOptDllPostproc, targetDir, out exception); + } + + return result; static bool CreateSymbolLink(string filePath, string targetDirectory, diff --git a/CollapseLauncher/Classes/GameManagement/ImageBackground/ImageBackgroundManager.Loaders.cs b/CollapseLauncher/Classes/GameManagement/ImageBackground/ImageBackgroundManager.Loaders.cs index 245be224d..70984e07b 100644 --- a/CollapseLauncher/Classes/GameManagement/ImageBackground/ImageBackgroundManager.Loaders.cs +++ b/CollapseLauncher/Classes/GameManagement/ImageBackground/ImageBackgroundManager.Loaders.cs @@ -13,13 +13,13 @@ using Microsoft.UI.Xaml.Media.Animation; using PhotoSauce.MagicScaler; using System; -using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Linq; using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; +using Windows.Foundation; using Windows.UI; // ReSharper disable IdentifierTypo @@ -36,27 +36,30 @@ public partial class ImageBackgroundManager #endregion - private void LoadImageAtIndex(int index, CancellationToken token) => - new Thread(() => LoadImageAtIndexCore(index, token)) + private void LoadImageAtIndex(int index, CancellationToken token) + { + if (ImageContextSources.Count <= index || + index < 0 || + IsBackgroundLoading) { - IsBackground = true - }.Start(); + return; + } + + IsBackgroundLoading = true; + new Thread(() => LoadImageAtIndexCore(index, token).ConfigureAwait(false)) + { + IsBackground = true, + Priority = ThreadPriority.Lowest + }.UnsafeStart(); + } - private async void LoadImageAtIndexCore(int index, CancellationToken token) + private async Task LoadImageAtIndexCore(int index, CancellationToken token) { Stopwatch? stopwatch = null; try { bool isUseFFmpeg = GlobalIsUseFFmpeg && GlobalIsFFmpegAvailable; - stopwatch = Stopwatch.StartNew(); - if (ImageContextSources.Count <= index || - index < 0) - { - return; - } - - IsBackgroundLoading = true; // -- Notify changes on context menu properties DispatcherQueueExtensions @@ -78,24 +81,24 @@ private async void LoadImageAtIndexCore(int index, CancellationToken token) Unsafe.SkipInit(out Uri? downloadedOverlayUri); if (Uri.TryCreate(context.OverlayImagePath, UriKind.Absolute, out Uri? overlayImageUri)) { - downloadedOverlayUri = await GetLocalOrDownloadedFilePath(overlayImageUri, token); - (downloadedOverlayUri, _) = await GetNativeOrDecodedImagePath(downloadedOverlayUri, token); + downloadedOverlayUri = await GetLocalOrDownloadedFilePath(overlayImageUri, token).ConfigureAwait(false); + (downloadedOverlayUri, _) = await GetNativeOrDecodedImagePath(downloadedOverlayUri, token).ConfigureAwait(false); } // -- Download background. Unsafe.SkipInit(out Uri? downloadedBackgroundUri); if (Uri.TryCreate(context.BackgroundImagePath, UriKind.Absolute, out Uri? backgroundImageUri)) { - downloadedBackgroundUri = await GetLocalOrDownloadedFilePath(backgroundImageUri, token); - (downloadedBackgroundUri, _) = await GetNativeOrDecodedImagePath(downloadedBackgroundUri, token); + downloadedBackgroundUri = await GetLocalOrDownloadedFilePath(backgroundImageUri, token).ConfigureAwait(false); + (downloadedBackgroundUri, _) = await GetNativeOrDecodedImagePath(downloadedBackgroundUri, token).ConfigureAwait(false); } // -- Download static background. Unsafe.SkipInit(out Uri? downloadedBackgroundStaticUri); if (Uri.TryCreate(context.BackgroundImageStaticPath, UriKind.Absolute, out Uri? backgroundStaticImageUri)) { - downloadedBackgroundStaticUri = await GetLocalOrDownloadedFilePath(backgroundStaticImageUri, token); - (downloadedBackgroundStaticUri, _) = await GetNativeOrDecodedImagePath(downloadedBackgroundStaticUri, token); + downloadedBackgroundStaticUri = await GetLocalOrDownloadedFilePath(backgroundStaticImageUri, token).ConfigureAwait(false); + (downloadedBackgroundStaticUri, _) = await GetNativeOrDecodedImagePath(downloadedBackgroundStaticUri, token).ConfigureAwait(false); } // Try to use static bg URL if normal bg is not available. @@ -108,9 +111,9 @@ private async void LoadImageAtIndexCore(int index, CancellationToken token) // -- Get upscaled image file if Waifu2X is enabled if (GlobalIsWaifu2XEnabled) { - downloadedOverlayUri = await TryGetScaledWaifu2XImagePath(downloadedOverlayUri, token); - downloadedBackgroundUri = await TryGetScaledWaifu2XImagePath(downloadedBackgroundUri, token); - downloadedBackgroundStaticUri = await TryGetScaledWaifu2XImagePath(downloadedBackgroundStaticUri, token); + downloadedOverlayUri = await TryGetScaledWaifu2XImagePath(downloadedOverlayUri, token).ConfigureAwait(false); + downloadedBackgroundUri = await TryGetScaledWaifu2XImagePath(downloadedBackgroundUri, token).ConfigureAwait(false); + downloadedBackgroundStaticUri = await TryGetScaledWaifu2XImagePath(downloadedBackgroundStaticUri, token).ConfigureAwait(false); } token.ThrowIfCancellationRequested(); @@ -238,19 +241,35 @@ private void SpawnImageLayer(Uri? overlayFilePath, bindingMode: BindingMode.OneWay, converter: StaticConverter.Shared); - layerElement.Transitions.Add(new PopupThemeTransition()); - layerElement.ImageLoaded += LayerElementOnLoaded; + layerElement.ImageLoaded += LayerElementOnLoaded; + layerElement.CanvasSizeChanged += LayerElementCanvasSizeChanged; PresenterGrid?.Children.Add(layerElement); layerElement.Tag = isVideo; } + private void LayerElementCanvasSizeChanged(LayeredBackgroundImage layerElement, Size size) + { + CurrentElementWidth = size.Width; + CurrentElementHeight = size.Height; + } + private void LayerElementOnLoaded(LayeredBackgroundImage layerElement) { - List lastElements = PresenterGrid?.Children.ToList() ?? []; - foreach (UIElement element in lastElements.Where(element => element != layerElement)) + layerElement.Transitions.Add(new PopupThemeTransition()); + layerElement.ImageLoaded -= LayerElementOnLoaded; + + if (PresenterGrid?.Children.Count > 1) { - PresenterGrid?.Children.Remove(element); + UIElement? lastElement = PresenterGrid?.Children.LastOrDefault(); + foreach (UIElement element in PresenterGrid?.Children.Where(element => element != lastElement) ?? []) + { + PresenterGrid?.Children.Remove(element); + if (element is LayeredBackgroundImage asLayeredImage) + { + asLayeredImage.CanvasSizeChanged -= LayerElementCanvasSizeChanged; + } + } } if (CurrentIsEnableBackgroundAutoPlay && WindowUtility.CurrentWindowIsVisible) @@ -263,7 +282,6 @@ private void LayerElementOnLoaded(LayeredBackgroundImage layerElement) { CurrentBackgroundIsSeekable = isDisplayControl; } - layerElement.ImageLoaded -= LayerElementOnLoaded; } private static bool TryGetUpscaledFilePath(string inputFilePath, diff --git a/CollapseLauncher/Classes/GameManagement/ImageBackground/ImageBackgroundManager.cs b/CollapseLauncher/Classes/GameManagement/ImageBackground/ImageBackgroundManager.cs index 4feeb03f6..9c95d1368 100644 --- a/CollapseLauncher/Classes/GameManagement/ImageBackground/ImageBackgroundManager.cs +++ b/CollapseLauncher/Classes/GameManagement/ImageBackground/ImageBackgroundManager.cs @@ -300,6 +300,26 @@ private set } } + public double CurrentElementWidth + { + get; + set + { + field = value; + OnPropertyChanged(); + } + } + + public double CurrentElementHeight + { + get; + set + { + field = value; + OnPropertyChanged(); + } + } + /// /// The collection of image context sources.

/// Notes to Dev:
@@ -395,7 +415,8 @@ await SetGlobalCustomBackground(GlobalCustomBackgroundImagePath, false, false, t { OriginBackgroundImagePath = bgPlaceholderPath, BackgroundImagePath = bgPlaceholderPath, - IsVideo = IsVideoMediaFileExtensionSupported(bgPlaceholderPath) + IsVideo = IsVideoMediaFileExtensionSupported(bgPlaceholderPath), + IsCustom = true }); return; } @@ -495,7 +516,8 @@ await GetCroppedCustomImage(null, OriginBackgroundImagePath = imagePath, BackgroundImagePath = resultBackgroundPath, ForceReload = true, // Request force reload (skip cache) - IsVideo = IsVideoMediaFileExtensionSupported(resultBackgroundPath) + IsVideo = IsVideoMediaFileExtensionSupported(resultBackgroundPath), + IsCustom = true }; UpdateContextListCore(token, skipPreviousContextCheck, context); @@ -643,6 +665,12 @@ public bool IsVideo init; } + public bool IsCustom + { + get; + init; + } + public bool Equals(LayeredImageBackgroundContext? other) => other?.GetHashCode() == GetHashCode(); public override bool Equals(object? obj) diff --git a/CollapseLauncher/Classes/Helper/Animation/AnimationHelper.cs b/CollapseLauncher/Classes/Helper/Animation/AnimationHelper.cs index 519fa8230..b06788039 100644 --- a/CollapseLauncher/Classes/Helper/Animation/AnimationHelper.cs +++ b/CollapseLauncher/Classes/Helper/Animation/AnimationHelper.cs @@ -163,6 +163,9 @@ internal static void EnableImplicitAnimation(this UIElement element, bool recurs switch (element) { + case Viewbox { Child: { } viewBoxContent }: + viewBoxContent.EnableImplicitAnimation(true, easingFunction); + break; case Button { Content: UIElement buttonContent }: buttonContent.EnableImplicitAnimation(true, easingFunction); break; diff --git a/CollapseLauncher/Classes/Helper/Metadata/PresetConfig.cs b/CollapseLauncher/Classes/Helper/Metadata/PresetConfig.cs index 14a0d4a9a..9dad84b01 100644 --- a/CollapseLauncher/Classes/Helper/Metadata/PresetConfig.cs +++ b/CollapseLauncher/Classes/Helper/Metadata/PresetConfig.cs @@ -7,6 +7,7 @@ using Hi3Helper.EncTool; using Hi3Helper.EncTool.Parser.AssetMetadata; using Hi3Helper.SentryHelper; +using Microsoft.UI.Xaml; using Microsoft.Win32; using System; using System.Buffers; @@ -20,6 +21,7 @@ using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; +using WinRT; using static Hi3Helper.Logger; // ReSharper disable InconsistentNaming // ReSharper disable IdentifierTypo @@ -80,13 +82,27 @@ public enum LauncherType Plugin } + [GeneratedBindableCustomProperty] + public partial class GameEventButtonPosition + { + [JsonConverter(typeof(JsonStringEnumConverter))] + public HorizontalAlignment HorizontalAlignment { get; init; } = HorizontalAlignment.Left; + + [JsonConverter(typeof(JsonStringEnumConverter))] + public VerticalAlignment VerticalAlignment { get; init; } = VerticalAlignment.Top; + + public Thickness Position { get; init; } = new(256, 596, 0, 0); + public double VSize { get; init; } = 80d; + } + public class GameInstallFileInfo { - public string GameDataFolderName { get; init; } = string.Empty; - public string[] FilesToDelete { get; init; } = []; - public string[] FoldersToDelete { get; init; } = []; - public string[] FoldersToKeepInData { get; init; } = []; - public string[] FilesCleanupIgnoreList { get; init; } = []; + public string GameDataFolderName { get; init; } = string.Empty; + public string[] FilesToDelete { get; init; } = []; + public string[] FoldersToDelete { get; init; } = []; + public string[] FoldersToKeepInData { get; init; } = []; + public string[] FilesCleanupIgnoreList { get; init; } = []; + public string[] CacheUpdateUnusedFilesIgnoreList { get; init; } = []; } public class SophonChunkUrls @@ -538,6 +554,8 @@ public SophonChunkUrls? LauncherResourceChunksURL public GameInstallFileInfo? GameInstallFileInfo { get; init; } + public GameEventButtonPosition GameEventButtonPosition { get => field ??= new GameEventButtonPosition(); init; } + #endregion #region Dynamic Config Properties diff --git a/CollapseLauncher/Classes/Helper/StreamUtility/StreamExtension.cs b/CollapseLauncher/Classes/Helper/StreamUtility/StreamExtension.cs index 0e7cae51b..90df5161d 100644 --- a/CollapseLauncher/Classes/Helper/StreamUtility/StreamExtension.cs +++ b/CollapseLauncher/Classes/Helper/StreamUtility/StreamExtension.cs @@ -309,7 +309,7 @@ public static FileInfo ResolveSymlink(this FileInfo fileInfo) ///
/// The directory to remove. /// Whether to remove all possibly empty directories recursively. - public static void DeleteEmptyDirectory(this string dir, bool recursive = false) + public static bool DeleteEmptyDirectory(this string dir, bool recursive = false) => new DirectoryInfo(dir).DeleteEmptyDirectory(recursive); /// @@ -317,20 +317,34 @@ public static void DeleteEmptyDirectory(this string dir, bool recursive = false) /// /// The directory to remove. /// Whether to remove all possibly empty directories recursively. - public static void DeleteEmptyDirectory(this DirectoryInfo dir, bool recursive = false) + public static bool DeleteEmptyDirectory(this DirectoryInfo dir, bool recursive = false) { - if (recursive) + try { - foreach (DirectoryInfo childDir in dir.EnumerateDirectories("*", SearchOption.TopDirectoryOnly)) + if (!dir.Exists) { - childDir.DeleteEmptyDirectory(); + return true; + } + + if (recursive) + { + foreach (DirectoryInfo childDir in dir.EnumerateDirectories("*", SearchOption.TopDirectoryOnly)) + { + childDir.DeleteEmptyDirectory(); + } } - } - FindFiles.TryIsDirectoryEmpty(dir.FullName, out bool isEmpty); - if (isEmpty) + FindFiles.TryIsDirectoryEmpty(dir.FullName, out bool isEmpty); + if (isEmpty) + { + dir.Delete(true); + } + + return true; + } + catch { - dir.Delete(true); + return false; } } diff --git a/CollapseLauncher/Classes/InstallManagement/Base/InstallManagerBase.Sophon.cs b/CollapseLauncher/Classes/InstallManagement/Base/InstallManagerBase.Sophon.cs index 30e4c4d2b..a4c4c0fb2 100644 --- a/CollapseLauncher/Classes/InstallManagement/Base/InstallManagerBase.Sophon.cs +++ b/CollapseLauncher/Classes/InstallManagement/Base/InstallManagerBase.Sophon.cs @@ -796,7 +796,7 @@ await TryGetAdditionalPackageForSophonDiff(httpClient, } // Filter asset list - await FilterSophonPatchAssetList(sophonUpdateAssetList, Token!.Token); + await FilterAssetList(sophonUpdateAssetList, x => x.AssetName, Token!.Token); // Get the remote chunk size ProgressPerFileSizeTotal = sophonUpdateAssetList.GetCalculatedDiffSize(!isPreloadMode); @@ -925,12 +925,6 @@ await Parallel.ForEachAsync(sophonUpdateAssetList } } - protected virtual Task FilterSophonPatchAssetList(List itemList, CancellationToken token) - { - // NOP - return Task.CompletedTask; - } - private ValueTask RunSophonAssetDownloadThread(HttpClient client, SophonAsset asset, ParallelOptions parallelOptions) diff --git a/CollapseLauncher/Classes/InstallManagement/StarRail/StarRailInstall.SophonPatch.cs b/CollapseLauncher/Classes/InstallManagement/StarRail/StarRailInstall.SophonPatch.cs index 156db6930..4a993a3b6 100644 --- a/CollapseLauncher/Classes/InstallManagement/StarRail/StarRailInstall.SophonPatch.cs +++ b/CollapseLauncher/Classes/InstallManagement/StarRail/StarRailInstall.SophonPatch.cs @@ -1,4 +1,5 @@ -using Hi3Helper.Data; +using Hi3Helper; +using Hi3Helper.Data; using System; using System.Buffers; using System.Collections.Generic; @@ -62,9 +63,13 @@ public override async Task FilterAssetList( continue; } + ConverterTool.NormalizePathInplaceNoTrim(filePath); + int indexOfAny = filePath.IndexOfAny(searchValues); if (indexOfAny >= 0) { + Logger.LogWriteLine($"[StarRailInstall::FilterAssetList] Asset: {patchAsset} is ignored due to marked as deleted asset.", + writeToLog: true); continue; } @@ -90,12 +95,9 @@ static ReadOnlySpan GetFilePathFromJson(ReadOnlySpan line) line = line[(firstIndexOf + first.Length)..]; int endIndexOf = line.IndexOf(end); - if (endIndexOf <= 0) - { - return ReadOnlySpan.Empty; - } - - return line[..endIndexOf]; + return endIndexOf <= 0 + ? ReadOnlySpan.Empty + : line[..endIndexOf]; } } diff --git a/CollapseLauncher/Classes/InstallManagement/Zenless/ZenlessInstall.SophonPatch.cs b/CollapseLauncher/Classes/InstallManagement/Zenless/ZenlessInstall.SophonPatch.cs index c867ee2fb..7f38d2909 100644 --- a/CollapseLauncher/Classes/InstallManagement/Zenless/ZenlessInstall.SophonPatch.cs +++ b/CollapseLauncher/Classes/InstallManagement/Zenless/ZenlessInstall.SophonPatch.cs @@ -1,11 +1,9 @@ -using Hi3Helper.Sophon; -using Hi3Helper.Sophon.Infos; +using Hi3Helper; using Hi3Helper.Sophon.Structs; using System; using System.Buffers; using System.Collections.Generic; using System.IO; -using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; // ReSharper disable CheckNamespace @@ -17,14 +15,6 @@ internal partial class ZenlessInstall { private const StringSplitOptions SplitOptions = StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries; - // ReSharper disable once StringLiteralTypo - [UnsafeAccessor(UnsafeAccessorKind.Field, Name = "k__BackingField")] - private static extern ref SophonChunksInfo GetChunkAssetChunksInfo(SophonAsset element); - - // ReSharper disable once StringLiteralTypo - [UnsafeAccessor(UnsafeAccessorKind.Field, Name = "k__BackingField")] - private static extern ref SophonChunksInfo GetChunkAssetChunksInfoAlt(SophonAsset element); - public override async Task FilterAssetList( List itemList, Func itemPathSelector, @@ -67,6 +57,8 @@ private static void FilterSophonAsset(List itemList, HashSet excep if (asset is SophonIdentifiableProperty { MatchingField: { } assetMatchingField } && exceptMatchFieldHashSet.Contains(assetMatchingField)) { + Logger.LogWriteLine($"[ZenlessInstall::FilterSophonAsset] Asset: {asset} is ignored due to marked as deleted asset.", + writeToLog: true); continue; } diff --git a/CollapseLauncher/Classes/Plugins/PluginGameInstallWrapper.cs b/CollapseLauncher/Classes/Plugins/PluginGameInstallWrapper.cs index a4fa7e2c5..3bd6c0769 100644 --- a/CollapseLauncher/Classes/Plugins/PluginGameInstallWrapper.cs +++ b/CollapseLauncher/Classes/Plugins/PluginGameInstallWrapper.cs @@ -1,7 +1,9 @@ using CollapseLauncher.Dialogs; +using CollapseLauncher.DiscordPresence; using CollapseLauncher.Extension; using CollapseLauncher.FileDialogCOM; using CollapseLauncher.Helper; +using CollapseLauncher.Helper.Loading; using CollapseLauncher.Helper.Metadata; using CollapseLauncher.InstallManager; using CollapseLauncher.InstallManager.Base; @@ -16,12 +18,15 @@ using Hi3Helper.Shared.ClassStruct; using Hi3Helper.Shared.Region; using Hi3Helper.Win32.ManagedTools; +using CollapseLauncher.Pages; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Media.Animation; using System; using System.Collections.Generic; using System.IO; using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; using System.Threading; using System.Threading.Tasks; @@ -30,6 +35,9 @@ namespace CollapseLauncher.Plugins; #nullable enable internal partial class PluginGameInstallWrapper : ProgressBase, IGameInstallManager { + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + private unsafe delegate void PerFileProgressCallbackNative(InstallPerFileProgress* progress); + private struct InstallProgressProperty { public int StateCount; @@ -63,6 +71,10 @@ public override string GamePath private readonly InstallProgressStateDelegate _updateProgressStatusDelegate; private InstallProgressProperty _updateProgressProperty; + private PerFileProgressCallbackNative? _perFileProgressDelegate; + private GCHandle _perFileProgressGcHandle; + private bool _hasPerFileProgress; + private PluginGameVersionWrapper GameManager => GameVersionManager as PluginGameVersionWrapper ?? throw new InvalidCastException("GameVersionManager is not PluginGameVersionWrapper"); @@ -103,10 +115,66 @@ private void ResetAndCancelTokenSource() public void Dispose() { + UnregisterPerFileProgressCallback(); _gameInstaller.Free(); GC.SuppressFinalize(this); } + private unsafe void TryRegisterPerFileProgressCallback() + { + if (_perFileProgressGcHandle.IsAllocated) + return; + + _perFileProgressDelegate = OnPerFileProgressCallback; + _perFileProgressGcHandle = GCHandle.Alloc(_perFileProgressDelegate); + nint callbackPtr = Marshal.GetFunctionPointerForDelegate(_perFileProgressDelegate); + + _hasPerFileProgress = _pluginPresetConfig.PluginInfo.EnablePerFileProgressCallback(callbackPtr); + + if (!_hasPerFileProgress) + { + _perFileProgressGcHandle.Free(); + _perFileProgressDelegate = null; + } + } + + private void UnregisterPerFileProgressCallback() + { + if (!_hasPerFileProgress) + return; + + _pluginPresetConfig.PluginInfo.DisablePerFileProgressCallback(); + _hasPerFileProgress = false; + + if (_perFileProgressGcHandle.IsAllocated) + _perFileProgressGcHandle.Free(); + _perFileProgressDelegate = null; + } + + private unsafe void OnPerFileProgressCallback(InstallPerFileProgress* progress) + { + // IMPORTANT: Called from NativeAOT plugin across reverse P/Invoke. Must never throw. + try + { + if (progress == null) + return; + + long downloaded = progress->PerFileDownloadedBytes; + long total = progress->PerFileTotalBytes; + + Progress.ProgressPerFileSizeCurrent = downloaded; + Progress.ProgressPerFileSizeTotal = total; + Progress.ProgressPerFilePercentage = total > 0 + ? ConverterTool.ToPercentage(total, downloaded) + : 0; + } + catch (Exception ex) + { + Logger.LogWriteLine($"[PluginGameInstallWrapper::OnPerFileProgressCallback] Exception (swallowed):\r\n{ex}", + LogType.Error, true); + } + } + [MethodImpl(MethodImplOptions.NoOptimization | MethodImplOptions.NoInlining)] public async ValueTask GetInstallationPath(bool isHasOnlyMigrateOption = false) { @@ -198,15 +266,81 @@ public async ValueTask GetInstallationPath(bool isHasOnlyMigrateOption = fa return folder; } - public Task StartPackageDownload(bool skipDialog = false) + [MethodImpl(MethodImplOptions.NoOptimization | MethodImplOptions.NoInlining)] + public async Task StartPackageDownload(bool skipDialog = false) { - // NOP - return Task.CompletedTask; + ResetStatusAndProgressProperty(); + bool isSuccess = false; + + try + { + IsRunning = true; + + Status.IsProgressAllIndetermined = true; + Status.IsProgressPerFileIndetermined = true; + Status.IsRunning = true; + Status.IsIncludePerFileIndicator = false; + UpdateStatus(); + + Logger.LogWriteLine("[PluginGameInstallWrapper::StartPackageDownload] Step 1/5: InitPluginComAsync...", LogType.Debug, true); + await _gameInstaller.InitPluginComAsync(_plugin, Token!.Token); + + Logger.LogWriteLine("[PluginGameInstallWrapper::StartPackageDownload] Step 2/5: GetGameSizeAsync...", LogType.Debug, true); + Guid cancelGuid = _plugin.RegisterCancelToken(Token.Token); + + _gameInstaller.GetGameSizeAsync(GameInstallerKind.Preload, in cancelGuid, out nint asyncSize); + long sizeToDownload = await asyncSize.AsTask(); + Logger.LogWriteLine($"[PluginGameInstallWrapper::StartPackageDownload] Size to download: {sizeToDownload}", LogType.Debug, true); + + Logger.LogWriteLine("[PluginGameInstallWrapper::StartPackageDownload] Step 3/5: GetGameDownloadedSizeAsync...", LogType.Debug, true); + _gameInstaller.GetGameDownloadedSizeAsync(GameInstallerKind.Preload, in cancelGuid, out nint asyncDownloaded); + long sizeAlreadyDownloaded = await asyncDownloaded.AsTask(); + Logger.LogWriteLine($"[PluginGameInstallWrapper::StartPackageDownload] Already downloaded: {sizeAlreadyDownloaded}", LogType.Debug, true); + + Logger.LogWriteLine("[PluginGameInstallWrapper::StartPackageDownload] Step 4/5: EnsureDiskSpaceAvailability...", LogType.Debug, true); + await EnsureDiskSpaceAvailability(GameManager.GameDirPath, sizeToDownload, sizeAlreadyDownloaded); + + Logger.LogWriteLine("[PluginGameInstallWrapper::StartPackageDownload] Step 5/5: StartPreloadAsync...", LogType.Debug, true); + TryRegisterPerFileProgressCallback(); + Status.IsIncludePerFileIndicator = _hasPerFileProgress; + UpdateStatus(); + + _gameInstaller.StartPreloadAsync( + _updateProgressDelegate, + _updateProgressStatusDelegate, + _plugin.RegisterCancelToken(Token.Token), + out nint asyncPreload); + + Logger.LogWriteLine("[PluginGameInstallWrapper::StartPackageDownload] Awaiting preload task...", LogType.Debug, true); + await asyncPreload.AsTask().ConfigureAwait(false); + Logger.LogWriteLine("[PluginGameInstallWrapper::StartPackageDownload] Preload task completed.", LogType.Debug, true); + isSuccess = true; + } + catch (OperationCanceledException) + { + Logger.LogWriteLine("[PluginGameInstallWrapper::StartPackageDownload] Cancelled by user.", LogType.Warning, true); + Status.IsCanceled = true; + throw; + } + catch (Exception ex) + { + Logger.LogWriteLine($"[PluginGameInstallWrapper::StartPackageDownload] Preload failed:\r\n{ex}", LogType.Error, true); + SentryHelper.ExceptionHandler(ex, SentryHelper.ExceptionType.UnhandledOther); + throw; + } + finally + { + Logger.LogWriteLine("[PluginGameInstallWrapper::StartPackageDownload] Entering finally block.", LogType.Debug, true); + UnregisterPerFileProgressCallback(); + Status.IsCompleted = isSuccess; + IsRunning = false; + UpdateStatus(); + } } public ValueTask StartPackageVerification(List? gamePackage = null) { - // NOP + // NOP — preload download includes verification internally return new ValueTask(1); } @@ -216,6 +350,7 @@ public async Task StartPackageInstallation() _updateProgressProperty.IsUpdateMode = await GameManager.GetGameState() == GameInstallStateEnum.NeedsUpdate; ResetStatusAndProgressProperty(); + bool isSuccess = false; try { @@ -240,6 +375,10 @@ public async Task StartPackageInstallation() await EnsureDiskSpaceAvailability(GameManager.GameDirPath, sizeToDownload, sizeAlreadyDownloaded); + TryRegisterPerFileProgressCallback(); + Status.IsIncludePerFileIndicator = _hasPerFileProgress; + UpdateStatus(); + Task routineTask; if (_updateProgressProperty.IsUpdateMode) { @@ -263,15 +402,23 @@ public async Task StartPackageInstallation() } await routineTask.ConfigureAwait(false); + isSuccess = true; } - catch (OperationCanceledException) when (Token!.IsCancellationRequested) + catch (OperationCanceledException) { Status.IsCanceled = true; throw; } + catch (Exception ex) + { + Logger.LogWriteLine($"[PluginGameInstallWrapper::StartPackageInstallation] Install/Update failed:\r\n{ex}", LogType.Error, true); + SentryHelper.ExceptionHandler(ex, SentryHelper.ExceptionType.UnhandledOther); + throw; + } finally { - Status.IsCompleted = true; + UnregisterPerFileProgressCallback(); + Status.IsCompleted = isSuccess; IsRunning = false; UpdateStatus(); } @@ -279,64 +426,102 @@ public async Task StartPackageInstallation() private void UpdateProgressCallback(in InstallProgress delegateProgress) { - using (_updateStatusLock.EnterScope()) + // IMPORTANT: This callback is invoked via a function pointer from the NativeAOT plugin. + // Any unhandled exception here crosses the reverse P/Invoke boundary and causes a + // FailFast (STATUS_FAIL_FAST_EXCEPTION / exit code -1073741189). Must never throw. + try { - long downloadedBytes = delegateProgress.DownloadedBytes; - long downloadedBytesTotal = delegateProgress.TotalBytesToDownload; - - long readDownload = delegateProgress.DownloadedBytes - _updateProgressProperty.LastDownloaded; - double currentSpeed = CalculateSpeed(readDownload); - - if (CheckIfNeedRefreshStopwatch()) + using (_updateStatusLock.EnterScope()) { - return; + _updateProgressProperty.StateCount = delegateProgress.StateCount; + _updateProgressProperty.StateCountTotal = delegateProgress.TotalStateToComplete; + + _updateProgressProperty.AssetCount = delegateProgress.DownloadedCount; + _updateProgressProperty.AssetCountTotal = delegateProgress.TotalCountToDownload; + + long downloadedBytes = delegateProgress.DownloadedBytes; + long downloadedBytesTotal = delegateProgress.TotalBytesToDownload; + + long readDownload = delegateProgress.DownloadedBytes - _updateProgressProperty.LastDownloaded; + double currentSpeed = CalculateSpeed(readDownload); + + Progress.ProgressAllSizeCurrent = downloadedBytes; + Progress.ProgressAllSizeTotal = downloadedBytesTotal; + Progress.ProgressAllSpeed = currentSpeed; + + if (_hasPerFileProgress) + { + // V1Ext_Update5: per-file bytes/percentage come from OnPerFileProgressCallback. + // We only set the speed here (overall throughput is the meaningful metric). + Progress.ProgressPerFileSpeed = currentSpeed; + } + else + { + // Fallback: mirror aggregate values into per-file fields for older plugins. + Progress.ProgressPerFileSizeCurrent = downloadedBytes; + Progress.ProgressPerFileSizeTotal = downloadedBytesTotal; + Progress.ProgressPerFileSpeed = currentSpeed; + Progress.ProgressPerFilePercentage = downloadedBytesTotal > 0 + ? ConverterTool.ToPercentage(downloadedBytesTotal, downloadedBytes) + : 0; + } + + Progress.ProgressAllTimeLeft = downloadedBytesTotal > 0 && currentSpeed > 0 + ? ConverterTool.ToTimeSpanRemain(downloadedBytesTotal, downloadedBytes, currentSpeed) + : TimeSpan.Zero; + + Progress.ProgressAllPercentage = downloadedBytesTotal > 0 + ? ConverterTool.ToPercentage(downloadedBytesTotal, downloadedBytes) + : 0; + + _updateProgressProperty.LastDownloaded = downloadedBytes; + + if (!CheckIfNeedRefreshStopwatch()) + { + return; + } + + if (Status.IsProgressAllIndetermined) + { + Status.IsProgressAllIndetermined = false; + Status.IsProgressPerFileIndetermined = false; + UpdateStatus(); + } + + UpdateProgress(); } - - _updateProgressProperty.StateCount = delegateProgress.StateCount; - _updateProgressProperty.StateCountTotal = delegateProgress.TotalStateToComplete; - - _updateProgressProperty.AssetCount = delegateProgress.DownloadedCount; - _updateProgressProperty.AssetCountTotal = delegateProgress.TotalCountToDownload; - - Progress.ProgressAllSizeCurrent = downloadedBytes; - Progress.ProgressAllSizeTotal = downloadedBytesTotal; - Progress.ProgressAllSpeed = currentSpeed; - - Progress.ProgressAllTimeLeft = ConverterTool - .ToTimeSpanRemain(downloadedBytesTotal, downloadedBytes, currentSpeed); - - Progress.ProgressAllPercentage = ConverterTool.ToPercentage(downloadedBytesTotal, downloadedBytes); - - _updateProgressProperty.LastDownloaded = downloadedBytes; - - if (Status.IsProgressAllIndetermined) - { - Status.IsProgressAllIndetermined = false; - Status.IsProgressPerFileIndetermined = false; - UpdateStatus(); - } - - UpdateProgress(); + } + catch (Exception ex) + { + Logger.LogWriteLine($"[PluginGameInstallWrapper::UpdateProgressCallback] Exception (swallowed to prevent FailFast):\r\n{ex}", LogType.Error, true); } } private void UpdateStatusCallback(InstallProgressState delegateState) { - using (_updateStatusLock.EnterScope()) + // IMPORTANT: Same reverse P/Invoke boundary guard as UpdateProgressCallback above. + try { - string stateString = delegateState switch + using (_updateStatusLock.EnterScope()) { - InstallProgressState.Removing => string.Format("Deleting" + ": " + Locale.Current.Lang?._Misc?.PerFromTo, _updateProgressProperty.StateCount, _updateProgressProperty.StateCountTotal), - InstallProgressState.Idle => Locale.Current.Lang?._Misc?.Idle, - InstallProgressState.Install => string.Format(Locale.Current.Lang?._Misc?.Extracting + ": " + Locale.Current.Lang?._Misc?.PerFromTo, _updateProgressProperty.StateCount, _updateProgressProperty.StateCountTotal), - InstallProgressState.Verify or InstallProgressState.Preparing => string.Format(Locale.Current.Lang?._Misc?.Verifying + ": " + Locale.Current.Lang?._Misc?.PerFromTo, _updateProgressProperty.StateCount, _updateProgressProperty.StateCountTotal), - _ => string.Format((!_updateProgressProperty.IsUpdateMode ? Locale.Current.Lang?._Misc?.Downloading : Locale.Current.Lang?._Misc?.Updating) + ": " + Locale.Current.Lang?._Misc?.PerFromTo, _updateProgressProperty.StateCount, _updateProgressProperty.StateCountTotal) - } ?? ""; - - Status.ActivityStatus = stateString; - Status.ActivityAll = string.Format(Locale.Current.Lang?._Misc?.PerFromTo ?? "", _updateProgressProperty.AssetCount, _updateProgressProperty.AssetCountTotal); + string stateString = delegateState switch + { + InstallProgressState.Removing => string.Format("Deleting" + ": " + Locale.Current.Lang._Misc.PerFromTo, _updateProgressProperty.StateCount, _updateProgressProperty.StateCountTotal), + InstallProgressState.Idle => Locale.Current.Lang._Misc.Idle, + InstallProgressState.Install => string.Format(Locale.Current.Lang._Misc.Extracting + ": " + Locale.Current.Lang._Misc.PerFromTo, _updateProgressProperty.StateCount, _updateProgressProperty.StateCountTotal), + InstallProgressState.Verify or InstallProgressState.Preparing => string.Format(Locale.Current.Lang._Misc.Verifying + ": " + Locale.Current.Lang._Misc.PerFromTo, _updateProgressProperty.StateCount, _updateProgressProperty.StateCountTotal), + _ => string.Format((!_updateProgressProperty.IsUpdateMode ? Locale.Current.Lang._Misc.Downloading : Locale.Current.Lang._Misc.Updating) + ": " + Locale.Current.Lang._Misc.PerFromTo, _updateProgressProperty.StateCount, _updateProgressProperty.StateCountTotal) + }; + + Status.ActivityStatus = stateString; + Status.ActivityAll = string.Format(Locale.Current.Lang._Misc.PerFromTo, _updateProgressProperty.AssetCount, _updateProgressProperty.AssetCountTotal); - UpdateStatus(); + UpdateStatus(); + } + } + catch (Exception ex) + { + Logger.LogWriteLine($"[PluginGameInstallWrapper::UpdateStatusCallback] Exception (swallowed to prevent FailFast):\r\n{ex}", LogType.Error, true); } } @@ -414,12 +599,34 @@ public async ValueTask UninstallGame() public void Flush() => FlushingTrigger?.Invoke(this, EventArgs.Empty); - // TODO: - // Implement this after WuWa Plugin implementation is completed - public ValueTask IsPreloadCompleted(CancellationToken token = default) + public async ValueTask IsPreloadCompleted(CancellationToken token = default) { - // NOP - return new ValueTask(true); + try + { + await _gameInstaller.InitPluginComAsync(_plugin, token); + + Guid cancelGuid = _plugin.RegisterCancelToken(token); + + _gameInstaller.GetGameSizeAsync(GameInstallerKind.Preload, in cancelGuid, out nint asyncTotal); + long totalSize = await asyncTotal.AsTask(); + + if (totalSize <= 0) + return false; + + _gameInstaller.GetGameDownloadedSizeAsync(GameInstallerKind.Preload, in cancelGuid, out nint asyncDownloaded); + long downloadedSize = await asyncDownloaded.AsTask(); + + return downloadedSize >= totalSize; + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + Logger.LogWriteLine($"[PluginGameInstallWrapper::IsPreloadCompleted] Error checking preload status:\r\n{ex}", LogType.Error, true); + return false; + } } // TODO: @@ -437,19 +644,127 @@ public ValueTask TryShowFailedGameConversionState() return new ValueTask(false); } - // TODO: - // Implement this after WuWa Plugin implementation is completed - public ValueTask CleanUpGameFiles(bool withDialog = true) + public async ValueTask CleanUpGameFiles(bool withDialog = true) { - // NOP - return new ValueTask(); + string gameDirPath = GameManager.GameDirPath; + string tempDirPath = Path.Combine(gameDirPath, "TempPath"); + + if (!Directory.Exists(tempDirPath)) + return; + + // Collect temp files + DirectoryInfo tempDir = new DirectoryInfo(tempDirPath); + List tempFiles = []; + long totalSize = 0; + + foreach (FileInfo file in tempDir.EnumerateFiles("*", SearchOption.AllDirectories)) + { + LocalFileInfo localFile = new LocalFileInfo(file, gameDirPath); + tempFiles.Add(localFile); + totalSize += file.Length; + } + + if (tempFiles.Count == 0) + return; + + if (withDialog) + { + if (WindowUtility.CurrentWindow is MainWindow mainWindow) + { + mainWindow.OverlayFrame.BackStack?.Clear(); + mainWindow.OverlayFrame.Navigate(typeof(NullPage)); + mainWindow.OverlayFrame.Navigate(typeof(FileCleanupPage), null, + new DrillInNavigationTransitionInfo()); + } + + if (FileCleanupPage.Current == null) + return; + await FileCleanupPage.Current.InjectFileInfoSource(tempFiles, totalSize); + + LoadingMessageHelper.HideLoadingFrame(); + + FileCleanupPage.Current.MenuExitButton.Click += ExitFromOverlay; + FileCleanupPage.Current.MenuReScanButton.Click += ExitFromOverlay; + FileCleanupPage.Current.MenuReScanButton.Click += async (_, _) => + { + await Task.Delay(250); + await CleanUpGameFiles(); + }; + return; + } + + // Delete directly without dialog + foreach (LocalFileInfo fileInfo in tempFiles) + { + TryDeleteReadOnlyFile(fileInfo.FullPath); + } + + return; + + static void ExitFromOverlay(object? sender, RoutedEventArgs args) + { + if (WindowUtility.CurrentWindow is not MainWindow mainWindow) + return; + + mainWindow.OverlayFrame.GoBack(); + mainWindow.OverlayFrame.BackStack?.Clear(); + } } - // TODO: - // Implement this after WuWa Plugin implementation is completed public void UpdateCompletenessStatus(CompletenessStatus status) { - // NOP + switch (status) + { + case CompletenessStatus.Running: + IsRunning = true; + Status.IsRunning = true; + Status.IsCompleted = false; + Status.IsCanceled = false; +#if !DISABLEDISCORD + InnerLauncherConfig.AppDiscordPresence?.SetActivity(ActivityType.Update); +#endif + break; + case CompletenessStatus.Completed: + IsRunning = false; + Status.IsRunning = false; + Status.IsCompleted = true; + Status.IsCanceled = false; + Status.IsProgressAllIndetermined = false; + Status.IsProgressPerFileIndetermined = false; +#if !DISABLEDISCORD + InnerLauncherConfig.AppDiscordPresence?.SetActivity(ActivityType.Idle); +#endif + lock (Progress) + { + Progress.ProgressAllPercentage = 100f; + Progress.ProgressPerFilePercentage = 100f; + } + break; + case CompletenessStatus.Cancelled: + IsRunning = false; + Status.IsRunning = false; + Status.IsCompleted = false; + Status.IsCanceled = true; + Status.IsProgressAllIndetermined = false; + Status.IsProgressPerFileIndetermined = false; +#if !DISABLEDISCORD + InnerLauncherConfig.AppDiscordPresence?.SetActivity(ActivityType.Idle); +#endif + break; + case CompletenessStatus.Idle: + IsRunning = false; + Status.IsRunning = false; + Status.IsCompleted = false; + Status.IsCanceled = false; + Status.IsProgressAllIndetermined = false; + Status.IsProgressPerFileIndetermined = false; +#if !DISABLEDISCORD + InnerLauncherConfig.AppDiscordPresence?.SetActivity(ActivityType.Idle); +#endif + break; + } + + UpdateAll(); } public PostInstallBehaviour PostInstallBehaviour { get; set; } = PostInstallBehaviour.Nothing; diff --git a/CollapseLauncher/Classes/Plugins/PluginInfo.cs b/CollapseLauncher/Classes/Plugins/PluginInfo.cs index 2f957769b..20b267f21 100644 --- a/CollapseLauncher/Classes/Plugins/PluginInfo.cs +++ b/CollapseLauncher/Classes/Plugins/PluginInfo.cs @@ -329,6 +329,48 @@ internal unsafe void ToggleSpeedLimiterService(bool isEnable) } } + /// + /// Registers a per-file progress callback with the plugin via the V1Ext_Update5 export. + /// Returns true if the plugin supports Update5 and the callback was registered. + /// + internal unsafe bool EnablePerFileProgressCallback(nint callbackPtr) + { + if (!IsLoaded) + return false; + + if (!Handle.TryGetExportUnsafe("SetPerFileProgressCallback", out nint setCallbackP)) + return false; + + HResult hr = ((delegate* unmanaged[Cdecl])setCallbackP)(callbackPtr); + if (Marshal.GetExceptionForHR(hr) is { } exception) + { + Logger.LogWriteLine($"[PluginInfo] Plugin: {Name} failed to register per-file progress callback: {hr} {exception}", + LogType.Error, true); + return false; + } + + return true; + } + + /// + /// Unregisters the per-file progress callback from the plugin. + /// + internal unsafe void DisablePerFileProgressCallback() + { + if (!IsLoaded) + return; + + if (!Handle.TryGetExportUnsafe("SetPerFileProgressCallback", out nint setCallbackP)) + return; + + HResult hr = ((delegate* unmanaged[Cdecl])setCallbackP)(nint.Zero); + if (Marshal.GetExceptionForHR(hr) is { } exception) + { + Logger.LogWriteLine($"[PluginInfo] Plugin: {Name} failed to unregister per-file progress callback: {hr} {exception}", + LogType.Error, true); + } + } + internal async Task Initialize(CancellationToken token = default) { if (!IsLoaded) diff --git a/CollapseLauncher/Classes/RegionManagement/RegionManagement.cs b/CollapseLauncher/Classes/RegionManagement/RegionManagement.cs index fc87a94a6..7f456a9e7 100644 --- a/CollapseLauncher/Classes/RegionManagement/RegionManagement.cs +++ b/CollapseLauncher/Classes/RegionManagement/RegionManagement.cs @@ -20,10 +20,10 @@ using System.Threading.Tasks; using static CollapseLauncher.InnerLauncherConfig; using static Hi3Helper.Logger; -using static Hi3Helper.Shared.Region.LauncherConfig; // ReSharper disable StringLiteralTypo // ReSharper disable CheckNamespace +#nullable enable namespace CollapseLauncher { [SuppressMessage("ReSharper", "InconsistentNaming")] @@ -32,8 +32,10 @@ namespace CollapseLauncher [SuppressMessage("ReSharper", "AssignNullToNotNullAttribute")] public sealed partial class MainPage { - private GamePresetProperty CurrentGameProperty { get; set; } - private bool IsLoadRegionComplete; + public GamePresetProperty? CurrentGameProperty { get; set; } + private bool IsLoadRegionComplete; + + public PresetConfig? CurrentPresetConfig => CurrentGameProperty?.GamePreset; private static string RegionToChangeName => MetadataHelper.GetCurrentTranslatedTitleRegion(); @@ -53,13 +55,13 @@ private async Task LoadRegionFromCurrentConfigV2(PresetConfig preset, stri CancellationTokenSourceWrapper tokenSource = new(); - string regionToChangeName = $"{preset.GameLauncherApi.GameNameTranslation} - {preset.GameLauncherApi.GameRegionTranslation}"; - bool runResult = await preset.GameLauncherApi + string regionToChangeName = $"{preset.GameLauncherApi?.GameNameTranslation} - {preset.GameLauncherApi?.GameRegionTranslation}"; + bool runResult = await (preset.GameLauncherApi? .LoadAsync(BeforeLoadRoutine, AfterLoadRoutine, ActionOnTimeOutRetry, OnErrorRoutine, - tokenSource.Token); + tokenSource.Token) ?? ValueTask.FromResult(false)); RegionLoadingStatus.Remove((gameName, gameRegion)); return runResult; @@ -127,6 +129,12 @@ async ValueTask AfterLoadRoutine(CancellationToken token) preset.GameLauncherApi.LauncherGameBackground, presenterGrid: BackgroundPresenterGrid, token: token); + + DispatcherQueueExtensions.TryEnqueue(() => + { + OnPropertyChanged(nameof(CurrentPresetConfig)); + OnPropertyChanged(nameof(CurrentGameBackgroundData)); + }); } catch (Exception ex) { @@ -182,7 +190,7 @@ private void ClearMainPageState() private async Task FinalizeLoadRegion(string gameName, string gameRegion, CancellationToken token) { - if (!MetadataHelper.TryGetGameConfig(gameName, gameRegion, out PresetConfig preset)) + if (!MetadataHelper.TryGetGameConfig(gameName, gameRegion, out PresetConfig? preset)) { return; } @@ -205,12 +213,12 @@ private async Task LoadGameStaticsByGameType(PresetConfig preset, string gameNam // Load region property (and potentially, cached one) GamePropertyVault.RegisterGameProperty(this, - preset.GameLauncherApi, + preset.GameLauncherApi!, gameName, gameRegion); // Spawn Region Notification - _ = SpawnRegionNotification(preset.ProfileName); + _ = SpawnRegionNotification(preset.ProfileName ?? ""); } private void DisposeAllPageStatics() @@ -234,7 +242,7 @@ private async Task SpawnRegionNotification(string RegionProfileName) await Task.Delay(250); } - if (NotificationData.RegionPush == null) return; + if (NotificationData?.RegionPush == null) return; List regionPushCopy = new(NotificationData.RegionPush); foreach (NotificationProp Entry in regionPushCopy) @@ -293,7 +301,7 @@ private async void ChangeRegionNoWarning(object sender, RoutedEventArgs e) { try { - (sender as Button).IsEnabled = false; + (sender as Button)?.IsEnabled = false; if (!IsLoadRegionComplete) { return; @@ -396,7 +404,7 @@ private async Task LoadRegionRootButton() // Start region loading _ = ShowAsyncLoadingTimedOutPill(); - if (!await LoadRegionFromCurrentConfigV2(gameRegion, gameTitle, gameRegion.ZoneName)) + if (!await LoadRegionFromCurrentConfigV2(gameRegion, gameTitle, gameRegion.ZoneName ?? "")) { return false; } @@ -428,7 +436,7 @@ private void ToggleChangeRegionBtn(in object sender, bool IsHide) LauncherFrame.BackStack.Clear(); } - (sender as Button).IsEnabled = !IsHide; + (sender as Button)?.IsEnabled = !IsHide; } private async Task ShowAsyncLoadingTimedOutPill(CancellationToken token = default) @@ -445,7 +453,7 @@ private async Task ShowAsyncLoadingTimedOutPill(CancellationToken token = defaul if (!IsLoadRegionComplete && !token.IsCancellationRequested) { - InvokeLoadingRegionPopup(true, Locale.Current.Lang?._MainPage?.RegionLoadingTitle, RegionToChangeName); + InvokeLoadingRegionPopup(true, Locale.Current.Lang?._MainPage?.RegionLoadingTitle ?? "", RegionToChangeName); } } catch (Exception ex) @@ -455,7 +463,7 @@ private async Task ShowAsyncLoadingTimedOutPill(CancellationToken token = defaul } } - private static void InvokeLoadingRegionPopup(bool ShowLoadingMessage = true, string Title = null, string Message = null) + private static void InvokeLoadingRegionPopup(bool ShowLoadingMessage = true, string? Title = null, string? Message = null) { if (ShowLoadingMessage) { diff --git a/CollapseLauncher/Classes/RepairManagement/Genshin/Repair.cs b/CollapseLauncher/Classes/RepairManagement/Genshin/Repair.cs index 0fb0d803a..e5c6ca11e 100644 --- a/CollapseLauncher/Classes/RepairManagement/Genshin/Repair.cs +++ b/CollapseLauncher/Classes/RepairManagement/Genshin/Repair.cs @@ -20,6 +20,7 @@ // ReSharper disable StringLiteralTypo // ReSharper disable CommentTypo +#nullable enable namespace CollapseLauncher { internal partial class GenshinRepair @@ -45,7 +46,15 @@ private async Task Repair(List repairAssetIndex, Can DownloadClient downloadClient = DownloadClient.CreateInstance(client); // Get the Dispatcher Query - QueryProperty queryProperty = await GetCachedDispatcherQuery(downloadClient.GetHttpClient(), token); + QueryProperty? queryProperty = null; + try + { + queryProperty = await GetCachedDispatcherQuery(downloadClient.GetHttpClient(), token); + } + catch (Exception ex) + { + LogWriteLine($"An error has occurred while parsing Persistent Manifests! {ex}", LogType.Warning, true); + } // Iterate repair asset ObservableCollection assetProperty = [.. AssetEntry]; @@ -96,7 +105,10 @@ await Parallel.ForEachAsync( } } - await SavePersistentRevision(queryProperty, token); + if (queryProperty != null) + { + await SavePersistentRevision(queryProperty, token); + } // Duplicate ctable.dat to ctable_streaming.dat string streamingAssetsPath = Path.Combine(GamePath, $"{ExecPrefix}_Data", "StreamingAssets"); @@ -156,7 +168,7 @@ private async Task RepairAssetTypeGeneric((PkgVersionProperties AssetIndex, IAss if (isUseSophonDownload) { ReadOnlySpan splittedPath = asset.AssetIndex.remoteName.TrimStart('\\'); - if (!SophonAssetDictRefLookup.TryGetValue(splittedPath, out SophonAsset downloadAsSophon)) + if (!SophonAssetDictRefLookup.TryGetValue(splittedPath, out SophonAsset? downloadAsSophon)) { throw new InvalidOperationException($"Asset {splittedPath} is marked as \"SophonGeneric\" but it wasn't included in the manifest"); } diff --git a/CollapseLauncher/Classes/RepairManagement/HonkaiV2/HonkaiRepairV2.AsbExt.Video.cs b/CollapseLauncher/Classes/RepairManagement/HonkaiV2/HonkaiRepairV2.AsbExt.Video.cs index a983d5793..9434d82e2 100644 --- a/CollapseLauncher/Classes/RepairManagement/HonkaiV2/HonkaiRepairV2.AsbExt.Video.cs +++ b/CollapseLauncher/Classes/RepairManagement/HonkaiV2/HonkaiRepairV2.AsbExt.Video.cs @@ -5,7 +5,7 @@ using Hi3Helper.Data; using Hi3Helper.EncTool; using Hi3Helper.EncTool.Parser.AssetMetadata; -using Hi3Helper.EncTool.Parser.Cache; +using Hi3Helper.EncTool.Parser.CacheParser; using Hi3Helper.EncTool.Parser.KianaDispatch; using Hi3Helper.Preset; using Hi3Helper.Shared.ClassStruct; @@ -14,12 +14,9 @@ using System.IO; using System.Linq; using System.Net.Http; -using System.Text; using System.Threading; using System.Threading.Tasks; -using CGMetadataHashId = Hi3Helper.EncTool.Parser.Cache.HashID; - // ReSharper disable CheckNamespace // ReSharper disable IdentifierTypo // ReSharper disable StringLiteralTypo @@ -32,6 +29,21 @@ namespace CollapseLauncher.RepairManagement; internal static partial class AssetBundleExtension { internal const string RelativePathVideo = @"BH3_Data\StreamingAssets\Video\"; + internal const string MetadataFilename = "107438912"; + + internal static void RemoveUnlistedVideoAssetFromList(this List originList, + List assetListFromVideo) + { + List originOthersListOnly = originList.Where(x => x.FT != FileType.Video).ToList(); + List originVideoListOnly = originList.Where(x => x.FT == FileType.Video).ToList(); + originList.Clear(); + originList.AddRange(originOthersListOnly); + + HashSet assetListVideoDict = + assetListFromVideo.Select(x => x.N).ToHashSet(StringComparer.OrdinalIgnoreCase); + + originList.AddRange(originVideoListOnly.Where(originVideoAsset => assetListVideoDict.Contains(originVideoAsset.N))); + } internal static async Task> GetVideoAssetListAsync( @@ -49,19 +61,19 @@ internal static async Task> HashSet ignoredCgHashset = new(ignoredCgIds ?? []); List assetInfoList = - await GetCacheAssetBundleListAsync(assetBundleHttpClient, - presetConfig, - gameServerInfo, - CacheAssetType.Data, - progressibleInstance, - token); + await assetBundleHttpClient + .GetCacheAssetBundleListAsync(presetConfig, + gameServerInfo, + CacheAssetType.Data, + progressibleInstance, + token); CacheAssetInfo? cgMetadataFile = assetInfoList - .FirstOrDefault(x => x.Asset.N.EndsWith(CGMetadataHashId.CgMetadataFilename)); + .FirstOrDefault(x => x.Asset.N.EndsWith(MetadataFilename)); if (cgMetadataFile == null) { - Logger.LogWriteLine($"[AssetBundleExtension::GetVideoAssetListAsync] Cannot find CG Metadata file with Asset ID: {CGMetadataHashId.CgMetadataFilename}", + Logger.LogWriteLine($"[AssetBundleExtension::GetVideoAssetListAsync] Cannot find CG Metadata file with Asset ID: {MetadataFilename}", LogType.Error, true); return []; @@ -83,11 +95,11 @@ await GetCacheAssetBundleListAsync(assetBundleHttpClient, cgFileStreamMemory.Position = 0; await using CacheStream dechipheredCgStream = - new CacheStream(cgFileStreamMemory, preSeed: cgMetadataFile.MhyMersenneTwisterSeed); + new(cgFileStreamMemory, preSeed: cgMetadataFile.MhyMersenneTwisterSeed); List cgEntries = []; await Parallel - .ForEachAsync(CGMetadata.Enumerate(dechipheredCgStream, Encoding.UTF8), + .ForEachAsync(KianaCgMetadata.Parse(dechipheredCgStream), new ParallelOptions { CancellationToken = token, @@ -97,56 +109,50 @@ await Parallel return cgEntries; - async ValueTask ImplCheckAndAdd(CGMetadata entry, CancellationToken innerToken) + async ValueTask ImplCheckAndAdd(KeyValuePair entry, CancellationToken innerToken) { - if (entry.InStreamingAssets || - ignoredCgHashset.Contains(entry.CgSubCategory)) + if (ignoredCgHashset.Contains(entry.Value.SubCategoryId)) { return; } - string assetName = gameLanguageType == AudioLanguageType.Japanese - ? entry.CgPathHighBitrateJP - : entry.CgPathHighBitrateCN; + string assetName = (gameLanguageType == AudioLanguageType.Japanese + ? entry.Value.PathJp + : entry.Value.PathCn) ?? throw new NullReferenceException(); assetName += ".usm"; long assetFilesize = gameLanguageType == AudioLanguageType.Japanese - ? entry.FileSizeHighBitrateJP - : entry.FileSizeHighBitrateCN; + ? entry.Value.SizeJp + : entry.Value.SizeCn; foreach (string baseUrl in gameServerInfo.ExternalAssetUrls) { string assetUrl = (isUseHttpRepairOverride ? "http://" : "https://") + baseUrl; assetUrl = assetUrl.CombineURLFromString("Video", assetName); - // If the file has no appoinment schedule (like non-birthday CG), then return true - /* - if (entry.AppointmentDownloadScheduleID == 0) - { - goto AddCgEntry; // I love goto. Dun ask me why :> - } - */ - // Update status if (progressibleInstance != null) { - progressibleInstance.Status.ActivityStatus = string.Format(Locale.Current.Lang?._GameRepairPage?.Status14 ?? "", entry.CgExtraKey); + progressibleInstance.Status.ActivityStatus = string.Format(Locale.Current.Lang?._GameRepairPage?.Status14 ?? "", assetName); progressibleInstance.Status.IsProgressAllIndetermined = true; progressibleInstance.Status.IsProgressPerFileIndetermined = true; progressibleInstance.UpdateStatus(); } - UrlStatus urlStatus = await assetBundleHttpClient.GetURLStatusCode(assetUrl, innerToken); - Logger.LogWriteLine($"The CG asset: {assetName} " + - (urlStatus.IsSuccessStatusCode ? "is" : "is not") + $" available (Status code: {urlStatus.StatusCode})", LogType.Default, true); - if (!urlStatus.IsSuccessStatusCode) + if (entry.Value.Category is CGCategory.Birthday or CGCategory.Activity or CGCategory.VersionPV) { - continue; - } + UrlStatus urlStatus = await assetBundleHttpClient.GetURLStatusCode(assetUrl, innerToken); + Logger.LogWriteLine($"The CG asset: {assetName} " + + (urlStatus.IsSuccessStatusCode ? "is" : "is not") + $" available (Status code: {urlStatus.StatusCode})", LogType.Default, true); + if (!urlStatus.IsSuccessStatusCode) + { + continue; + } - if (urlStatus.FileSize > 0) - { - assetFilesize = urlStatus.FileSize; + if (urlStatus.FileSize > 0) + { + assetFilesize = urlStatus.FileSize; + } } lock (cgEntries) @@ -157,7 +163,7 @@ async ValueTask ImplCheckAndAdd(CGMetadata entry, CancellationToken innerToken) N = assetName, RN = assetUrl, S = assetFilesize, - AssociatedObject = entry + AssociatedObject = entry.Value }); } return; diff --git a/CollapseLauncher/Classes/RepairManagement/HonkaiV2/HonkaiRepairV2.Check.Generic.cs b/CollapseLauncher/Classes/RepairManagement/HonkaiV2/HonkaiRepairV2.Check.Generic.cs index fdefb8d42..74a044ee6 100644 --- a/CollapseLauncher/Classes/RepairManagement/HonkaiV2/HonkaiRepairV2.Check.Generic.cs +++ b/CollapseLauncher/Classes/RepairManagement/HonkaiV2/HonkaiRepairV2.Check.Generic.cs @@ -3,7 +3,9 @@ using CollapseLauncher.RepairManagement; using Hi3Helper; using Hi3Helper.Data; +using Hi3Helper.EncTool; using Hi3Helper.EncTool.Hashes; +using Hi3Helper.EncTool.Parser.CacheParser; using Hi3Helper.EncTool.Parser.Senadina; using Hi3Helper.Shared.ClassStruct; using System; @@ -49,6 +51,39 @@ private async Task IsHashMatchedAuto( token)).IsHashMatched; } + /// + /// Check actual remote size asset from the actual URL.
+ ///
+ /// Note: This method only works on asset type with URL defined. + /// Otherwise, the method will immediately returns . + ///
+ private async ValueTask TryIsAssetRemoteSizeEquals( + FilePropertiesRemote asset, + FileInfo fileInfo, + bool useFastCheck, + CancellationToken token = default) + { + if (!fileInfo.Exists) + { + return false; + } + + if (asset.FT != FileType.Video || + string.IsNullOrEmpty(asset.RN) || + useFastCheck) + { + return true; + } + + UrlStatus status = await HttpClientAssetBundle.GetCachedUrlStatus(asset.RN, token); + if (!status.IsSuccessStatusCode || status.FileSize == 0) // Returns true if status is not successful or size is 0 anyways + { + return true; + } + + return fileInfo.Exists && status.FileSize == fileInfo.Length; + } + private async Task<(bool IsHashMatched, int HashSize)> IsHashMatchedAuto( FilePropertiesRemote asset, byte[] hashBuffer, @@ -78,6 +113,12 @@ private async Task IsHashMatchedAuto( if (!isAssetExist || (assetFileInfo.Length != asset.S && !isSkipSizeCheck)) { + // Try alternate check for video + if (await TryIsAssetRemoteSizeEquals(asset, assetFileInfo, useFastCheck, token)) + { + return (true, hashSize); + } + Interlocked.Add(ref ProgressAllSizeCurrent, asset.S); if (addAssetIfBroken) { diff --git a/CollapseLauncher/Classes/RepairManagement/HonkaiV2/HonkaiRepairV2.Fetch.cs b/CollapseLauncher/Classes/RepairManagement/HonkaiV2/HonkaiRepairV2.Fetch.cs index 9e3f031f5..c0bef2101 100644 --- a/CollapseLauncher/Classes/RepairManagement/HonkaiV2/HonkaiRepairV2.Fetch.cs +++ b/CollapseLauncher/Classes/RepairManagement/HonkaiV2/HonkaiRepairV2.Fetch.cs @@ -5,7 +5,7 @@ using Hi3Helper.Data; using Hi3Helper.EncTool; using Hi3Helper.EncTool.Parser.AssetMetadata; -using Hi3Helper.EncTool.Parser.Cache; +using Hi3Helper.EncTool.Parser.CacheParser; using Hi3Helper.EncTool.Parser.KianaDispatch; using Hi3Helper.EncTool.Parser.Senadina; using Hi3Helper.Shared.ClassStruct; @@ -83,6 +83,7 @@ private async Task FetchAssetFromGameAssetBundle(List asse #region Fetch Video Assets from AssetBundle List assetListFromVideo = []; + List assetListFromVideoOnlyDownloadable = []; Task assetListFromVideoTask = HttpClientAssetBundle .GetVideoAssetListAsync(gamePresetConfig, @@ -93,6 +94,7 @@ private async Task FetchAssetFromGameAssetBundle(List asse .GetResultFromAction(result => { assetListFromVideo.AddRange(result); + assetListFromVideoOnlyDownloadable.AddRange(result.Where(x => ((KianaCgMetadata)x.AssociatedObject).DownloadMode == CGDownloadMode.DownloadTipOnce)); FinalizeVideoAssetsPath(assetListFromVideo); }); #endregion @@ -144,9 +146,18 @@ await Task.WhenAll(assetListFromVideoTask, assetListFromBlockTask); #endregion + #region Remove Video Assets from base + + if (!IsMainAssetOnlyMode && !IsCacheMode) + { + assetIndex.RemoveUnlistedVideoAssetFromList(assetListFromVideo); + } + + #endregion + // Finalize the asset index list by overriding it from above additional sources. FinalizeBaseAssetIndex(assetIndex, - assetListFromVideo, + assetListFromVideoOnlyDownloadable, assetListFromAudio, assetListFromBlock); } @@ -155,6 +166,7 @@ await Task.WhenAll(assetListFromVideoTask, #region Fetch by Game Cache Files private static Task FetchAssetFromGameCacheFiles(List assetIndex, CancellationToken token) { + // TODO: Use it for altering assets for Cache Update mode return Task.CompletedTask; } #endregion @@ -276,7 +288,7 @@ private void FinalizeVideoAssetsPath(List assetList) { string relativePath = Path.Combine(AssetBundleExtension.RelativePathVideo, asset.N); ConverterTool.NormalizePathInplaceNoTrim(relativePath); - if (asset.AssociatedObject is CGMetadata { InStreamingAssets: false }) + if (asset.AssociatedObject is KianaCgMetadata { DownloadMode: CGDownloadMode.DownloadTipOnce }) { versionStreamWriter.WriteLine($"Video/{asset.N}\t1"); } @@ -375,7 +387,7 @@ private async Task FinalizeBlockAssetsPath( CancellationToken token) { // Block assets replacement and add - HashSet oldBlockNames = new HashSet(StringComparer.OrdinalIgnoreCase); + HashSet oldBlockNames = new(StringComparer.OrdinalIgnoreCase); foreach (FilePropertiesRemote asset in targetAssetList) { string relativePath = Path.Combine(AssetBundleExtension.RelativePathBlock, asset.N); diff --git a/CollapseLauncher/CollapseLauncher.csproj b/CollapseLauncher/CollapseLauncher.csproj index a69521d08..d8d13206a 100644 --- a/CollapseLauncher/CollapseLauncher.csproj +++ b/CollapseLauncher/CollapseLauncher.csproj @@ -294,8 +294,8 @@ - - + + @@ -311,13 +311,13 @@ - + - + diff --git a/CollapseLauncher/XAMLs/MainApp/MainPage.Navigation.cs b/CollapseLauncher/XAMLs/MainApp/MainPage.Navigation.cs index 64d936086..fa03a0dfa 100644 --- a/CollapseLauncher/XAMLs/MainApp/MainPage.Navigation.cs +++ b/CollapseLauncher/XAMLs/MainApp/MainPage.Navigation.cs @@ -13,6 +13,8 @@ using Microsoft.UI.Xaml.Input; using Microsoft.UI.Xaml.Media.Animation; using System; +using System.Collections; +using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Numerics; @@ -36,6 +38,31 @@ #nullable enable namespace CollapseLauncher; +file static class NavigationExtension +{ + public static void Add(this IList list, string localePropertyPath, string? iconGlyph = null, object? tag = null) + where TItem : NavigationViewItemBase, new() + { + TItem item = new() { Tag = tag }; + item.BindNavigationViewItemText(Locale.Current, localePropertyPath); + + if (item is NavigationViewItem asItem && iconGlyph != null) + { + asItem.Icon = new FontIcon { Glyph = iconGlyph }; + } + + list.Add(item); + } + + public static void Add(this IList list, string localePropertyPath, string? iconGlyph = null) + where TItem : NavigationViewItemBase, new() + where TPage : notnull + { + Type navigationType = typeof(TPage); + list.Add(localePropertyPath, iconGlyph, navigationType); + } +} + public partial class MainPage : Page { private void InitializeNavigationItems(bool ResetSelection = true) @@ -49,59 +76,50 @@ void Impl() NavigationViewControl.MenuItems.Clear(); NavigationViewControl.FooterMenuItems.Clear(); - IGameVersion? CurrentGameVersionCheck = GetCurrentGameProperty().GameVersion; + GamePresetProperty gameProperty = GetCurrentGameProperty(); + IGameVersion? CurrentGameVersionCheck = gameProperty.GameVersion; - FontIcon IconLauncher = new() { Glyph = "" }; - FontIcon IconRepair = new() { Glyph = "" }; - FontIcon IconCaches = new() { Glyph = m_isWindows11 ? "" : "" }; - FontIcon IconGameSettings = new() { Glyph = "" }; - FontIcon IconAppSettings = new() { Glyph = "" }; - FontIcon IconFilesCleanup = new() { Glyph = "" }; + FontIcon iconAppSettings = new() { Glyph = "" }; + string cachePageGlyph = m_isWindows11 ? "" : ""; if (m_appMode == AppMode.Hi3CacheUpdater) { if (CurrentGameVersionCheck?.GamePreset.IsCacheUpdateEnabled ?? false) { - NavigationViewControl.MenuItems.Add(new NavigationViewItem { Icon = IconCaches, Tag = typeof(CachesPage) }.BindNavigationViewItemText(Locale.Current, "Lang._CachesPage.PageTitle")); + NavigationViewControl.MenuItems.Add("Lang._CachesPage.PageTitle", cachePageGlyph); } return; } - NavigationViewControl.MenuItems.Add(new NavigationViewItem { Icon = IconLauncher, Tag = typeof(HomePage) }.BindNavigationViewItemText(Locale.Current, "Lang._HomePage.PageTitle")); - NavigationViewControl.MenuItems.Add(new NavigationViewItemHeader().BindNavigationViewItemText(Locale.Current, "Lang._MainPage.NavigationUtilities")); + NavigationViewControl.MenuItems.Add("Lang._HomePage.PageTitle", ""); + NavigationViewControl.MenuItems.Add("Lang._MainPage.NavigationUtilities"); if (CurrentGameVersionCheck?.GamePreset.IsRepairEnabled ?? false) { - NavigationViewControl.MenuItems.Add(new NavigationViewItem { Icon = IconRepair, Tag = typeof(RepairPage) }.BindNavigationViewItemText(Locale.Current, "Lang._GameRepairPage.PageTitle")); + NavigationViewControl.MenuItems.Add("Lang._GameRepairPage.PageTitle", ""); } if (CurrentGameVersionCheck?.GamePreset.IsCacheUpdateEnabled ?? false) { - NavigationViewControl.MenuItems.Add(new NavigationViewItem { Icon = IconCaches, Tag = typeof(CachesPage) }.BindNavigationViewItemText(Locale.Current, "Lang._CachesPage.PageTitle")); + NavigationViewControl.MenuItems.Add("Lang._CachesPage.PageTitle", cachePageGlyph); } - switch (CurrentGameVersionCheck?.GameType) + Type? gspPageType = CurrentGameVersionCheck?.GameType switch { - case GameNameType.Honkai: - NavigationViewControl.FooterMenuItems.Add(new NavigationViewItem { Icon = IconGameSettings, Tag = typeof(HonkaiGameSettingsPage) }.BindNavigationViewItemText(Locale.Current, "Lang._GameSettingsPage.PageTitle")); - break; - case GameNameType.StarRail: - NavigationViewControl.FooterMenuItems.Add(new NavigationViewItem { Icon = IconGameSettings, Tag = typeof(StarRailGameSettingsPage) }.BindNavigationViewItemText(Locale.Current, "Lang._StarRailGameSettingsPage.PageTitle")); - break; - case GameNameType.Genshin: - NavigationViewControl.FooterMenuItems.Add(new NavigationViewItem { Icon = IconGameSettings, Tag = typeof(GenshinGameSettingsPage) }.BindNavigationViewItemText(Locale.Current, "Lang._GenshinGameSettingsPage.PageTitle")); - break; - case GameNameType.Zenless: - NavigationViewControl.FooterMenuItems.Add(new NavigationViewItem { Icon = IconGameSettings, Tag = typeof(ZenlessGameSettingsPage) }.BindNavigationViewItemText(Locale.Current, "Lang._GameSettingsPage.PageTitle")); - break; - } + GameNameType.Honkai => typeof(HonkaiGameSettingsPage), + GameNameType.StarRail => typeof(StarRailGameSettingsPage), + GameNameType.Genshin => typeof(GenshinGameSettingsPage), + GameNameType.Zenless => typeof(ZenlessGameSettingsPage), + _ => null + }; - NavigationViewControl.FooterMenuItems.Add(new NavigationViewItem { Icon = IconFilesCleanup, Tag = "filescleanup"}.BindNavigationViewItemText(Locale.Current, "Lang._FileCleanupPage.Title")); + NavigationViewControl.FooterMenuItems.Add("Lang._GameSettingsPage.PageTitle", "", gspPageType); + NavigationViewControl.FooterMenuItems.Add("Lang._FileCleanupPage.Title", "", "filescleanup"); if (NavigationViewControl.SettingsItem is NavigationViewItem settingsItem) { - settingsItem.Tag = typeof(SettingsPage); - settingsItem.Icon = IconAppSettings; + settingsItem.Tag = typeof(SettingsPage); + settingsItem.Icon = iconAppSettings; _ = settingsItem.BindNavigationViewItemText(Locale.Current, "Lang._SettingsPage.PageTitle"); } @@ -125,7 +143,7 @@ void Impl() break; } } - AttachShadowNavigationPanelItem(IconAppSettings); + AttachShadowNavigationPanelItem(iconAppSettings); if (ResetSelection) { @@ -310,6 +328,12 @@ typeOfPageObj is Type currentPageType && } LauncherFrame.Navigate(pageType, null, transitionInfo ?? new DrillInNavigationTransitionInfo()); + + if (isForceLoad) + { + LauncherFrame.BackStack.Clear(); + LauncherFrame.CacheSize = 0; + } break; } case "filescleanup": diff --git a/CollapseLauncher/XAMLs/MainApp/MainPage.xaml b/CollapseLauncher/XAMLs/MainApp/MainPage.xaml index 9b4f885a6..683fb91cf 100644 --- a/CollapseLauncher/XAMLs/MainApp/MainPage.xaml +++ b/CollapseLauncher/XAMLs/MainApp/MainPage.xaml @@ -15,21 +15,29 @@ 0 + + + + + MediaSourceCacheDir="{x:Bind PlaceholderDecodedCacheDir}"> + + + + + + + + + + + diff --git a/CollapseLauncher/XAMLs/MainApp/MainPage.xaml.cs b/CollapseLauncher/XAMLs/MainApp/MainPage.xaml.cs index 9da2562d2..9e77624f7 100644 --- a/CollapseLauncher/XAMLs/MainApp/MainPage.xaml.cs +++ b/CollapseLauncher/XAMLs/MainApp/MainPage.xaml.cs @@ -4,6 +4,8 @@ using CollapseLauncher.Helper; using CollapseLauncher.Helper.Animation; using CollapseLauncher.Helper.Image; +using CollapseLauncher.Helper.LauncherApiLoader; +using CollapseLauncher.Helper.LauncherApiLoader.HoYoPlay; using CollapseLauncher.Helper.Metadata; using CollapseLauncher.Helper.Update; using CollapseLauncher.Pages; @@ -23,10 +25,12 @@ using Microsoft.UI.Xaml.Media.Animation; using System; using System.Collections.Generic; +using System.ComponentModel; using System.Diagnostics; using System.IO; using System.Linq; using System.Numerics; +using System.Runtime.CompilerServices; using System.Security.Principal; using System.Text.Json; using System.Threading; @@ -34,6 +38,7 @@ using Windows.Foundation; using Windows.Graphics; using Windows.UI; +using WinRT; using static Hi3Helper.Shared.Region.LauncherConfig; using UIElementExtensions = CollapseLauncher.Extension.UIElementExtensions; // ReSharper disable StringLiteralTypo @@ -43,8 +48,22 @@ #nullable enable namespace CollapseLauncher { - public partial class MainPage + [GeneratedBindableCustomProperty] + public partial class MainPage : INotifyPropertyChanged { + #region INotifyPropertyChanged + + public event PropertyChangedEventHandler? PropertyChanged; + + // ReSharper disable once UnusedMember.Local + private void OnPropertyChanged([CallerMemberName] string? propertyName = null) + { + // Raise the PropertyChanged event, passing the name of the property whose value has changed. + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } + + #endregion + #region Properties private bool _lockRegionChangeBtn; private bool _disableInstantRegionChange; @@ -58,6 +77,26 @@ public partial class MainPage internal string PlaceholderDecodedCacheDir => AppGameImgFolder; internal ImageBackgroundManager CurrentBackgroundManager => ImageBackgroundManager.Shared; + public HypLauncherBackgroundList? CurrentGameBackgroundData + { + get + { + ILauncherApi? api = CurrentPresetConfig?.GameLauncherApi; + HypLauncherBackgroundList? data = api?.LauncherGameBackground?.Data; + return data; + } + } + + public bool NeedShowEventIcon + { + get => GetAppConfigValue("ShowEventsPanel"); + set + { + SetAndSaveConfigValue("ShowEventsPanel", value); + OnPropertyChanged(); + } + } + #endregion #region Main Routine @@ -80,6 +119,7 @@ public MainPage() // Enable implicit animation on certain elements AnimationHelper.EnableImplicitAnimation(true, null, GridBGRegionGrid, GridBGNotifBtn, NotificationPanelClearAllGrid); + ImageEventImgViewBox.EnableElementVisibilityAnimation(); } catch (Exception ex) { @@ -250,20 +290,7 @@ private async void ErrorSenderInvoker_ExceptionEvent(object? sender, ErrorProper private void MainFrameChangerInvoker_FrameEvent(object? sender, MainFrameProperties e) { InnerLauncherConfig.m_appCurrentFrameName = e.FrameTo.Name; - - int previousStackLimit = LauncherFrame.CacheSize; - if (e.RequireCacheReset) - { - LauncherFrame.BackStack.Clear(); - LauncherFrame.CacheSize = 0; - NavigationViewControl.SelectedItem = null; - } - TryNavigateFrom(e.FrameTo, e.Transition, e.RequireCacheReset); - if (e.RequireCacheReset) - { - LauncherFrame.CacheSize = previousStackLimit; - } } private void MainFrameChangerInvoker_FrameGoBackEvent(object? sender, EventArgs e) @@ -881,5 +908,17 @@ private void SpawnWebView2Panel(Uri url) } } #endregion + + private void ClickImageEventSpriteLink(object sender, PointerRoutedEventArgs e) + { + if (sender is not FrameworkElement asUiElement || + !e.GetCurrentPoint(asUiElement).Properties.IsLeftButtonPressed || + asUiElement.Tag is not string url) + { + return; + } + + SpawnWebView2.SpawnWebView2Window(url, Content); + } } } diff --git a/CollapseLauncher/XAMLs/MainApp/Pages/CachesPage.xaml b/CollapseLauncher/XAMLs/MainApp/Pages/CachesPage.xaml index 232177a8f..f9c7d4d06 100644 --- a/CollapseLauncher/XAMLs/MainApp/Pages/CachesPage.xaml +++ b/CollapseLauncher/XAMLs/MainApp/Pages/CachesPage.xaml @@ -10,7 +10,6 @@ xmlns:interactivity="using:Microsoft.Xaml.Interactivity" xmlns:local="using:CollapseLauncher" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" - CacheMode="BitmapCache" Loaded="InitializeLoaded" NavigationCacheMode="Disabled" Unloaded="Page_Unloaded" diff --git a/CollapseLauncher/XAMLs/MainApp/Pages/Dialogs/DownloadSettings.xaml b/CollapseLauncher/XAMLs/MainApp/Pages/Dialogs/DownloadSettings.xaml index 66a2a0b73..78fc2454a 100644 --- a/CollapseLauncher/XAMLs/MainApp/Pages/Dialogs/DownloadSettings.xaml +++ b/CollapseLauncher/XAMLs/MainApp/Pages/Dialogs/DownloadSettings.xaml @@ -5,7 +5,6 @@ xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:helper="using:CollapseLauncher.Helper" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" - CacheMode="BitmapCache" Loaded="Control_Loaded" mc:Ignorable="d"> diff --git a/CollapseLauncher/XAMLs/MainApp/Pages/Dialogs/SimpleDialogs.cs b/CollapseLauncher/XAMLs/MainApp/Pages/Dialogs/SimpleDialogs.cs index 49de5501c..eb088f14a 100644 --- a/CollapseLauncher/XAMLs/MainApp/Pages/Dialogs/SimpleDialogs.cs +++ b/CollapseLauncher/XAMLs/MainApp/Pages/Dialogs/SimpleDialogs.cs @@ -1910,7 +1910,56 @@ void HomepageHyperlinkOnClick(Hyperlink sender, HyperlinkClickEventArgs args) closeText: Locale.Current.Lang?._Misc?.IDoNotAcceptAgreement, primaryText: Locale.Current.Lang?._Misc?.IAcceptAgreement, defaultButton: ContentDialogButton.Primary, - dialogTheme: ContentDialogTheme.Informational); + dialogTheme: ContentDialogTheme.Informational, + onLoaded: (sender, _) => + { + if (sender is not ContentDialog dialog) + return; + + // Disable the primary ("I accept") button until user scrolls to the bottom + dialog.IsPrimaryButtonEnabled = false; + + // The ContentDialog wraps its Content in a ScrollViewer. + // Find it and listen for scroll changes. + ScrollViewer? sv = dialog.FindDescendant(); + if (sv == null) + return; + + sv.ViewChanged += OnViewChanged; + + // If content is short enough to not need scrolling, + // enable the button after layout completes. + sv.SizeChanged += OnSizeChanged; + + void OnSizeChanged(object s, SizeChangedEventArgs e) + { + if (s is not ScrollViewer scrollViewer) + return; + + if (scrollViewer.ScrollableHeight < 1) + { + dialog.IsPrimaryButtonEnabled = true; + scrollViewer.ViewChanged -= OnViewChanged; + scrollViewer.SizeChanged -= OnSizeChanged; + } + } + + void OnViewChanged(object s, ScrollViewerViewChangedEventArgs args) + { + if (args.IsIntermediate) + return; + + if (s is not ScrollViewer scrollViewer) + return; + + if (scrollViewer.VerticalOffset >= scrollViewer.ScrollableHeight - 40) + { + dialog.IsPrimaryButtonEnabled = true; + scrollViewer.ViewChanged -= OnViewChanged; + scrollViewer.SizeChanged -= OnSizeChanged; + } + } + }); if (result == ContentDialogResult.None) { return false; diff --git a/CollapseLauncher/XAMLs/MainApp/Pages/GameSettingsPages/GenshinGameSettingsPage.xaml b/CollapseLauncher/XAMLs/MainApp/Pages/GameSettingsPages/GenshinGameSettingsPage.xaml index b1133f857..0c3aae292 100644 --- a/CollapseLauncher/XAMLs/MainApp/Pages/GameSettingsPages/GenshinGameSettingsPage.xaml +++ b/CollapseLauncher/XAMLs/MainApp/Pages/GameSettingsPages/GenshinGameSettingsPage.xaml @@ -12,7 +12,6 @@ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:static="using:CollapseLauncher.GameSettings.Genshin" xmlns:xaml="using:Microsoft.Graphics.Canvas.UI.Xaml" - CacheMode="BitmapCache" Loaded="InitializeSettings" NavigationCacheMode="Enabled" mc:Ignorable="d"> diff --git a/CollapseLauncher/XAMLs/MainApp/Pages/GameSettingsPages/HonkaiGameSettingsPage.xaml b/CollapseLauncher/XAMLs/MainApp/Pages/GameSettingsPages/HonkaiGameSettingsPage.xaml index 7389d3377..b77bb302f 100644 --- a/CollapseLauncher/XAMLs/MainApp/Pages/GameSettingsPages/HonkaiGameSettingsPage.xaml +++ b/CollapseLauncher/XAMLs/MainApp/Pages/GameSettingsPages/HonkaiGameSettingsPage.xaml @@ -9,7 +9,6 @@ xmlns:extension="using:CollapseLauncher.Extension" xmlns:helper="using:CollapseLauncher.Helper" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" - CacheMode="BitmapCache" Loaded="InitializeSettings" NavigationCacheMode="Enabled" mc:Ignorable="d"> diff --git a/CollapseLauncher/XAMLs/MainApp/Pages/GameSettingsPages/StarRailGameSettingsPage.xaml b/CollapseLauncher/XAMLs/MainApp/Pages/GameSettingsPages/StarRailGameSettingsPage.xaml index 1b099dfdf..716480b22 100644 --- a/CollapseLauncher/XAMLs/MainApp/Pages/GameSettingsPages/StarRailGameSettingsPage.xaml +++ b/CollapseLauncher/XAMLs/MainApp/Pages/GameSettingsPages/StarRailGameSettingsPage.xaml @@ -10,7 +10,6 @@ xmlns:helper="using:CollapseLauncher.Helper" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:static="using:CollapseLauncher.GameSettings.StarRail" - CacheMode="BitmapCache" Loaded="InitializeSettings" NavigationCacheMode="Enabled" mc:Ignorable="d"> diff --git a/CollapseLauncher/XAMLs/MainApp/Pages/GameSettingsPages/ZenlessGameSettingsPage.xaml b/CollapseLauncher/XAMLs/MainApp/Pages/GameSettingsPages/ZenlessGameSettingsPage.xaml index 4e93210c2..ed2b85218 100644 --- a/CollapseLauncher/XAMLs/MainApp/Pages/GameSettingsPages/ZenlessGameSettingsPage.xaml +++ b/CollapseLauncher/XAMLs/MainApp/Pages/GameSettingsPages/ZenlessGameSettingsPage.xaml @@ -12,7 +12,6 @@ xmlns:helper="using:CollapseLauncher.Helper" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:static="using:CollapseLauncher.GameSettings.Zenless" - CacheMode="BitmapCache" Loaded="InitializeSettings" NavigationCacheMode="Enabled" mc:Ignorable="d"> diff --git a/CollapseLauncher/XAMLs/MainApp/Pages/HomePage.Variable.cs b/CollapseLauncher/XAMLs/MainApp/Pages/HomePage.Variable.cs index 3046b193a..55f48f32f 100644 --- a/CollapseLauncher/XAMLs/MainApp/Pages/HomePage.Variable.cs +++ b/CollapseLauncher/XAMLs/MainApp/Pages/HomePage.Variable.cs @@ -121,6 +121,7 @@ internal bool IsShowSidePanel { SetAndSaveConfigValue("ShowEventsPanel", value); HideImageCarousel(!value); + InnerLauncherConfig.m_mainPage?.NeedShowEventIcon = value; } } diff --git a/CollapseLauncher/XAMLs/MainApp/Pages/HomePage.xaml b/CollapseLauncher/XAMLs/MainApp/Pages/HomePage.xaml index ea8d8abd9..8c186ecbe 100644 --- a/CollapseLauncher/XAMLs/MainApp/Pages/HomePage.xaml +++ b/CollapseLauncher/XAMLs/MainApp/Pages/HomePage.xaml @@ -28,6 +28,7 @@ + @@ -169,21 +170,6 @@ Background="Transparent" ContextFlyout="{StaticResource BackgroundContextMenuFlyout}" DataContext="{x:Bind CurrentBackgroundManager, Mode=OneWay}" /> - - - - GetAppConfigValue("ShowEventsPanel").ToBool(); - - private static void ReturnToHomePage() => MainFrameChanger.ChangeMainFrame(typeof(HomePage), true); + private void ReturnToHomePage() + { + if (!(m_mainPage?.TryGetCurrentPageObject(out object typeOfPage) ?? false) || + typeOfPage is not Type asTypePage || + asTypePage != typeof(HomePage) || + GamePropertyVault.GetCurrentGameProperty() != CurrentGameProperty) + { + return; + } + MainFrameChanger.ChangeMainFrame(typeof(HomePage), true); + } private async void Page_Loaded(object sender, RoutedEventArgs e) { @@ -169,8 +176,6 @@ private async void Page_Loaded(object sender, RoutedEventArgs e) !(CurrentGameProperty.GameSettings?.SettingsCollapseMisc.IsSyncPlaytimeToDatabase ?? false)) SyncDbPlaytimeBtn.IsEnabled = false; - TryLoadEventPanelImage(); - if (IsCarouselPanelAvailable || IsNewsPanelAvailable) { ImageCarouselPipsPager.Visibility = Visibility.Visible; @@ -326,30 +331,6 @@ private void Page_Unloaded(object sender, RoutedEventArgs e) } #endregion - #region EventPanel - - private static IValueConverter ImageUrlCacheConverter; - - private void TryLoadEventPanelImage() - { - // Get the url and article image path - // Set event icon props - ImageEventImgGrid.Visibility = !NeedShowEventIcon ? Visibility.Collapsed : Visibility.Visible; - - ImageEventImg.BindProperty(GameBackgroundData, - nameof(HypLauncherBackgroundList.FeaturedEventIconUrl), - Image.SourceProperty, - BindingMode.OneWay, - ImageUrlCacheConverter ??= new UrlToCachedImagePathConverter()); - - ImageEventImg.BindProperty(GameBackgroundData, - nameof(HypLauncherBackgroundList.FeaturedEventIconClickLink), - TagProperty, - BindingMode.OneWay); - } - - #endregion - #region Carousel public void StartCarouselSlideshow() @@ -587,13 +568,8 @@ private void HideSocMedFlyout(object sender, PointerRoutedEventArgs e) #endregion #region Event Image - private async void HideImageEventImg(bool hide) + private static void HideImageEventImg(bool hide) { - //if (!NeedShowEventIcon) return; - - if (!hide) - ImageEventImgGrid.Visibility = Visibility.Visible; - Storyboard storyboard = new(); DoubleAnimation OpacityAnimation = new() { @@ -602,15 +578,11 @@ private async void HideImageEventImg(bool hide) Duration = new Duration(TimeSpan.FromSeconds(0.10)) }; - Storyboard.SetTarget(OpacityAnimation, ImageEventImgGrid); + Storyboard.SetTarget(OpacityAnimation, m_mainPage?.ImageEventImg); Storyboard.SetTargetProperty(OpacityAnimation, "Opacity"); storyboard.Children.Add(OpacityAnimation); storyboard.Begin(); - - await Task.Delay(100); - - ImageEventImgGrid.Visibility = hide ? Visibility.Collapsed : Visibility.Visible; } #endregion @@ -664,14 +636,6 @@ private async void OpenButtonLinkFromTag(object sender, RoutedEventArgs e) // Open the URL and spawn WebView2 window SpawnWebView2.SpawnWebView2Window(tagProperty[0], Content); } - - private void ClickImageEventSpriteLink(object sender, PointerRoutedEventArgs e) - { - if (!e.GetCurrentPoint((UIElement)sender).Properties.IsLeftButtonPressed) return; - object ImageTag = ((Image)sender).Tag; - if (ImageTag == null) return; - SpawnWebView2.SpawnWebView2Window((string)ImageTag, Content); - } #endregion #region Tag Property diff --git a/CollapseLauncher/XAMLs/MainApp/Pages/RepairPage.xaml b/CollapseLauncher/XAMLs/MainApp/Pages/RepairPage.xaml index d7765be17..57a2d157d 100644 --- a/CollapseLauncher/XAMLs/MainApp/Pages/RepairPage.xaml +++ b/CollapseLauncher/XAMLs/MainApp/Pages/RepairPage.xaml @@ -13,7 +13,6 @@ xmlns:interactivity="using:Microsoft.Xaml.Interactivity" xmlns:local="using:CollapseLauncher" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" - CacheMode="BitmapCache" Loaded="InitializeLoaded" NavigationCacheMode="Disabled" Unloaded="Page_Unloaded" diff --git a/CollapseLauncher/XAMLs/MainApp/Pages/SettingsPage.xaml b/CollapseLauncher/XAMLs/MainApp/Pages/SettingsPage.xaml index de67b0529..7cc2dcfdb 100644 --- a/CollapseLauncher/XAMLs/MainApp/Pages/SettingsPage.xaml +++ b/CollapseLauncher/XAMLs/MainApp/Pages/SettingsPage.xaml @@ -17,7 +17,6 @@ xmlns:localeSourceGen="using:Hi3Helper.LocaleSourceGen" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:plugins="using:CollapseLauncher.Plugins" - CacheMode="BitmapCache" Loaded="Page_Loaded" NavigationCacheMode="Enabled" Unloaded="Page_Unloaded" diff --git a/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Events.Control.cs b/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Events.Control.cs index b7298e8b0..48ae65fde 100644 --- a/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Events.Control.cs +++ b/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Events.Control.cs @@ -321,7 +321,7 @@ private static void MediaDurationPosition_OnChanged(DependencyObject d, Dependen { value = TimeSpan.Zero; } - instance._videoPlayer.Position = value; + instance._videoPlayer?.Position = value; } catch (Exception ex) { diff --git a/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Events.FrameInitializer.cs b/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Events.FrameInitializer.cs index 277b94da8..be3500598 100644 --- a/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Events.FrameInitializer.cs +++ b/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Events.FrameInitializer.cs @@ -57,7 +57,7 @@ private void InitializeRenderTargetSize(MediaPlaybackSession playbackSession) { try { - double currentCanvasWidth = playbackSession.NaturalVideoWidth; + double currentCanvasWidth = playbackSession.NaturalVideoWidth; double currentCanvasHeight = playbackSession.NaturalVideoHeight; _canvasWidth = (int)currentCanvasWidth; @@ -66,12 +66,11 @@ private void InitializeRenderTargetSize(MediaPlaybackSession playbackSession) // In some occasion, MediaPlayer reportedly 0x0px size which causes E_INVALIDARG while rendering frame // if FFmpeg source is used. So, use size reported by FFmpeg instead. - if (_canvasRenderSize == default && _videoFfmpegMediaSource != null) - { - _canvasWidth = _videoFfmpegMediaSource.CurrentVideoStream.PixelWidth; - _canvasHeight = _videoFfmpegMediaSource.CurrentVideoStream.PixelHeight; - _canvasRenderSize = new Rect(0, 0, _canvasWidth, _canvasHeight); - } + if (_canvasRenderSize != default || _videoFfmpegMediaSource == null) return; + + _canvasWidth = _videoFfmpegMediaSource.CurrentVideoStream.PixelWidth; + _canvasHeight = _videoFfmpegMediaSource.CurrentVideoStream.PixelHeight; + _canvasRenderSize = new Rect(0, 0, _canvasWidth, _canvasHeight); } catch (Exception ex) { @@ -183,9 +182,9 @@ private void DisposeVideoPlayer(bool disposeRenderImageSource = true) NullifyMediaPlayerNativePointers(); } - // ReSharper disable once ConstantConditionalAccessQualifier - Interlocked.Exchange(ref _videoPlayer, null!)?.Dispose(); Interlocked.Exchange(ref _videoFfmpegMediaSource, null)?.Dispose(); + // ReSharper disable once ConstantConditionalAccessQualifier + Interlocked.Exchange(ref _videoPlayer, null!)?.Dispose(); DisposeRenderTarget(disposeRenderImageSource); } @@ -197,9 +196,6 @@ private void DisposeVideoPlayer(bool disposeRenderImageSource = true) } finally { - GC.Collect(); - GC.WaitForPendingFinalizers(); - Interlocked.Exchange(ref _isVideoInitialized, 0); } } diff --git a/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Events.FrameRenderer.cs b/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Events.FrameRenderer.cs index a88b2841a..8cb73d9e8 100644 --- a/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Events.FrameRenderer.cs +++ b/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Events.FrameRenderer.cs @@ -49,9 +49,9 @@ private static ref readonly Guid IMediaPlayer5_IID #region Direct Function Table Call Delegates - private static unsafe delegate* unmanaged[Stdcall] _functionTableBeginDraw; - private static unsafe delegate* unmanaged[Stdcall] _functionTableDrawImage; - private static unsafe delegate* unmanaged[Stdcall] _functionTableDispose; + private static unsafe delegate* unmanaged[Stdcall] _functionTableBeginDraw; + private static unsafe delegate* unmanaged[Stdcall] _functionTableDrawImage; + private static unsafe delegate* unmanaged[Stdcall] _functionTableDispose; #endregion @@ -76,7 +76,7 @@ private static ref readonly Guid IMediaPlayer5_IID private int _canvasHeight; private Rect _canvasRenderSize; - private MediaPlayer _videoPlayer = null!; + private MediaPlayer? _videoPlayer; private nint _videoPlayerPtr = nint.Zero; private CancellationTokenSource? _videoPlayerFadeCts; private FFmpegMediaSource? _videoFfmpegMediaSource; @@ -193,7 +193,7 @@ private void VideoPlayer_VideoFrameAvailableSafe(MediaPlayer sender, object args return; } - _videoPlayer.CopyFrameToVideoSurface(_canvasRenderTarget); + _videoPlayer?.CopyFrameToVideoSurface(_canvasRenderTarget); ds = _canvasImageSource?.CreateDrawingSession(default, _canvasRenderSize); ds?.DrawImage(_canvasRenderTarget); } @@ -450,7 +450,7 @@ public void DisposeAndPauseVideoView(Action? actionOnPause Interlocked.Exchange(ref _isBlockVideoFrameDraw, 0); // Make sure to unblock if request is cancelled }); // Set events - DispatcherQueue.TryEnqueue(() => SetValue(IsVideoPlayProperty, false)); + DispatcherQueue?.TryEnqueue(() => SetValue(IsVideoPlayProperty, false)); actionOnPause?.Invoke(); if (_videoPlayer == null!) @@ -516,8 +516,8 @@ private void PlayVideoView(Action? actionAfterPause = null, double volumeFadeResolutionMs = 10d, CancellationToken token = default) { - _videoPlayer.Volume = 0; - _videoPlayer.Play(); + _videoPlayer?.Volume = 0; + _videoPlayer?.Play(); SetValue(IsVideoPlayProperty, true); actionAfterPause?.Invoke(); @@ -535,7 +535,7 @@ private void PauseVideoView(Action? actionAfterPause = null, void ActionAfterPauseInject() { - _videoPlayer.Pause(); + _videoPlayer?.Pause(); DispatcherQueueExtensions.TryEnqueue(() => SetValue(IsVideoPlayProperty, false)); actionAfterPause?.Invoke(); } diff --git a/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Events.Loaders.cs b/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Events.Loaders.cs index 83379c3e8..ca1372b90 100644 --- a/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Events.Loaders.cs +++ b/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Events.Loaders.cs @@ -18,6 +18,9 @@ using System.Threading.Tasks; using Windows.Media.Playback; using Windows.Storage.Streams; +// ReSharper disable IdentifierTypo +#pragma warning disable IDE0051 +#pragma warning disable CS8321 // Local function is declared but never used // ReSharper disable StringLiteralTypo // ReSharper disable CommentTypo @@ -220,14 +223,19 @@ private void LoadFromSourceAsyncDetached( DisposeAndPauseVideoView(); } - InnerLoadDetached(); + _ = InnerLoadDetached().ConfigureAwait(false); lastMediaType = mediaType; return; - async void InnerLoadDetached() + async Task InnerLoadDetached() { try { + if (!IsLoaded || grid.IsObjectDisposed()) + { + return; + } + // ReSharper disable once ConvertIfStatementToSwitchStatement if (mediaType == MediaSourceType.Image && await LoadImageFromSourceAsync(source, @@ -283,7 +291,6 @@ private static async ValueTask LoadImageFromSourceAsync( image.BindProperty(instance, horizontalAlignmentProperty, HorizontalAlignmentProperty, BindingMode.OneWay); image.BindProperty(instance, verticalAlignmentProperty, VerticalAlignmentProperty, BindingMode.OneWay); - image.Transitions.Add(new ContentThemeTransition()); grid.Children.Add(image); image.Tag = (grid, instance); @@ -353,8 +360,8 @@ private static async Task LoadVideoFromSourceAsync( // Set-ups Video Player upfront instance.InitializeVideoPlayer(); - bool useFfmpeg = instance.UseFfmpegDecoder; - MediaPlayer player = instance._videoPlayer; + bool useFfmpeg = instance.UseFfmpegDecoder; + MediaPlayer? player = instance._videoPlayer; // Assign media source using FFmpeg. if (useFfmpeg) @@ -367,7 +374,10 @@ private static async Task LoadVideoFromSourceAsync( { Video = { - MaxDecoderThreads = (uint)Environment.ProcessorCount + MaxDecoderThreads = (uint)Environment.ProcessorCount, + VideoOutputAllow10bit = true, + VideoOutputAllowBgra8 = true, + VideoOutputAllowNv12 = true } }; @@ -384,10 +394,7 @@ private static async Task LoadVideoFromSourceAsync( } else if (sourceUri != null) { - string sourceUriStr = sourceUri.IsFile ? sourceUri.LocalPath : sourceUri.ToString(); - ffmpegMediaSource = sourceUri.IsFile - ? await FFmpegMediaSource.CreateFromFileAsync(sourceUriStr, ffmpegConfig, windowId.Value) - : await FFmpegMediaSource.CreateFromUriAsync(sourceUriStr, ffmpegConfig, windowId.Value); + ffmpegMediaSource = await CreateFFmpegSourceFromUri(sourceUri, ffmpegConfig, windowId.Value); } // Yeet @@ -410,7 +417,7 @@ private static async Task LoadVideoFromSourceAsync( // Unsubscribe frame renderer event to avoid double call, and then mark deinitialization. Interlocked.Exchange(ref instance._isVideoInitialized, 0); - player.VideoFrameAvailable -= !instance.UseSafeFrameRenderer + player?.VideoFrameAvailable -= !instance.UseSafeFrameRenderer ? instance.VideoPlayer_VideoFrameAvailableUnsafe : instance.VideoPlayer_VideoFrameAvailableSafe; @@ -444,11 +451,11 @@ private static async Task LoadVideoFromSourceAsync( if (sourceStream != null) { IRandomAccessStream sourceStreamRandom = sourceStream.AsRandomAccessStream(true); - player.SetStreamSource(sourceStreamRandom); + player?.SetStreamSource(sourceStreamRandom); } else if (sourceUri != null) { - instance._videoPlayer.SetUriSource(sourceUri); + instance._videoPlayer?.SetUriSource(sourceUri); } else { @@ -493,6 +500,11 @@ private void InitializeVideoFrameOnMediaOpened(MediaPlayer sender, object args) void Impl() { + if (!IsLoaded) + { + return; + } + // Update Media Duration and Update Binding to it. SetValue(MediaDurationProperty, sender.NaturalDuration); SetValue(IsCurrentMediaSeekableProperty, sender.CanSeek); @@ -537,11 +549,11 @@ private static void Image_VideoFrameOnLoaded(object sender, RoutedEventArgs e) return; } - Image_ImageOpened(sender, e); if (!parentGrid.Item2.IsVideoAutoplay) return; Interlocked.Exchange(ref parentGrid.Item2._videoState, VideoState.Playing); parentGrid.Item2.InitializeAndPlayVideoView(); + Image_ImageOpened(sender, e); } private static void Image_VideoFrameOnUnloaded(object sender, RoutedEventArgs e) @@ -643,4 +655,24 @@ private static void ClearMediaGrid(Grid grid, UIElement? except = null) } #endregion + + #region URI to Stream Source Creator + + private static async Task CreateFFmpegSourceFromUri(Uri uri, + MediaSourceConfig sourceConfig, + ulong windowId) + { + string sourceUriStr = uri.IsFile ? uri.LocalPath : uri.ToString(); + FFmpegMediaSource? source = null; + + if (!uri.IsFile) + { + source = await FFmpegMediaSource.CreateFromUriAsync(sourceUriStr, sourceConfig, windowId); + } + + source ??= await FFmpegMediaSource.CreateFromFileAsync(sourceUriStr, sourceConfig, windowId); + return source ?? throw new InvalidOperationException("Cannot create stream. This shouldn't happen!"); + } + + #endregion } diff --git a/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Events.cs b/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Events.cs index fe235f321..8409389ae 100644 --- a/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Events.cs +++ b/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Events.cs @@ -2,8 +2,11 @@ using CollapseLauncher.Helper; using Microsoft.UI.Composition; using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Input; +using Microsoft.UI.Xaml.Media.Imaging; using System; +using System.Linq; using System.Numerics; using System.Threading; using Windows.Foundation; @@ -13,6 +16,7 @@ namespace CollapseLauncher.XAMLs.Theme.CustomControls; public partial class LayeredBackgroundImage { public event Action? ImageLoaded; + public event Action? CanvasSizeChanged; #region Fields @@ -381,9 +385,28 @@ private void NotifyImageLoaded() { ImageLoaded?.Invoke(this); } - } + // Update canvas width/height property + UIElement? canvasElement = _foregroundGrid.Children.LastOrDefault() // Favor foreground first + ?? _backgroundGrid.Children.LastOrDefault(); + Size size = default; + if (canvasElement is Image image) + { + if (image.Source is BitmapSource asBitmapSource) + { + size = new Size(asBitmapSource.PixelWidth, asBitmapSource.PixelHeight); + } + else + { + size = image.RenderSize; + } + } + + SetValue(CanvasWidthProperty, size.Width); + SetValue(CanvasHeightProperty, size.Height); + CanvasSizeChanged?.Invoke(this, size); + } #endregion diff --git a/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Properties.cs b/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Properties.cs index 0db1aa03d..2d541a704 100644 --- a/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Properties.cs +++ b/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Properties.cs @@ -210,6 +210,10 @@ private bool IsUseStaticBackgroundUsed set; } + public double CanvasWidth => (double)GetValue(CanvasWidthProperty); + + public double CanvasHeight => (double)GetValue(CanvasHeightProperty); + #endregion #region Fields @@ -424,5 +428,17 @@ private bool IsUseStaticBackgroundUsed typeof(LayeredBackgroundImage), new PropertyMetadata(null!)); + public static readonly DependencyProperty CanvasWidthProperty = + DependencyProperty.Register(nameof(CanvasWidth), + typeof(double), + typeof(LayeredBackgroundImage), + new PropertyMetadata(0d)); + + public static readonly DependencyProperty CanvasHeightProperty = + DependencyProperty.Register(nameof(CanvasHeight), + typeof(double), + typeof(LayeredBackgroundImage), + new PropertyMetadata(0d)); + #endregion } diff --git a/CollapseLauncher/XAMLs/Theme/CustomControls/NewPipsPager.Events.cs b/CollapseLauncher/XAMLs/Theme/CustomControls/NewPipsPager.Events.cs index fcdcb79cc..5fc4b75a2 100644 --- a/CollapseLauncher/XAMLs/Theme/CustomControls/NewPipsPager.Events.cs +++ b/CollapseLauncher/XAMLs/Theme/CustomControls/NewPipsPager.Events.cs @@ -1,4 +1,5 @@ #nullable enable +using CollapseLauncher.Extension; using Microsoft.UI.Input; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; diff --git a/CollapseLauncher/XAMLs/Theme/CustomControls/NewPipsPager.Templates.cs b/CollapseLauncher/XAMLs/Theme/CustomControls/NewPipsPager.Templates.cs index 91a4b5e8c..7e7578262 100644 --- a/CollapseLauncher/XAMLs/Theme/CustomControls/NewPipsPager.Templates.cs +++ b/CollapseLauncher/XAMLs/Theme/CustomControls/NewPipsPager.Templates.cs @@ -1,5 +1,6 @@ #nullable enable using CollapseLauncher.Extension; +using CollapseLauncher.Helper; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; using System.Threading; @@ -60,6 +61,7 @@ protected override void OnApplyTemplate() Loaded += NewPipsPager_Loaded; Unloaded += NewPipsPager_Unloaded; + ApplyNavigationButtonLocaleBind(); ApplyNavigationButtonEvents(); ApplyInitialTemplates(); ApplyKeyPressEvents(); @@ -69,7 +71,13 @@ protected override void OnApplyTemplate() private void ApplyInitialTemplates() { _pipsPagerItemsRepeater.ItemsSource ??= _itemsDummy; - Orientation_OnChange((NewPipsPager)this, Orientation); + Orientation_OnChange(this, Orientation); + } + + private void ApplyNavigationButtonLocaleBind() + { + _previousPageButton.BindTooltipToLocale(Locale.Current, "Lang._Misc.Prev"); + _nextPageButton.BindTooltipToLocale(Locale.Current, "Lang._Misc.Next"); } private void ApplyNavigationButtonEvents() diff --git a/CollapseLauncher/XAMLs/Theme/CustomControls/NewPipsPager.cs b/CollapseLauncher/XAMLs/Theme/CustomControls/NewPipsPager.cs index 752431b7f..e0f902506 100644 --- a/CollapseLauncher/XAMLs/Theme/CustomControls/NewPipsPager.cs +++ b/CollapseLauncher/XAMLs/Theme/CustomControls/NewPipsPager.cs @@ -1,5 +1,4 @@ #nullable enable -using CollapseLauncher.Extension; using Microsoft.UI.Xaml.Controls; namespace CollapseLauncher.XAMLs.Theme.CustomControls; diff --git a/CollapseLauncher/XAMLs/Theme/CustomControls/NewPipsPager.xaml b/CollapseLauncher/XAMLs/Theme/CustomControls/NewPipsPager.xaml index 0315ded65..bddbb3152 100644 --- a/CollapseLauncher/XAMLs/Theme/CustomControls/NewPipsPager.xaml +++ b/CollapseLauncher/XAMLs/Theme/CustomControls/NewPipsPager.xaml @@ -111,11 +111,13 @@