diff --git a/CollapseLauncher.slnx b/CollapseLauncher.slnx index bfdd010376..04fa93e882 100644 --- a/CollapseLauncher.slnx +++ b/CollapseLauncher.slnx @@ -53,6 +53,10 @@ + + + + diff --git a/CollapseLauncher/App.xaml.cs b/CollapseLauncher/App.xaml.cs index 327484fb9c..fdbbb153fb 100644 --- a/CollapseLauncher/App.xaml.cs +++ b/CollapseLauncher/App.xaml.cs @@ -137,12 +137,6 @@ protected override void OnLaunched(LaunchActivatedEventArgs args) { ImageLoaderHelper.InitWaifu2X(); } - - // Initialize support for MagicScaler's WebP decoding - CodecManager.Configure(codecs => - { - codecs.UseLibwebp(); - }); } catch (Exception ex) { diff --git a/CollapseLauncher/Assets/Images/GameBackground/genshin.avif b/CollapseLauncher/Assets/Images/GameBackground/genshin.avif new file mode 100644 index 0000000000..fca3d6690f Binary files /dev/null and b/CollapseLauncher/Assets/Images/GameBackground/genshin.avif differ diff --git a/CollapseLauncher/Assets/Images/GameBackground/genshin.webp b/CollapseLauncher/Assets/Images/GameBackground/genshin.webp deleted file mode 100644 index 2b38257579..0000000000 Binary files a/CollapseLauncher/Assets/Images/GameBackground/genshin.webp and /dev/null differ diff --git a/CollapseLauncher/Assets/Images/GameBackground/honkai.avif b/CollapseLauncher/Assets/Images/GameBackground/honkai.avif new file mode 100644 index 0000000000..478fdfa9d6 Binary files /dev/null and b/CollapseLauncher/Assets/Images/GameBackground/honkai.avif differ diff --git a/CollapseLauncher/Assets/Images/GameBackground/honkai.webp b/CollapseLauncher/Assets/Images/GameBackground/honkai.webp deleted file mode 100644 index 8d0ed46ea8..0000000000 Binary files a/CollapseLauncher/Assets/Images/GameBackground/honkai.webp and /dev/null differ diff --git a/CollapseLauncher/Assets/Images/GameBackground/starrail.avif b/CollapseLauncher/Assets/Images/GameBackground/starrail.avif new file mode 100644 index 0000000000..54f1e52ec4 Binary files /dev/null and b/CollapseLauncher/Assets/Images/GameBackground/starrail.avif differ diff --git a/CollapseLauncher/Assets/Images/GameBackground/starrail.webp b/CollapseLauncher/Assets/Images/GameBackground/starrail.webp deleted file mode 100644 index 149b046deb..0000000000 Binary files a/CollapseLauncher/Assets/Images/GameBackground/starrail.webp and /dev/null differ diff --git a/CollapseLauncher/Assets/Images/GameBackground/zzz.avif b/CollapseLauncher/Assets/Images/GameBackground/zzz.avif new file mode 100644 index 0000000000..df987f585e Binary files /dev/null and b/CollapseLauncher/Assets/Images/GameBackground/zzz.avif differ diff --git a/CollapseLauncher/Assets/Images/GameBackground/zzz.webp b/CollapseLauncher/Assets/Images/GameBackground/zzz.webp deleted file mode 100644 index d1f3f1a198..0000000000 Binary files a/CollapseLauncher/Assets/Images/GameBackground/zzz.webp and /dev/null differ diff --git a/CollapseLauncher/Assets/Images/GamePoster/poster_genshin.avif b/CollapseLauncher/Assets/Images/GamePoster/poster_genshin.avif new file mode 100644 index 0000000000..a5868121d8 Binary files /dev/null and b/CollapseLauncher/Assets/Images/GamePoster/poster_genshin.avif differ diff --git a/CollapseLauncher/Assets/Images/GamePoster/poster_genshin.png b/CollapseLauncher/Assets/Images/GamePoster/poster_genshin.png deleted file mode 100644 index ee2504b7d2..0000000000 Binary files a/CollapseLauncher/Assets/Images/GamePoster/poster_genshin.png and /dev/null differ diff --git a/CollapseLauncher/Assets/Images/GamePoster/poster_honkai.avif b/CollapseLauncher/Assets/Images/GamePoster/poster_honkai.avif new file mode 100644 index 0000000000..e4a7e00469 Binary files /dev/null and b/CollapseLauncher/Assets/Images/GamePoster/poster_honkai.avif differ diff --git a/CollapseLauncher/Assets/Images/GamePoster/poster_honkai.png b/CollapseLauncher/Assets/Images/GamePoster/poster_honkai.png deleted file mode 100644 index 3ba48393b9..0000000000 Binary files a/CollapseLauncher/Assets/Images/GamePoster/poster_honkai.png and /dev/null differ diff --git a/CollapseLauncher/Assets/Images/GamePoster/poster_starrail.avif b/CollapseLauncher/Assets/Images/GamePoster/poster_starrail.avif new file mode 100644 index 0000000000..6d59581e3c Binary files /dev/null and b/CollapseLauncher/Assets/Images/GamePoster/poster_starrail.avif differ diff --git a/CollapseLauncher/Assets/Images/GamePoster/poster_starrail.png b/CollapseLauncher/Assets/Images/GamePoster/poster_starrail.png deleted file mode 100644 index 9d82a6712f..0000000000 Binary files a/CollapseLauncher/Assets/Images/GamePoster/poster_starrail.png and /dev/null differ diff --git a/CollapseLauncher/Assets/Images/GamePoster/poster_zzz.avif b/CollapseLauncher/Assets/Images/GamePoster/poster_zzz.avif new file mode 100644 index 0000000000..592af416a7 Binary files /dev/null and b/CollapseLauncher/Assets/Images/GamePoster/poster_zzz.avif differ diff --git a/CollapseLauncher/Assets/Images/GamePoster/poster_zzz.png b/CollapseLauncher/Assets/Images/GamePoster/poster_zzz.png deleted file mode 100644 index 389af86feb..0000000000 Binary files a/CollapseLauncher/Assets/Images/GamePoster/poster_zzz.png and /dev/null differ diff --git a/CollapseLauncher/Assets/Images/PageBackground/StartupBackground.png b/CollapseLauncher/Assets/Images/PageBackground/StartupBackground.png deleted file mode 100644 index 4cad046e38..0000000000 Binary files a/CollapseLauncher/Assets/Images/PageBackground/StartupBackground.png and /dev/null differ diff --git a/CollapseLauncher/Assets/Images/PageBackground/StartupBackground2.avif b/CollapseLauncher/Assets/Images/PageBackground/StartupBackground2.avif new file mode 100644 index 0000000000..1514071f5f Binary files /dev/null and b/CollapseLauncher/Assets/Images/PageBackground/StartupBackground2.avif differ diff --git a/CollapseLauncher/Assets/Images/PageBackground/StartupBackground2.png b/CollapseLauncher/Assets/Images/PageBackground/StartupBackground2.png deleted file mode 100644 index 25f0a2a092..0000000000 Binary files a/CollapseLauncher/Assets/Images/PageBackground/StartupBackground2.png and /dev/null differ diff --git a/CollapseLauncher/Assets/Images/PageBackground/default_genshin.avif b/CollapseLauncher/Assets/Images/PageBackground/default_genshin.avif new file mode 100644 index 0000000000..82415e3a9f Binary files /dev/null and b/CollapseLauncher/Assets/Images/PageBackground/default_genshin.avif differ diff --git a/CollapseLauncher/Assets/Images/PageBackground/default_genshin.webp b/CollapseLauncher/Assets/Images/PageBackground/default_genshin.webp deleted file mode 100644 index 30bbfa05aa..0000000000 Binary files a/CollapseLauncher/Assets/Images/PageBackground/default_genshin.webp and /dev/null differ diff --git a/CollapseLauncher/Assets/Images/PageBackground/default_honkai.avif b/CollapseLauncher/Assets/Images/PageBackground/default_honkai.avif new file mode 100644 index 0000000000..fcda9bf2a0 Binary files /dev/null and b/CollapseLauncher/Assets/Images/PageBackground/default_honkai.avif differ diff --git a/CollapseLauncher/Assets/Images/PageBackground/default_honkai.webp b/CollapseLauncher/Assets/Images/PageBackground/default_honkai.webp deleted file mode 100644 index 6953e43a31..0000000000 Binary files a/CollapseLauncher/Assets/Images/PageBackground/default_honkai.webp and /dev/null differ diff --git a/CollapseLauncher/Assets/Images/PageBackground/default_starrail.avif b/CollapseLauncher/Assets/Images/PageBackground/default_starrail.avif new file mode 100644 index 0000000000..241acde4b3 Binary files /dev/null and b/CollapseLauncher/Assets/Images/PageBackground/default_starrail.avif differ diff --git a/CollapseLauncher/Assets/Images/PageBackground/default_starrail.webp b/CollapseLauncher/Assets/Images/PageBackground/default_starrail.webp deleted file mode 100644 index c696b35089..0000000000 Binary files a/CollapseLauncher/Assets/Images/PageBackground/default_starrail.webp and /dev/null differ diff --git a/CollapseLauncher/Assets/Images/PageBackground/default_zzz.avif b/CollapseLauncher/Assets/Images/PageBackground/default_zzz.avif new file mode 100644 index 0000000000..6a325a280e Binary files /dev/null and b/CollapseLauncher/Assets/Images/PageBackground/default_zzz.avif differ diff --git a/CollapseLauncher/Assets/Images/PageBackground/default_zzz.webp b/CollapseLauncher/Assets/Images/PageBackground/default_zzz.webp deleted file mode 100644 index 0e33010e68..0000000000 Binary files a/CollapseLauncher/Assets/Images/PageBackground/default_zzz.webp and /dev/null differ diff --git a/CollapseLauncher/Classes/AnimatedVisuals/Lottie/NewLogoTitleIntro.cs b/CollapseLauncher/Classes/AnimatedVisuals/Lottie/NewLogoTitleIntro.cs index bd524472c6..415c66c2c0 100644 --- a/CollapseLauncher/Classes/AnimatedVisuals/Lottie/NewLogoTitleIntro.cs +++ b/CollapseLauncher/Classes/AnimatedVisuals/Lottie/NewLogoTitleIntro.cs @@ -43,6 +43,9 @@ // | Gradient stops | 6 | // | CompositionVisualSurface | 42 | // ------------------------------------ + +using CollapseLauncher.GameManagement.ImageBackground; +using CollapseLauncher.Helper.Image; using Hi3Helper.Shared.Region; using Microsoft.Graphics.Canvas; using Microsoft.Graphics.Canvas.Effects; @@ -55,6 +58,7 @@ using System.ComponentModel; using System.IO; using System.Numerics; +using System.Threading.Tasks; using Windows.Foundation; using Windows.UI; @@ -156,9 +160,9 @@ void EnsureImageLoadingStarted() if (!_isImageLoadingStarted) { var eventHandler = new TypedEventHandler(HandleLoadCompleted); - _image_image_0 = LoadedImageSurface.StartLoadFromStream(File.OpenRead(Path.Combine(LauncherConfig.AppExecutableDir, @"Assets\CollapseLauncherLogo.png")).AsRandomAccessStream()); + _image_image_0 = LoadedImageSurface.StartLoadFromStream(File.OpenRead(Path.Combine(LauncherConfig.AppExecutableDir, @"Assets\CollapseLauncherLogo.png")).AsRandomAccessStream()); _image_image_0.LoadCompleted += eventHandler; - _isImageLoadingStarted = true; + _isImageLoadingStarted = true; } } diff --git a/CollapseLauncher/Classes/DiscordPresence/DiscordActivityType.cs b/CollapseLauncher/Classes/DiscordPresence/DiscordActivityType.cs new file mode 100644 index 0000000000..4a05430ec8 --- /dev/null +++ b/CollapseLauncher/Classes/DiscordPresence/DiscordActivityType.cs @@ -0,0 +1,15 @@ +#pragma warning disable IDE0130 + +namespace CollapseLauncher.DiscordPresence; + +public enum DiscordActivityType +{ + None, + Idle, + Play, + Update, + Repair, + Cache, + GameSettings, + AppSettings +} diff --git a/CollapseLauncher/Classes/DiscordPresence/DiscordPresenceManager.cs b/CollapseLauncher/Classes/DiscordPresence/DiscordPresenceManager.cs deleted file mode 100644 index d4f172e4e0..0000000000 --- a/CollapseLauncher/Classes/DiscordPresence/DiscordPresenceManager.cs +++ /dev/null @@ -1,476 +0,0 @@ -using CollapseLauncher.Helper; -using CollapseLauncher.Helper.Metadata; -using CollapseLauncher.Helper.Update; -using CollapseLauncher.Plugins; -using DiscordRPC; -using DiscordRPC.Entities; -using DiscordRPC.Message; -using Hi3Helper; -using System; -using System.Diagnostics.CodeAnalysis; -using System.Runtime.CompilerServices; -using System.Threading.Tasks.Dataflow; -using static Hi3Helper.Shared.Region.LauncherConfig; -// ReSharper disable PartialTypeWithSinglePart -// ReSharper disable StringLiteralTypo -// ReSharper disable SwitchStatementHandlesSomeKnownEnumValuesWithDefault -#pragma warning disable IDE0130 - -#nullable enable -namespace CollapseLauncher.DiscordPresence -{ - #region Enums - - public enum ActivityType - { - None, - Idle, - Play, - Update, - Repair, - Cache, - GameSettings, - AppSettings - } - - #endregion - - public sealed partial class DiscordPresenceManager : IDisposable - { - #region Properties - - public bool IsRpcEnabled - { - get => field = GetAppConfigValue("EnableDiscordRPC"); - set - { - if (field == value) return; - field = value; - - SetAndSaveConfigValue("EnableDiscordRPC", value); - if (value) SetupPresence(null); - else DisablePresence(); - } - } - - private const string CollapseLogoExt = "https://collapselauncher.com/img/logo@2x.webp"; - - private DiscordRpcClient? _client; - - private RichPresence? _presence; - private ActivityType _activityType; - private DateTime? _lastPlayTime; - private bool _firstTimeConnect = true; - private readonly ActionBlock _presenceUpdateQueue; - - private bool _cachedIsIdleEnabled = true; - - public bool IdleEnabled - { - get - { - bool value = GetAppConfigValue("EnableDiscordIdleStatus"); - _cachedIsIdleEnabled = value; - return value; - } - set - { - SetAndSaveConfigValue("EnableDiscordIdleStatus", value); - _cachedIsIdleEnabled = value; - } - } - - #endregion - - public DiscordPresenceManager(bool initialStart = true) - { - _presenceUpdateQueue = new ActionBlock(_ => _client?.SetPresence(_presence), - new ExecutionDataflowBlockOptions - { - MaxMessagesPerTask = 1, - MaxDegreeOfParallelism = 1, - EnsureOrdered = true - }); - - if (!initialStart) - { - return; - } - - // Prepare idle cached setting - Logger.LogWriteLine($"Doing initial start for Discord RPC!\r\n\tIdle status : {IdleEnabled}", - LogType.Scheme); - } - - // Deconstruct and dispose unmanaged resources - ~DiscordPresenceManager() - { - Dispose(); - } - - public void Dispose() - { - // Dispose Discord RPC client - DisablePresence(); - - // Suppress the GC from finalization - GC.SuppressFinalize(this); - } - - private void EnablePresence(ulong applicationId) - { - if (!IsRpcEnabled) return; - _firstTimeConnect = true; - - // Flush and dispose the session - DisablePresence(); - - // Initialize Discord RPC client - _client = new DiscordRpcClient(applicationId.ToString(), ILoggerHelper.GetILogger("DiscordRPC")); - - _client.OnReady += OnReady; - _client.OnPresenceUpdate += OnPresenceUpdate; - - if (!_client.Initialize()) - { - Logger.LogWriteLine("Error initializing Discord Presence.", LogType.Warning, true); - return; - } - - Logger.LogWriteLine("Discord Presence is Enabled!"); - } - - private void OnReady(object? sender, ReadyMessage? msg) - { - Logger.LogWriteLine($"Connected to Discord with user {msg?.User?.Username}"); - if (!_firstTimeConnect) - { - // Restart Discord RPC client - _firstTimeConnect = true; - SetupPresence(null); - } - else - { - // Restore our last activity - if (!(!_cachedIsIdleEnabled && - _activityType is ActivityType.Idle or ActivityType.None)) - { - SetActivity(_activityType); - } - - _firstTimeConnect = false; - } - } - - private static void OnPresenceUpdate(object? sender, PresenceMessage? msg) - { - if (msg?.Presence == null) - { - Logger.LogWriteLine("Activity cleared!"); - } - else - { - Logger.LogWriteLine(msg.Presence.State == null - ? $"Activity updated! => {msg.Presence.Details}" - : $"Activity updated! => {msg.Presence.Details} - {msg.Presence.State}"); - } - } - - public void DisablePresence() - { - _client?.SetPresence(null); - _client?.Dispose(); - _client = null; - } - - private static ulong GetDiscordPresenceId(PresetConfig presetConfig) - { - return presetConfig.GameName switch - { - "Honkai: Star Rail" => AppDiscordApplicationIDHsr, - "Honkai Impact 3rd" => AppDiscordApplicationIDHi3, - "Genshin Impact" => AppDiscordApplicationIDGi, - "Zenless Zone Zero" => AppDiscordApplicationIDZzz, - _ => TryGetPresenceFromPlugin(presetConfig) - }; - - static ulong TryGetPresenceFromPlugin(PresetConfig presetConfig) - { - if (presetConfig is not PluginPresetConfigWrapper { DiscordPresenceContext : { IsFeatureAvailable: true } discordContext } || - discordContext.PresenceId == 0) - { - return AppDiscordApplicationID; // Default - } - - return discordContext.PresenceId; - } - } - - public void SetupPresence(PresetConfig? presetConfig) - { - bool isGameStatusEnabled = GetAppConfigValue("EnableDiscordGameStatus"); - if (!IsRpcEnabled || !isGameStatusEnabled) return; - - string gameTitle = MetadataHelper.CurrentGameTitleName; - string gameRegion = MetadataHelper.CurrentGameRegionName; - - if (presetConfig == null && - !MetadataHelper.TryGetGameConfig(gameTitle, gameRegion, out presetConfig)) - { - return; - } - - if (GetDiscordPresenceId(presetConfig) is var presenceId && presenceId == 0) - { - Logger.LogWriteLine("Discord Presence (Unknown Game)", LogType.Error, true); - } - - EnablePresence(presenceId); - } - - public void SetActivity(ActivityType activity, DateTime? activityOffset = null) - { - if (!IsRpcEnabled) return; - - //_lastAttemptedActivityType = activity; - _activityType = activity; - - switch (activity) - { - case ActivityType.Play: - { - bool isGameStatusEnabled = GetAppConfigValue("EnableDiscordGameStatus").ToBool(); - BuildActivityGameStatus((isGameStatusEnabled ? Locale.Current.Lang?._Misc?.DiscordRP_InGame : Locale.Current.Lang?._Misc?.DiscordRP_Play) ?? "", - isGameStatusEnabled, activityOffset); - break; - } - case ActivityType.Update: - { - bool isGameStatusEnabled = GetAppConfigValue("EnableDiscordGameStatus").ToBool(); - BuildActivityGameStatus(Locale.Current.Lang?._Misc?.DiscordRP_Update ?? "", isGameStatusEnabled); - break; - } - case ActivityType.Repair: - BuildActivityAppStatus(Locale.Current.Lang?._Misc?.DiscordRP_Repair ?? ""); - break; - case ActivityType.Cache: - BuildActivityAppStatus(Locale.Current.Lang?._Misc?.DiscordRP_Cache ?? ""); - break; - case ActivityType.GameSettings: - BuildActivityAppStatus(Locale.Current.Lang?._Misc?.DiscordRP_GameSettings ?? ""); - break; - case ActivityType.AppSettings: - BuildActivityAppStatus(Locale.Current.Lang?._Misc?.DiscordRP_AppSettings ?? ""); - break; - case ActivityType.Idle: - _lastPlayTime = null; - if (_cachedIsIdleEnabled) - { - BuildActivityAppStatus(Locale.Current.Lang?._Misc?.DiscordRP_Idle ?? ""); - } - else - { - _presence = null; // Clear presence - } - - break; - default: - _presence = new RichPresence - { - Details = Locale.Current.Lang?._Misc?.DiscordRP_Default, - Assets = new Assets - { - LargeImageKey = "launcher-logo-new", - LargeImageText = - $"Collapse Launcher v{LauncherUpdateHelper.LauncherCurrentVersionString} {(IsPreview ? "Preview" : "Stable")}" - }, - Timestamps = null! - }; - break; - } - - UpdateActivity(); - } - - private void BuildActivityGameStatus(string activityName, bool isGameStatusEnabled, DateTime? activityOffset = null) - { - string curGameName = MetadataHelper.CurrentGameTitleName; - string curGameRegion = MetadataHelper.CurrentGameRegionName; - - if (string.IsNullOrEmpty(curGameName) || string.IsNullOrEmpty(curGameRegion) || - !MetadataHelper.TryGetGameConfig(curGameName, curGameRegion, out PresetConfig? presetConfig)) - return; - - string curGameNameTranslate = MetadataHelper.GetTranslatedTitle(curGameName); - string curGameRegionTranslate = MetadataHelper.GetTranslatedRegion(curGameRegion); - - if (TryBuildActivityGameStatusFromPlugin(activityName, - curGameNameTranslate, - curGameRegionTranslate, - isGameStatusEnabled, - activityOffset, - presetConfig, - out _presence)) - { - return; - } - - _presence = new RichPresence - { - Details = $"{activityName} {(!isGameStatusEnabled ? curGameNameTranslate : null)}", - State = $"{Locale.Current.Lang?._Misc?.DiscordRP_Region} {curGameRegionTranslate}", - Assets = new Assets - { - LargeImageKey = $"game-{presetConfig.GameType.ToString().ToLower()}-logo", - LargeImageText = $"{curGameNameTranslate} - {curGameRegionTranslate}", - SmallImageKey = "launcher-logo-new", - SmallImageText = $"Collapse Launcher v{LauncherUpdateHelper.LauncherCurrentVersionString} " - + $"{(IsPreview ? "Preview" : "Stable")}" - }, - Timestamps = new Timestamps - { - Start = GetCachedStartPlayTime(activityOffset) - } - }; - } - - private bool TryBuildActivityGameStatusFromPlugin( - string activityName, - string? translatedGameName, - string? translatedRegionName, - bool isGameStatusEnabled, - DateTime? activityOffset, - PresetConfig presetConfig, - [NotNullWhen(true)] out RichPresence? presence) - { - Unsafe.SkipInit(out presence); - - if (presetConfig is not PluginPresetConfigWrapper asPluginPresetConfig || - !asPluginPresetConfig.DiscordPresenceContext.IsFeatureAvailable) - { - return false; - } - - string? largeIconUrl = asPluginPresetConfig.DiscordPresenceContext.LargeIconUrl; - string? largeIconTooltip = asPluginPresetConfig.DiscordPresenceContext.LargeIconTooltip; - string? smallIconUrl = asPluginPresetConfig.DiscordPresenceContext.SmallIconUrl; - string? smallIconTooltip = asPluginPresetConfig.DiscordPresenceContext.SmallIconTooltip; - - presence = new RichPresence - { - Details = $"{activityName} {(!isGameStatusEnabled ? translatedGameName : null)}", - State = $"{Locale.Current.Lang?._Misc?.DiscordRP_Region} {translatedRegionName}", - Assets = new Assets - { - LargeImageKey = largeIconUrl ?? CollapseLogoExt, - LargeImageText = largeIconTooltip ?? $"{translatedGameName} - {translatedRegionName}", - SmallImageKey = smallIconUrl ?? CollapseLogoExt, - SmallImageText = smallIconTooltip ?? - $"Collapse Launcher v{LauncherUpdateHelper.LauncherCurrentVersionString} " - + $"{(IsPreview ? "Preview" : "Stable")}" - }, - Timestamps = new Timestamps - { - Start = GetCachedStartPlayTime(activityOffset) - } - }; - - return true; - } - - private DateTime GetCachedStartPlayTime(DateTime? activityOffset) - { - _lastPlayTime ??= activityOffset; - _lastPlayTime ??= DateTime.UtcNow; - return _lastPlayTime.Value; - } - - private void BuildActivityAppStatus(string activityName) - { - string curGameName = MetadataHelper.CurrentGameTitleName; - string curGameRegion = MetadataHelper.CurrentGameRegionName; - - if (string.IsNullOrEmpty(curGameName) || string.IsNullOrEmpty(curGameRegion) || - !MetadataHelper.TryGetGameConfig(curGameName, curGameRegion, out PresetConfig? presetConfig)) - return; - - string curGameNameTranslate = MetadataHelper.GetTranslatedTitle(curGameName); - string curGameRegionTranslate = MetadataHelper.GetTranslatedRegion(curGameRegion); - - if (TryBuildActivityAppStatusFromPlugin(activityName, - curGameNameTranslate, - curGameRegionTranslate, - presetConfig, - out _presence)) - { - return; - } - - _presence = new RichPresence - { - Details = activityName, - State = $"{Locale.Current.Lang?._Misc?.DiscordRP_Region} {curGameRegionTranslate}", - Assets = new Assets - { - LargeImageKey = $"game-{presetConfig.GameType.ToString().ToLower()}-logo", - LargeImageText = curGameNameTranslate, - SmallImageKey = "launcher-logo-new", - SmallImageText = $"Collapse Launcher v{LauncherUpdateHelper.LauncherCurrentVersionString} " - + $"{(IsPreview ? "Preview" : "Stable")}" - }, - Timestamps = null! - }; - } - - private static bool TryBuildActivityAppStatusFromPlugin( - string activityName, - string? translatedGameName, - string? translatedRegionName, - PresetConfig presetConfig, - [NotNullWhen(true)] out RichPresence? presence) - { - Unsafe.SkipInit(out presence); - - if (presetConfig is not PluginPresetConfigWrapper asPluginPresetConfig || - !asPluginPresetConfig.DiscordPresenceContext.IsFeatureAvailable) - { - return false; - } - - string? largeIconUrl = asPluginPresetConfig.DiscordPresenceContext.LargeIconUrl; - string? largeIconTooltip = asPluginPresetConfig.DiscordPresenceContext.LargeIconTooltip; - string? smallIconUrl = asPluginPresetConfig.DiscordPresenceContext.SmallIconUrl; - string? smallIconTooltip = asPluginPresetConfig.DiscordPresenceContext.SmallIconTooltip; - - presence = new RichPresence - { - Details = activityName, - State = $"{Locale.Current.Lang?._Misc?.DiscordRP_Region} {translatedRegionName}", - Assets = new Assets - { - LargeImageKey = largeIconUrl ?? CollapseLogoExt, - LargeImageText = largeIconTooltip ?? translatedGameName, - SmallImageKey = smallIconUrl ?? CollapseLogoExt, - SmallImageText = smallIconTooltip ?? - $"Collapse Launcher v{LauncherUpdateHelper.LauncherCurrentVersionString} " - + $"{(IsPreview ? "Preview" : "Stable")}" - }, - Timestamps = null! - }; - - return true; - } - - private void UpdateActivity() - { - try - { - _presenceUpdateQueue.Post(_presence); - } - catch (Exception ex) - { - Logger.LogWriteLine($"Error when updating Discord Presence Activity\r\n{ex}", LogType.Error, true); - } - } - } -} \ No newline at end of file diff --git a/CollapseLauncher/Classes/DiscordPresence/DiscordRpcManager.cs b/CollapseLauncher/Classes/DiscordPresence/DiscordRpcManager.cs new file mode 100644 index 0000000000..9c3764d42b --- /dev/null +++ b/CollapseLauncher/Classes/DiscordPresence/DiscordRpcManager.cs @@ -0,0 +1,422 @@ +using CollapseLauncher.Helper; +using CollapseLauncher.Helper.Metadata; +using CollapseLauncher.Helper.Update; +using CollapseLauncher.Plugins; +using DiscordRPC; +using DiscordRPC.Entities; +using DiscordRPC.Message; +using Hi3Helper; +using Hi3Helper.LocaleSourceGen; +using Hi3Helper.Shared.Region; +using Microsoft.Extensions.Logging; +using System; +using System.Collections.Concurrent; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Channels; + +#pragma warning disable IDE0130 + +#nullable enable +namespace CollapseLauncher.DiscordPresence; + +public partial class DiscordRpcManager : IDisposable +{ + public bool IsDisposed; + + public DiscordRpcClient? Client; + public readonly Thread PresenceSetThread; + public readonly Channel PresenceSetChannel; + + private ulong _lastPresenceId = LauncherConfig.AppDiscordApplicationID; + private DiscordActivityType _lastActivityStatus; + + private readonly EventWaitHandle _isReadyWaitHandle = new(false, EventResetMode.ManualReset); + + public bool IsGameStatusEnabled + { + get => LauncherConfig.GetAppConfigValue("EnableDiscordGameStatus"); + set + { + LauncherConfig.SetAndSaveConfigValue("EnableDiscordGameStatus", value); + SetActivity(_lastActivityStatus); // Refresh activity status to the last one + } + } + + public bool IsShowOnIdle + { + get => LauncherConfig.GetAppConfigValue("EnableDiscordIdleStatus"); + set + { + LauncherConfig.SetAndSaveConfigValue("EnableDiscordIdleStatus", value); + SetActivity(_lastActivityStatus); // Refresh activity status to the last one + } + } + + public bool IsEnabled + { + get => LauncherConfig.GetAppConfigValue("EnableDiscordRPC"); + set + { + bool isPreviouslyEnabled = LauncherConfig.GetAppConfigValue("EnableDiscordRPC"); + LauncherConfig.SetAndSaveConfigValue("EnableDiscordRPC", value); + + if (value) Start(); + else Stop(); + + // Refresh activity status if it was previously disabled. + if (!isPreviouslyEnabled && + value != isPreviouslyEnabled) + { + SetActivity(_lastActivityStatus); + } + } + } + + private readonly ConcurrentDictionary _cachedStartTimes = []; + private readonly ILogger _sharedLogger; + private PresetConfig? _currentPresetConfig; + + public DiscordRpcManager() + { + _sharedLogger = ILoggerHelper.GetILogger("DiscordRPC"); + PresenceSetChannel = Channel.CreateUnbounded(new UnboundedChannelOptions + { + SingleWriter = true + }); + + PresenceSetThread = new Thread(PresenceSetterInvoke) + { + IsBackground = true + }; + PresenceSetThread.Start(); + + // Initialize from start if enabled + if (IsEnabled) + { + Start(); + } + } + + public void Dispose() + { + if (Interlocked.Exchange(ref IsDisposed, true)) + return; + + // Stop presence RPC + Stop(); + + // Complete the writer and dispose the wait handle. + PresenceSetChannel.Writer.TryComplete(); + _isReadyWaitHandle.Dispose(); + } + + public async void PresenceSetterInvoke(object? ctx) + { + try + { + ChannelReader reader = PresenceSetChannel.Reader; + + while (!IsDisposed && await reader.WaitToReadAsync()) + { + while (reader.TryRead(out RichPresence? presence)) + { + if (IsDisposed) + { + return; + } + + // Blocks and wait until the ready signal is set. + _isReadyWaitHandle.WaitOne(); + Client?.SetPresence(presence); + } + } + } + catch (ObjectDisposedException) + { + // ignore + + // From @neon-nyan: + // The reason why this is ignored, is because the disposed exception will + // come from the _isReadyWaitHandle. The EventWaitHandle doesn't have such + // property or field to check whether the handle is already disposed or + // not anyway, so we just yeet the exception. + } + catch (Exception e) + { + _sharedLogger.LogError(e, "An error has occurred while setting presence on the RPC client."); + } + } + + public void Stop() + { + DiscordRpcClient? oldClient = Interlocked.Exchange(ref Client, null); + if (oldClient == null) + { + return; + } + + // Reset the channel by flushing all pending presences + while (PresenceSetChannel.Reader.TryRead(out _)) { } + + if (!Volatile.Read(ref IsDisposed)) + { + // Reset wait handle and block presence update until the + // client is ready or started. + _isReadyWaitHandle.Reset(); + } + + oldClient.OnReady -= EventClientOnReady; + oldClient.OnPresenceUpdate -= EventClientOnPresenceUpdate; + oldClient.Dispose(); + } + + public void Start() + { + // If not enabled, choose to not initialize the client. + if (!IsEnabled) + { + return; + } + + ulong presenceId = _lastPresenceId == 0 + ? LauncherConfig.AppDiscordApplicationID + : _lastPresenceId; + + // Initialize new client and replace the field atomically. + Interlocked.Exchange(ref Client, new DiscordRpcClient($"{presenceId}", _sharedLogger)); + Client.OnReady += EventClientOnReady; + Client.OnPresenceUpdate += EventClientOnPresenceUpdate; + if (!Client.Initialize()) + { + _sharedLogger.LogInformation("Failed while trying to initialize the client!"); + } + } + + private void EventClientOnReady(object sender, ReadyMessage? msg) + { + _sharedLogger.LogInformation("Connected to Discord with user {username}", msg?.User?.Username); + _isReadyWaitHandle.Set(); // Unblock the presence update thread. + } + + private void EventClientOnPresenceUpdate(object sender, PresenceMessage? msg) + { + if (msg?.Presence == null) + { + _sharedLogger.LogInformation("Activity cleared!"); + } + else + { + _sharedLogger.LogInformation("Activity updated! => {msg}", msg.Presence.State == null + ? msg.Presence.Details + : $"{msg.Presence.Details} - {msg.Presence.State}"); + } + } + + public void SetPresence(PresetConfig? config) + { + Interlocked.Exchange(ref _currentPresetConfig, config); + if (config == null) + { + Interlocked.Exchange(ref _lastPresenceId, 0); + return; + } + + ulong presenceId = GetDiscordPresenceId(config); + Interlocked.Exchange(ref _lastPresenceId, presenceId); + + // We intentionally stop and start the client to refresh / re-create the client + // with the new presence ID. + Stop(); + Start(); + } + + public void SetActivity(DiscordActivityType type = DiscordActivityType.None, DateTime? specifiedStartTime = null) + { + Interlocked.Exchange(ref _lastActivityStatus, type); + + // Prevent from exhausting the Presence channel if not enabled. + if (!IsEnabled) return; + + // Make sure to re-enable the client if it was not initialized while the manager is not disposed yet. + if (Volatile.Read(ref Client) == null && + !IsDisposed && + IsEnabled) + { + Start(); + } + + LangParamsMisc? langMisc = Locale.Current.Lang?._Misc; + RichPresence? presence = type switch + { + DiscordActivityType.Play => PresenceBuilder.BuildTimedState(IsGameStatusEnabled ? langMisc?.DiscordRP_InGame : langMisc?.DiscordRP_Play, this, specifiedStartTime), + DiscordActivityType.Update => PresenceBuilder.BuildTimedState(langMisc?.DiscordRP_Update, this, specifiedStartTime), + DiscordActivityType.Idle => IsShowOnIdle ? PresenceBuilder.BuildIdleState(this) : null, + DiscordActivityType.Repair => PresenceBuilder.BuildGenericState(langMisc?.DiscordRP_Repair, this), + DiscordActivityType.Cache => PresenceBuilder.BuildGenericState(langMisc?.DiscordRP_Cache, this), + DiscordActivityType.GameSettings => PresenceBuilder.BuildGenericState(langMisc?.DiscordRP_GameSettings, this), + DiscordActivityType.AppSettings => PresenceBuilder.BuildGenericState(langMisc?.DiscordRP_AppSettings, this), + _ => IsShowOnIdle ? new RichPresence + { + Details = Locale.Current.Lang?._Misc?.DiscordRP_Default, + Assets = new Assets + { + LargeImageKey = PresenceBuilder.DefaultLauncherLogo, + LargeImageText = PresenceBuilder.DefaultLauncherLogoTooltip + }, + Timestamps = null! + } : null + }; + + PresenceSetChannel.Writer.TryWrite(presence); + } + + private static ulong GetDiscordPresenceId(PresetConfig presetConfig) + { + return presetConfig.GameName switch + { + "Honkai: Star Rail" => LauncherConfig.AppDiscordApplicationIDHsr, + "Honkai Impact 3rd" => LauncherConfig.AppDiscordApplicationIDHi3, + "Genshin Impact" => LauncherConfig.AppDiscordApplicationIDGi, + "Zenless Zone Zero" => LauncherConfig.AppDiscordApplicationIDZzz, + _ => TryGetPresenceFromPlugin(presetConfig) + }; + + static ulong TryGetPresenceFromPlugin(PresetConfig presetConfig) + { + if (presetConfig is not PluginPresetConfigWrapper { DiscordPresenceContext: { IsFeatureAvailable: true } discordContext } || + discordContext.PresenceId == 0) + { + return LauncherConfig.AppDiscordApplicationID; // Default + } + + return discordContext.PresenceId; + } + } + + private static class PresenceBuilder + { + private const string CollapseLogoExt = "https://collapselauncher.com/img/logo@2x.webp"; + + public const string DefaultLauncherLogo = "launcher-logo-new"; + public static readonly string DefaultLauncherLogoTooltip = $"Collapse Launcher v{LauncherUpdateHelper.LauncherCurrentVersionString} " + + $"{(LauncherConfig.IsPreview ? "Preview" : "Stable")}"; + + public static RichPresence BuildTimedState(string? activityName, DiscordRpcManager manager, DateTime? specifiedStartTime = null) + { + bool isGameStatusEnabled = manager.IsGameStatusEnabled; + PresetConfig? presetConfig = manager._currentPresetConfig; + + int presetConfigHashId = presetConfig?.HashID ?? 0; + + // Try to get the existing start offset or create a new one if not exist. + DateTime startOffset = manager._cachedStartTimes.GetOrAdd(presetConfigHashId, specifiedStartTime ?? DateTime.UtcNow); + + string? currentGameName = presetConfig?.GameName; + string? translatedGameName = MetadataHelper.GetTranslatedTitle(currentGameName); + + return BuildGenericState($"{activityName} {(!isGameStatusEnabled ? translatedGameName : null)}", + manager, + new Timestamps + { + Start = startOffset + }); + } + + public static RichPresence BuildGenericState(string? activityName, DiscordRpcManager manager, Timestamps? timestamps = null) + { + PresetConfig? presetConfig = manager._currentPresetConfig; + TryGetGameIconsAndTranslatedNames(presetConfig, + out string? largeIconUrl, + out string? largeIconTooltip, + out string? smallIconUrl, + out string? smallIconTooltip, + out string? translatedGameRegion); + + return new RichPresence + { + Details = activityName, + State = $"{Locale.Current.Lang?._Misc?.DiscordRP_Region} {translatedGameRegion}", + Assets = new Assets + { + LargeImageKey = largeIconUrl, + LargeImageText = largeIconTooltip, + SmallImageKey = smallIconUrl, + SmallImageText = smallIconTooltip + }, + Timestamps = timestamps + }; + } + + public static RichPresence BuildIdleState(DiscordRpcManager manager) + { + // Try to remove existing cached start time (Reset) + PresetConfig? presetConfig = manager._currentPresetConfig; + int presetConfigHashId = presetConfig?.GetHashCode() ?? 0; + manager._cachedStartTimes.TryRemove(presetConfigHashId, out _); + return BuildGenericState(Locale.Current.Lang?._Misc?.DiscordRP_Idle, manager); + } + + private static void TryGetGameIconsAndTranslatedNames( + PresetConfig? presetConfig, + out string? largeIconUrl, + out string? largeIconTooltip, + out string? smallIconUrl, + out string? smallIconTooltip, + out string? translatedGameRegion) + { + Unsafe.SkipInit(out largeIconUrl); + Unsafe.SkipInit(out largeIconTooltip); + Unsafe.SkipInit(out smallIconUrl); + Unsafe.SkipInit(out smallIconTooltip); + + string? currentGameName = presetConfig?.GameName; + string? currentGameRegion = presetConfig?.ZoneName; + string? translatedGameName = MetadataHelper.GetTranslatedTitle(currentGameName); + translatedGameRegion = MetadataHelper.GetTranslatedRegion(currentGameRegion); + + // Try to get icons from plugin if available. + TryGetPluginGameIcons(presetConfig, + out largeIconUrl, + out largeIconTooltip, + out smallIconUrl, + out smallIconTooltip, + out bool isPluginGame); + + largeIconUrl ??= isPluginGame ? CollapseLogoExt : $"game-{presetConfig?.GameType.ToString().ToLower()}-logo"; + largeIconTooltip ??= $"{translatedGameName} - {translatedGameRegion}"; + smallIconUrl ??= isPluginGame ? CollapseLogoExt : DefaultLauncherLogo; + smallIconTooltip ??= DefaultLauncherLogoTooltip; + } + + private static void TryGetPluginGameIcons(PresetConfig? presetConfig, + out string? largeIconUrl, + out string? largeIconTooltip, + out string? smallIconUrl, + out string? smallIconTooltip, + out bool isPluginGame) + { + Unsafe.SkipInit(out largeIconUrl); + Unsafe.SkipInit(out largeIconTooltip); + Unsafe.SkipInit(out smallIconUrl); + Unsafe.SkipInit(out smallIconTooltip); + Unsafe.SkipInit(out isPluginGame); + + if (presetConfig is not PluginPresetConfigWrapper asPluginPresetConfig) + { + return; + } + + isPluginGame = true; + if (!asPluginPresetConfig.DiscordPresenceContext.IsFeatureAvailable) + { + return; + } + + largeIconUrl = asPluginPresetConfig.DiscordPresenceContext.LargeIconUrl; + largeIconTooltip = asPluginPresetConfig.DiscordPresenceContext.LargeIconTooltip; + smallIconUrl = asPluginPresetConfig.DiscordPresenceContext.SmallIconUrl; + smallIconTooltip = asPluginPresetConfig.DiscordPresenceContext.SmallIconTooltip; + } + } +} diff --git a/CollapseLauncher/Classes/EventsManagement/BackgroundActivityManager.cs b/CollapseLauncher/Classes/EventsManagement/BackgroundActivityManager.cs index 2ec01077cf..d1d83fc86b 100644 --- a/CollapseLauncher/Classes/EventsManagement/BackgroundActivityManager.cs +++ b/CollapseLauncher/Classes/EventsManagement/BackgroundActivityManager.cs @@ -259,6 +259,12 @@ private static void AttachEventToNotification(PresetConfig presetConfig, IBackgr activity!.ProgressChanged += progressChangedEventHandler; activity!.StatusChanged += statusChangedEventHandler; + // The notification can be attached after the operation has already + // started. Hydrate it immediately instead of waiting for another + // progress/status event and leaving the placeholder text visible. + progressChangedEventHandler(activity, activity.Progress); + statusChangedEventHandler(activity, activity.Status); + activity.FlushingTrigger += (_, _) => { activity.ProgressChanged -= progressChangedEventHandler; diff --git a/CollapseLauncher/Classes/Extension/UIElementExtensions.cs b/CollapseLauncher/Classes/Extension/UIElementExtensions.cs index 5fe490be70..2214d2376f 100644 --- a/CollapseLauncher/Classes/Extension/UIElementExtensions.cs +++ b/CollapseLauncher/Classes/Extension/UIElementExtensions.cs @@ -57,7 +57,11 @@ internal static T BindNavigationViewItemText(this T element, object? localeOb internal static T BindTooltipToLocale(this T element, object? localeObjBinding, string localePropertyName, IValueConverter? converter = null, object? converterParameter = null) where T : DependencyObject { - TextBlock tooltipTextBlock = new(); + TextBlock tooltipTextBlock = new() + { + TextWrapping = TextWrapping.Wrap, + TextTrimming = TextTrimming.CharacterEllipsis + }; tooltipTextBlock.BindProperty(TextBlock.TextProperty, localeObjBinding, localePropertyName, @@ -144,14 +148,14 @@ internal static void SetAllControlsCursorRecursive(this UIElement? element, Inpu } } - internal static void BindProperty(this FrameworkElement element, - object? objectToBind, - string propertyName, - DependencyProperty dependencyProperty, - BindingMode bindingMode, - IValueConverter? converter = null) + internal static void BindProperty(this FrameworkElement? element, + object? objectToBind, + string propertyName, + DependencyProperty dependencyProperty, + BindingMode bindingMode, + IValueConverter? converter = null) { - element.SetBinding(dependencyProperty, new Binding + element?.SetBinding(dependencyProperty, new Binding { Source = objectToBind, Mode = bindingMode, @@ -1306,5 +1310,14 @@ internal static T Create(Action? setAttributeDelegate = null) return element; } + + internal static void UpdateLayoutAndBinding(this ComboBox comboBox) + { + comboBox.UpdateLayout(); + object? lastSelectedItem = comboBox.SelectedItem; + comboBox.SelectedItem = null; + comboBox.SelectedItem = lastSelectedItem; + comboBox.UpdateLayout(); + } } } diff --git a/CollapseLauncher/Classes/GameManagement/GameSettings/BaseClass/ImportExportBase.cs b/CollapseLauncher/Classes/GameManagement/GameSettings/BaseClass/ImportExportBase.cs index e907634086..50b1e78d8a 100644 --- a/CollapseLauncher/Classes/GameManagement/GameSettings/BaseClass/ImportExportBase.cs +++ b/CollapseLauncher/Classes/GameManagement/GameSettings/BaseClass/ImportExportBase.cs @@ -1,6 +1,9 @@ using CollapseLauncher.Helper; +using CollapseLauncher.Helper.Database; +using CollapseLauncher.Helper.Metadata; using CollapseLauncher.Interfaces; using Hi3Helper; +using Hi3Helper.Data; using Hi3Helper.EncTool; using Hi3Helper.UABT; using Hi3Helper.UABT.Binary; @@ -70,11 +73,11 @@ public RegistryKey? RegistryRoot return RegistryRoot; } - public async Task ImportSettings(string? gameBasePath = null) + public async Task ImportSettings(string? gameBasePath = null, string? path = null) { try { - string path = await FileDialogNative.GetFilePicker(new Dictionary { { "Collapse Registry", "*.clreg" } }, Locale.Current.Lang?._GameSettingsPage?.SettingsRegImportTitle); + path ??= await FileDialogNative.GetFilePicker(new Dictionary { { "Collapse Registry", "*.clreg" } }, Locale.Current.Lang?._GameSettingsPage?.SettingsRegImportTitle); if (string.IsNullOrEmpty(path)) throw new OperationCanceledException(Locale.Current.Lang?._GameSettingsPage?.SettingsRegErr1); @@ -190,11 +193,11 @@ private void ReadV3Values(Stream fs, string? gameBasePath) ImportStreamToFiles(stream, gameBasePath); } - public async Task ExportSettings(bool isCompressed = true, string? parentPathToImport = null, string[]? relativePathToImport = null) + public async Task ExportSettings(bool isCompressed = true, string? parentPathToImport = null, string[]? relativePathToImport = null, string? path = null) { try { - string path = await FileDialogNative.GetFileSavePicker(new Dictionary { { "Collapse Registry", "*.clreg" } }, Locale.Current.Lang?._GameSettingsPage?.SettingsRegExportTitle); + path ??= await FileDialogNative.GetFileSavePicker(new Dictionary { { "Collapse Registry", "*.clreg" } }, Locale.Current.Lang?._GameSettingsPage?.SettingsRegExportTitle); EnsureFileSaveHasExtension(ref path, ".clreg"); if (string.IsNullOrEmpty(path)) throw new OperationCanceledException(Locale.Current.Lang?._GameSettingsPage?.SettingsRegErr1); @@ -533,5 +536,70 @@ protected virtual void ReadBinary(EndianBinaryReader reader, string valueName) _ = reader.Read(val, 0, len); RegistryRoot?.SetValue(valueName, val, RegistryValueKind.Binary); } + + + # region database + + private string GameTypeValue => (GameVersionManager?.GameType is not GameNameType.Plugin + ? GameVersionManager?.GameType.ToString() : GameVersionManager?.GameName.Replace(" ", "")) ?? "UNKNOWN"; + private string KeySettings => $"{GameTypeValue}-{GameVersionManager?.GameRegion}-gs"; + private string KeyLastUpdated => $"{GameTypeValue}-{GameVersionManager?.GameRegion}-gs-lu"; + + public async Task PushToDatabase() + { + try + { + string path = Path.GetTempFileName(); + _ = await ExportSettings(false, null, null, path); + + if (!File.Exists(path)) return null; + + var fi = new FileInfo(path); + if (fi.Length == 0) return null; + byte[] fileBytes = await File.ReadAllBytesAsync(path); + await DbHandler.StoreKeyValue(KeySettings, "", true, true, fileBytes); + await DbHandler.StoreKeyValue(KeyLastUpdated, DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(), true); + fi.Delete(); + } + catch (Exception ex) + { + Console.WriteLine(ex); + return ex; + } + + return null; + } + + public async Task GetFromDatabase() + { + try + { + var retval = await DbHandler.QueryKey(KeySettings, true, true); + + if (retval == null) throw new NullReferenceException(); + + string path = Path.GetTempFileName(); + await File.WriteAllBytesAsync(path, Convert.FromHexString(retval)); + + string? gameBasePath = null; + if (GameVersionManager?.GameType == GameNameType.Zenless) + { + gameBasePath = ConverterTool.NormalizePath(GameVersionManager?.GameDirPath); + } + + await ImportSettings(gameBasePath, path); + + File.Delete(path); + } + catch (Exception ex) + { + Console.WriteLine(ex); + return ex; + } + + return null; + } + + #endregion } } diff --git a/CollapseLauncher/Classes/GameManagement/GameSettings/BaseClass/MagicNodeBaseValues.cs b/CollapseLauncher/Classes/GameManagement/GameSettings/BaseClass/MagicNodeBaseValues.cs index 2dc0372ada..32e6f1e1e7 100644 --- a/CollapseLauncher/Classes/GameManagement/GameSettings/BaseClass/MagicNodeBaseValues.cs +++ b/CollapseLauncher/Classes/GameManagement/GameSettings/BaseClass/MagicNodeBaseValues.cs @@ -28,13 +28,6 @@ public enum JsonEnumStoreType internal static class MagicNodeBaseValuesExt { - // ReSharper disable once UnusedMember.Local - private static readonly JsonSerializerOptions JsonSerializerOpts = new() - { - AllowTrailingCommas = true, - ReadCommentHandling = JsonCommentHandling.Skip - }; - private static JsonObject EnsureCreatedObject(this JsonNode? node, string keyName) { // If the node is empty, then create a new instance of it @@ -336,7 +329,11 @@ internal class MagicNodeBaseValues : NotifyPropertyChanged, IGameSettingsValu private SettingsGameVersionManager GameVersionManager { get; set; } [JsonIgnore] - protected JsonNode? SettingsJsonNode { get; private set; } + protected JsonNode? SettingsJsonNode + { + get; + private set; + } [JsonIgnore] public IGameSettings ParentGameSettings => null!; @@ -406,15 +403,14 @@ public void Save() { // Get the file and dir path string filePath = GameVersionManager.ConfigFilePath; - string? fileDirPath = Path.GetDirectoryName(filePath); - - // Create the dir if not exist - if (string.IsNullOrEmpty(fileDirPath) && !Directory.Exists(fileDirPath)) - Directory.CreateDirectory(fileDirPath!); // Write into the file string jsonString = SettingsJsonNode.SerializeJsonNode(TypeInfo, false, true); Sleepy.WriteString(filePath, jsonString, Magic); + +#if DEBUG + Logger.LogWriteLine($"Serialized data:\r\n{jsonString}", LogType.Debug, true); +#endif } public override bool Equals(object? obj) diff --git a/CollapseLauncher/Classes/GameManagement/GameSettings/Zenless/FileClass/GeneralData.cs b/CollapseLauncher/Classes/GameManagement/GameSettings/Zenless/FileClass/GeneralData.cs index 67a9075499..a424ba10f6 100644 --- a/CollapseLauncher/Classes/GameManagement/GameSettings/Zenless/FileClass/GeneralData.cs +++ b/CollapseLauncher/Classes/GameManagement/GameSettings/Zenless/FileClass/GeneralData.cs @@ -3,7 +3,6 @@ using CollapseLauncher.GameSettings.Zenless.JsonProperties; using Hi3Helper; using System; -using System.Diagnostics.CodeAnalysis; using System.Text.Json.Nodes; using System.Text.Json.Serialization; using System.Text.Json.Serialization.Metadata; @@ -20,34 +19,10 @@ namespace CollapseLauncher.GameSettings.Zenless { [GeneratedBindableCustomProperty] - internal sealed partial class GeneralData : MagicNodeBaseValues, IDisposable + internal sealed partial class GeneralData : MagicNodeBaseValues { - #region Disposer - - ~GeneralData() - { - _systemSettingDataMap = null; - _keyboardBindingMap = null; - _mouseBindingMap = null; - _gamepadBindingMap = null; - - GC.Collect(); - } - - public void Dispose() - { - GC.SuppressFinalize(this); - } - - #endregion - #region Node Based Properties - private JsonNode? _systemSettingDataMap; - private JsonNode? _keyboardBindingMap; - private JsonNode? _mouseBindingMap; - private JsonNode? _gamepadBindingMap; - [JsonPropertyName("SystemSettingDataMap")] [JsonIgnore] // We ignore this one from getting serialized to default JSON value public JsonNode SystemSettingDataMap @@ -55,8 +30,8 @@ public JsonNode SystemSettingDataMap // Cache the SystemSettingDataMap inside the parent SettingsJsonNode // and ensure that the node for SystemSettingDataMap exists. If not exist, // create a new one (via GetAsJsonNode()). - get => _systemSettingDataMap ??= SettingsJsonNode.GetAsJsonNode("SystemSettingDataMap"); - set => _systemSettingDataMap?.SetAsJsonNode("SystemSettingDataMap", value); + get => field ??= SettingsJsonNode.GetAsJsonNode("SystemSettingDataMap"); + set => field?.SetAsJsonNode("SystemSettingDataMap", value); } [JsonPropertyName("KeyboardBindingMap")] @@ -66,8 +41,8 @@ public JsonNode KeyboardBindingMap // Cache the KeyboardBindingMap inside the parent SettingsJsonNode // and ensure that the node for KeyboardBindingMap exists. If not exist, // create a new one (via GetAsJsonNode()). - get => _keyboardBindingMap ??= SettingsJsonNode.GetAsJsonNode("KeyboardBindingMap"); - set => _keyboardBindingMap?.SetAsJsonNode("KeyboardBindingMap", value); + get => field ??= SettingsJsonNode.GetAsJsonNode("KeyboardBindingMap"); + set => field?.SetAsJsonNode("KeyboardBindingMap", value); } [JsonPropertyName("MouseBindingMap")] @@ -77,8 +52,8 @@ public JsonNode MouseBindingMap // Cache the MouseBindingMap inside the parent SettingsJsonNode // and ensure that the node for MouseBindingMap exists. If not exist, // create a new one (via GetAsJsonNode()). - get => _mouseBindingMap ??= SettingsJsonNode.GetAsJsonNode("MouseBindingMap"); - set => _mouseBindingMap?.SetAsJsonNode("MouseBindingMap", value); + get => field ??= SettingsJsonNode.GetAsJsonNode("MouseBindingMap"); + set => field?.SetAsJsonNode("MouseBindingMap", value); } [JsonPropertyName("GamepadBindingMap")] @@ -88,26 +63,23 @@ public JsonNode GamepadBindingMap // Cache the GamepadBindingMap inside the parent SettingsJsonNode // and ensure that the node for GamepadBindingMap exists. If not exist, // create a new one (via GetAsJsonNode()). - get => _gamepadBindingMap ??= SettingsJsonNode.GetAsJsonNode("GamepadBindingMap"); - set => _gamepadBindingMap?.SetAsJsonNode("GamepadBindingMap", value); + get => field ??= SettingsJsonNode.GetAsJsonNode("GamepadBindingMap"); + set => field?.SetAsJsonNode("GamepadBindingMap", value); } [JsonPropertyName("PlayerPrefs_StringContainer")] - [JsonIgnore] - [field: AllowNull, MaybeNull] // We ignore this one from getting serialized to default JSON value + [JsonIgnore] // We ignore this one from getting serialized to default JSON value public JsonNode PlayerPrefsStringContainer { // Cache the PlayerPrefsStringContainer inside the parent SettingsJsonNode // and ensure that the node for PlayerPrefsStringContainer exists. If not exist, // create a new one (via GetAsJsonNode()). - get => field ??= - SettingsJsonNode.GetAsJsonNode("PlayerPrefs_StringContainer"); + get => field ??= SettingsJsonNode.GetAsJsonNode("PlayerPrefs_StringContainer"); set => field?.SetAsJsonNode("PlayerPrefs_StringContainer", value); } [JsonPropertyName("PlayerPrefs_IntContainer")] - [JsonIgnore] - [field: AllowNull, MaybeNull] // We ignore this one from getting serialized to default JSON value + [JsonIgnore] // We ignore this one from getting serialized to default JSON value public JsonNode PlayerPrefsIntContainer { // Cache the PlayerPrefsIntContainer inside the parent SettingsJsonNode @@ -118,15 +90,13 @@ public JsonNode PlayerPrefsIntContainer } [JsonPropertyName("PlayerPrefs_FloatContainer")] - [JsonIgnore] - [field: AllowNull, MaybeNull] // We ignore this one from getting serialized to default JSON value + [JsonIgnore] // We ignore this one from getting serialized to default JSON value public JsonNode PlayerPrefsFloatContainer { // Cache the PlayerPrefsFloatContainer inside the parent SettingsJsonNode // and ensure that the node for PlayerPrefsFloatContainer exists. If not exist, // create a new one (via GetAsJsonNode()). - get => field ??= - SettingsJsonNode.GetAsJsonNode("PlayerPrefs_FloatContainer"); + get => field ??= SettingsJsonNode.GetAsJsonNode("PlayerPrefs_FloatContainer"); set => field?.SetAsJsonNode("PlayerPrefs_FloatContainer", value); } @@ -617,22 +587,22 @@ public static GeneralData Load() public new static GeneralData LoadWithMagic(byte[] magic, SettingsGameVersionManager versionManager, JsonTypeInfo typeInfo) { - var returnVal = MagicNodeBaseValues.LoadWithMagic(magic, versionManager, typeInfo); + GeneralData returnVal = MagicNodeBaseValues.LoadWithMagic(magic, versionManager, typeInfo); #if DEBUG - const bool isPrintDebug = true; - if (isPrintDebug) - { - Logger.LogWriteLine($"Zenless GeneralData parsed value:\r\n\t" + - $"FPS : {returnVal.Fps}\r\n\t" + - $"VSync : {returnVal.VSync}\r\n\t" + - $"RenRes: {returnVal.RenderResolution}\r\n\t" + - $"AA : {returnVal.AntiAliasing}\r\n\t" + - $"Shadow: {returnVal.ShadowQuality}\r\n\t" + - $"CharQ : {returnVal.CharacterQuality}\r\n\t" + - $"RelfQ : {returnVal.ReflectionQuality}\r\n\t", - LogType.Debug, true); - } + const bool isPrintDebug = true; + if (isPrintDebug) + { + Logger.LogWriteLine($"Zenless GeneralData parsed value:\r\n\t" + + $"FPS : {returnVal.Fps}\r\n\t" + + $"VSync : {returnVal.VSync}\r\n\t" + + $"RenRes: {returnVal.RenderResolution}\r\n\t" + + $"AA : {returnVal.AntiAliasing}\r\n\t" + + $"Shadow: {returnVal.ShadowQuality}\r\n\t" + + $"CharQ : {returnVal.CharacterQuality}\r\n\t" + + $"RelfQ : {returnVal.ReflectionQuality}\r\n\t", + LogType.Debug, true); + } #endif return returnVal; diff --git a/CollapseLauncher/Classes/GameManagement/GameSettings/Zenless/JsonProperties/Properties.cs b/CollapseLauncher/Classes/GameManagement/GameSettings/Zenless/JsonProperties/Properties.cs index 1c9ff0bf2f..79379a3d0f 100644 --- a/CollapseLauncher/Classes/GameManagement/GameSettings/Zenless/JsonProperties/Properties.cs +++ b/CollapseLauncher/Classes/GameManagement/GameSettings/Zenless/JsonProperties/Properties.cs @@ -28,7 +28,7 @@ public readonly struct SystemSettingLocalData public void SetDataEnum(TDataEnum value, JsonEnumStoreType enumStoreType = JsonEnumStoreType.AsNumber) where TDataEnum : struct, Enum => _node.SetNodeValueEnum("Data", value, enumStoreType); - public SystemSettingLocalData([NotNull] JsonNode node, TData defaultData = default, int defaultVersion = 1) + public SystemSettingLocalData([NotNull] JsonNode node, TData defaultData = default, int defaultVersion = 0) { ArgumentNullException.ThrowIfNull(node); _node = node; @@ -44,7 +44,7 @@ public SystemSettingLocalData([NotNull] JsonNode node, TData defaultData = defau public static class SystemSettingLocalDataExt { public static SystemSettingLocalData AsSystemSettingLocalData( - [NotNull] this JsonNode? node, string keyName, TData defaultData = default, int defaultVersion = 1) + [NotNull] this JsonNode? node, string keyName, TData defaultData = default, int defaultVersion = 0) where TData : struct { ArgumentNullException.ThrowIfNull(node); diff --git a/CollapseLauncher/Classes/GameManagement/GameSettings/Zenless/Settings.cs b/CollapseLauncher/Classes/GameManagement/GameSettings/Zenless/Settings.cs index 9718db6c2e..edebfc692c 100644 --- a/CollapseLauncher/Classes/GameManagement/GameSettings/Zenless/Settings.cs +++ b/CollapseLauncher/Classes/GameManagement/GameSettings/Zenless/Settings.cs @@ -39,7 +39,12 @@ private byte[] MagicReDo #endregion #region Properties - public GeneralData GeneralData { get; private set; } + + public GeneralData GeneralData + { + get; + private set; + } #endregion public ZenlessSettings(IGameVersion gameVersionManager) : base(gameVersionManager) @@ -57,7 +62,6 @@ public sealed override void InitializeSettings() base.InitializeSettings(); SettingsScreen = ScreenManager.Load(this); - GeneralData?.Dispose(); GeneralData = GeneralData.LoadWithMagic( MagicReDo, SettingsGameVersionManager.Create(GameVersionManager, ZZZSettingsConfigFile, "GENERAL_DATA.bin"), diff --git a/CollapseLauncher/Classes/GameManagement/GameSettings/Zenless/Sleepy.cs b/CollapseLauncher/Classes/GameManagement/GameSettings/Zenless/Sleepy.cs index 87c4309354..07e050c15c 100644 --- a/CollapseLauncher/Classes/GameManagement/GameSettings/Zenless/Sleepy.cs +++ b/CollapseLauncher/Classes/GameManagement/GameSettings/Zenless/Sleepy.cs @@ -1,6 +1,3 @@ -// ReSharper disable CommentTypo -// ReSharper disable UnusedMember.Local -// ReSharper disable UnusedVariable /* * Initial Implementation Credit by: @Shatyuka */ @@ -10,16 +7,19 @@ using System.Buffers; using System.IO; using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; using System.Text; +// ReSharper disable RedundantUnsafeContext +// ReSharper disable UnusedMember.Local // ReSharper disable IdentifierTypo namespace CollapseLauncher.GameSettings.Zenless; #nullable enable -internal static class Sleepy +internal static unsafe class Sleepy { // https://github.com/dotnet/runtime/blob/a7efcd9ca9255dc9faa8b4a2761cdfdb62619610/src/libraries/System.Runtime.Serialization.Formatters/src/System/Runtime/Serialization/Formatters/Binary/BinaryEnums.cs#L7C1-L32C6 - private enum BinaryHeaderEnum + private enum BinaryHeaderEnum : byte { SerializedStreamHeader = 0, Object = 1, @@ -44,47 +44,12 @@ private enum BinaryHeaderEnum CrossAppDomainAssembly = 20, MethodCall = 21, MethodReturn = 22, - BinaryReference = -1 - } - - // https://github.com/dotnet/runtime/blob/a7efcd9ca9255dc9faa8b4a2761cdfdb62619610/src/libraries/System.Runtime.Serialization.Formatters/src/System/Runtime/Serialization/Formatters/Binary/BinaryEnums.cs#L35 - private enum BinaryTypeEnum - { - Primitive = 0, - String = 1, - Object = 2, - ObjectUrt = 3, - ObjectUser = 4, - ObjectArray = 5, - StringArray = 6, - PrimitiveArray = 7 - } - - // https://github.com/dotnet/runtime/blob/a7efcd9ca9255dc9faa8b4a2761cdfdb62619610/src/libraries/System.Runtime.Serialization.Formatters/src/System/Runtime/Serialization/Formatters/Binary/BinaryEnums.cs#L47 - private enum BinaryArrayTypeEnum - { - Single = 0, - Jagged = 1, - Rectangular = 2, - SingleOffset = 3, - JaggedOffset = 4, - RectangularOffset = 5 - } - - // https://github.com/dotnet/runtime/blob/a7efcd9ca9255dc9faa8b4a2761cdfdb62619610/src/libraries/System.Runtime.Serialization.Formatters/src/System/Runtime/Serialization/Formatters/Binary/BinaryEnums.cs#L99 - private enum InternalArrayTypeE - { - Empty = 0, - Single = 1, - Jagged = 2, - Rectangular = 3, - Base64 = 4 } internal static string ReadString(string filePath, ReadOnlySpan magic) { // Get the FileInfo - FileInfo fileInfo = new FileInfo(filePath).EnsureNoReadOnly(out bool isExist); + FileInfo fileInfo = new FileInfo(filePath).StripAlternateDataStream().EnsureNoReadOnly(out bool isExist); if (!isExist) throw new FileNotFoundException("[Sleepy::ReadString] File does not exist!"); @@ -93,76 +58,53 @@ internal static string ReadString(string filePath, ReadOnlySpan magic) return ReadString(stream, magic); } - internal static unsafe string ReadString(Stream stream, ReadOnlySpan magic) + [SkipLocalsInit] + internal static string ReadString(Stream stream, ReadOnlySpan magic) { // Stream assertion if (!stream.CanRead) throw new ArgumentException("[Sleepy::ReadString] Stream must be readable!", nameof(stream)); // Assign the reader - using BinaryReader reader = new BinaryReader(stream, Encoding.UTF8, true); + using BinaryReader reader = new(stream, Encoding.UTF8, true); - // Emulate and Assert the BinaryFormatter header info - reader.EmulateSleepyBinaryFormatterHeaderAssertion(); + // Emulate and Assert the BinaryFormatter header + reader.EmulateReadAssert(); // Get the data length - int length = reader.GetBinaryFormatterDataLength(); - int magicLength = magic.Length; + int length = reader.Read7BitEncodedInt(); // Alloc temporary buffers - char[] bufferChars = ArrayPool.Shared.Rent(length); + Span evil = stackalloc bool[magic.Length]; + byte[] evilBuffer = ArrayPool.Shared.Rent(length); + char[] unevilBuffer = ArrayPool.Shared.Rent(length); - // Do the do - CreateEvil(magic, out bool[] evil, out int evilsCount); - fixed (bool* evp = &evil[0]) - fixed (char* bp = &bufferChars[0]) - { - try - { - // Do the do (pt. 2) - int j = InternalDecode(magic, evp, reader, length, magicLength, bp); - - // Emulate and Assert the BinaryFormatter footer - reader.EmulateSleepyBinaryFormatterFooterAssertion(); - - // Return - return new string(bp, 0, j); - } - finally - { - // Return and clear the buffer, to only returns the return string. - ArrayPool.Shared.Return(bufferChars, true); - } - } - } + // Read evil data to evil buffer >:) + reader.BaseStream.ReadExactly(evilBuffer, 0, length); - private static unsafe int InternalDecode(ReadOnlySpan magic, bool* evil, BinaryReader reader, int length, int magicLength, char* bp) - { - bool eepy = false; + try + { + // Do the do + CreateEvil(magic, evil); - int j = 0; - int i = 0; + // Do the do (pt. 2) + int j = InternalRead(magic, + evil, + evilBuffer.AsSpan(0, length), + unevilBuffer.AsSpan(0, length)); - amimir: - var n = i % magicLength; - byte c = reader.ReadByte(); - byte ch = (byte)(c ^ magic[n]); + // Emulate and Assert the BinaryFormatter footer + reader.EmulateReadAssertMessageEnd(); - if (*(evil + n)) - { - eepy = ch != 0; + // Return + return new string(unevilBuffer, 0, j); } - else + finally { - if (eepy) - { - ch += 0x40; - eepy = false; - } - *(bp + j++) = (char)ch; + // Return and clear the buffer, to only returns the return string. + evil.Clear(); + ArrayPool.Shared.Return(evilBuffer, true); + ArrayPool.Shared.Return(unevilBuffer, true); } - - if (++i < length) goto amimir; - return j; } internal static void WriteString(string filePath, ReadOnlySpan content, ReadOnlySpan magic) @@ -182,7 +124,8 @@ internal static void WriteString(string filePath, ReadOnlySpan content, Re WriteString(stream, content, magic); } - internal static unsafe void WriteString(Stream stream, ReadOnlySpan content, ReadOnlySpan magic) + [SkipLocalsInit] + internal static void WriteString(Stream stream, ReadOnlySpan content, ReadOnlySpan magic) { // Stream assertion if (!stream.CanWrite) throw new ArgumentException("[Sleepy::WriteString] Stream must be writable!", nameof(stream)); @@ -191,178 +134,206 @@ internal static unsafe void WriteString(Stream stream, ReadOnlySpan conten if (magic.Length == 0) throw new ArgumentException("[Sleepy::WriteString] Magic cannot be empty!", nameof(magic)); // Assign the writer - using BinaryWriter writer = new BinaryWriter(stream, Encoding.UTF8, true); + using BinaryWriter writer = new(stream, Encoding.UTF8, true); // Emulate to write the BinaryFormatter header - writer.EmulateSleepyBinaryFormatterHeaderWrite(); + writer.EmulateWrite(); // Do the do - int contentLen = content.Length; - int bufferLen = contentLen * 2; + int contentLen = content.Length; + int bufferLen = Encoding.UTF8.GetMaxByteCount(contentLen); // Alloc temporary buffers - byte[] contentBytes = ArrayPool.Shared.Rent(bufferLen); - byte[] encodedBytes = ArrayPool.Shared.Rent(bufferLen); + Span evil = stackalloc bool[magic.Length]; + byte[] evilBuffer = ArrayPool.Shared.Rent(bufferLen); + byte[] unevilBuffer = ArrayPool.Shared.Rent(bufferLen); - // Do the do - CreateEvil(magic, out bool[] evil, out int evilsCount); - - fixed (char* cp = &content[0]) - fixed (byte* bp = &contentBytes[0]) - fixed (byte* ep = &encodedBytes[0]) - fixed (bool* evp = &evil[0]) - { - try - { - // Get the string bytes - _ = Encoding.UTF8.GetBytes(cp, contentLen, bp, bufferLen); - - // Do the do (pt. 2) - int h = InternalWrite(magic, contentLen, bp, ep, evp); - - writer.Write7BitEncodedInt(h); - writer.BaseStream.Write(encodedBytes, 0, h); - writer.EmulateSleepyBinaryFormatterFooterWrite(); - } - finally - { - // Return and clear the buffer. - ArrayPool.Shared.Return(contentBytes, true); - ArrayPool.Shared.Return(encodedBytes, true); - } - } + try + { + // Encode content to unevil UTF-8 buffer + int unevilBufferLen = Encoding.UTF8.GetBytes(content, unevilBuffer); + + // Do the do + CreateEvil(magic, evil); + + // Do the do (pt. 2) + int h = InternalWrite(magic, + evil, + evilBuffer, + unevilBuffer.AsSpan(0, unevilBufferLen)); + + writer.Write7BitEncodedInt(h); + writer.BaseStream.Write(evilBuffer, 0, h); + writer.EmulateWriteMessageEnd(); + } + finally + { + // Return and clear the buffer. + evil.Clear(); + ArrayPool.Shared.Return(evilBuffer, true); + ArrayPool.Shared.Return(unevilBuffer, true); + } + } + + private static int InternalRead( + ReadOnlySpan magic, + scoped ReadOnlySpan evil, + ReadOnlySpan evilBuffer, + Span unevilBuffer) + { + bool eepy = false; + + int j = 0; + int i = 0; + + amimir: + int n = i % magic.Length; + byte c = evilBuffer[i]; + byte ch = (byte)(c ^ magic[n]); + + if (evil[n]) + { + eepy = ch != 0; + } + else + { + if (eepy) + { + ch += 0x40; + eepy = false; + } + unevilBuffer[j++] = (char)ch; + } + + if (++i < evilBuffer.Length) goto amimir; + return j; } - private static unsafe int InternalWrite(ReadOnlySpan magic, int contentLen, byte* bp, byte* ep, bool* evil) + private static int InternalWrite( + ReadOnlySpan magic, + scoped ReadOnlySpan evil, + Span evilBuffer, + ReadOnlySpan unevilBuffer) { int h = 0; int i = 0; int j = 0; - amimir: + amimir: int n = i % magic.Length; - byte ch = *(bp + j); - if (*(evil + n)) + byte ch = unevilBuffer[j]; + if (evil[n]) { byte eepy = 0; - if (*(bp + j) >= 0x40) + if (unevilBuffer[j] >= 0x40) { ch -= 0x40; eepy = 1; } - *(ep + h++) = (byte)(eepy ^ magic[n]); + evilBuffer[h++] = (byte)(eepy ^ magic[n]); n = ++i % magic.Length; } - *(ep + h++) = (byte)(ch ^ magic[n]); + evilBuffer[h++] = (byte)(ch ^ magic[n]); ++i; ++j; - if (j < contentLen) goto amimir; + if (j < unevilBuffer.Length) goto amimir; return h; } - private static void CreateEvil(ReadOnlySpan magic, out bool[] evilist, out int evilsCount) + private static void CreateEvil(ReadOnlySpan magic, scoped Span evilist) { int magicLength = magic.Length; int i = 0; - evilist = new bool[magicLength]; - evilsCount = 0; - evilist: + + evilist: int n = i % magicLength; evilist[i] = (magic[n] & 0xC0) == 0xC0; - if (evilist[i]) ++evilsCount; + if (++i < magicLength) goto evilist; } - private static void EmulateSleepyBinaryFormatterHeaderAssertion(this BinaryReader reader) + extension(BinaryReader reader) { - // Do assert [class] -> [string object] - // START! - // Check if the first byte is SerializedStreamHeader - reader.LogAssertInfoByteEnum(BinaryHeaderEnum.SerializedStreamHeader); + private void EmulateReadAssert() + { + // Check if the record type is a SerializedStreamHeader + reader.ReadAssert(BinaryHeaderEnum.SerializedStreamHeader); - // Check if the type is an Object - reader.LogAssertInfoInt32Enum(BinaryHeaderEnum.Object); + // Check if Root object ID == 1 + reader.ReadAssert(1); - // Check if the type is a BinaryReference - reader.LogAssertInfoInt32Enum(BinaryHeaderEnum.BinaryReference); + // Check if No header object is required + reader.ReadAssert(-1); - // Check if the BinaryReference type is a String - reader.LogAssertInfoInt32Enum(BinaryTypeEnum.String); + // Check if the major version is 1 + reader.ReadAssert(1); - // Check for the binary array type and check if it's Single - reader.LogAssertInfoInt32Enum(BinaryArrayTypeEnum.Single); + // Check if the minor version is 0 + reader.ReadAssert(0); - // Check for the binary type and check if it's StringArray (UTF-8) - reader.LogAssertInfoByteEnum(BinaryTypeEnum.StringArray); + // Check if the record type is an ObjectString + reader.ReadAssert(BinaryHeaderEnum.ObjectString); - // Check for the internal array type and check if it's Single - reader.LogAssertInfoInt32Enum(InternalArrayTypeE.Single); - } + // Check if Root object ID == 1 + reader.ReadAssert(1); + } - // Do assert [class] -> [EOF mark] - // START! - private static void EmulateSleepyBinaryFormatterFooterAssertion(this BinaryReader reader) => - reader.LogAssertInfoByteEnum(BinaryHeaderEnum.MessageEnd); + private void EmulateReadAssertMessageEnd() => + reader.ReadAssert(BinaryHeaderEnum.MessageEnd); - private static void EmulateSleepyBinaryFormatterHeaderWrite(this BinaryWriter writer) - { - // Emulate to write Sleepy BinaryFormatter header information - writer.WriteEnumAsByte(BinaryHeaderEnum.SerializedStreamHeader); - writer.WriteEnumAsInt32(BinaryHeaderEnum.Object); - writer.WriteEnumAsInt32(BinaryHeaderEnum.BinaryReference); - writer.WriteEnumAsInt32(BinaryTypeEnum.String); - writer.WriteEnumAsInt32(BinaryArrayTypeEnum.Single); - writer.WriteEnumAsByte(BinaryTypeEnum.StringArray); - writer.WriteEnumAsInt32(InternalArrayTypeE.Single); - } + [SkipLocalsInit] + private void ReadAssert(T assertWith) + where T : unmanaged + { + Span buffer = stackalloc byte[sizeof(T)]; + _ = reader.BaseStream.Read(buffer); - // Emulate to write Sleepy BinaryFormatter footer EOF - private static void EmulateSleepyBinaryFormatterFooterWrite(this BinaryWriter writer) => - writer.WriteEnumAsByte(BinaryHeaderEnum.MessageEnd); + ref T thisEnum = ref MemoryMarshal.AsRef(buffer); + if (IsEqual(ref thisEnum, ref assertWith)) + return; - private static void WriteEnumAsByte(this BinaryWriter writer, T headerEnum) - where T : struct, Enum - { - int enumValue = Unsafe.As(ref headerEnum); - writer.Write((byte)enumValue); + throw new InvalidDataException($"[Sleepy::LogAssertInfo] BinaryFormatter header is not valid at stream pos: {reader.BaseStream.Position:x8}. Expecting value: {assertWith} but getting: {thisEnum} instead!"); + } } - private static void WriteEnumAsInt32(this BinaryWriter writer, T headerEnum) - where T : struct, Enum - { - int enumValue = Unsafe.As(ref headerEnum); - writer.Write(enumValue); - } - private static void LogAssertInfoByteEnum(this BinaryReader stream, T assertHeaderEnum) - where T : struct, Enum + extension(BinaryWriter writer) { - int currentInt = stream.ReadByte(); - LogAssertInfo(stream, ref assertHeaderEnum, ref currentInt); - } + private void EmulateWrite() + { + // Emulate to write Sleepy BinaryFormatter header information + writer.Write(BinaryHeaderEnum.SerializedStreamHeader); + writer.Write(1); + writer.Write(-1); + writer.Write(1); + writer.Write(0); + writer.Write(BinaryHeaderEnum.ObjectString); + writer.Write(1); + } - private static void LogAssertInfoInt32Enum(this BinaryReader stream, T assertHeaderEnum) - where T : struct, Enum - { - int currentInt = stream.ReadInt32(); - LogAssertInfo(stream, ref assertHeaderEnum, ref currentInt); - } + // Emulate to write Sleepy BinaryFormatter footer EOF + private void EmulateWriteMessageEnd() => + writer.Write(BinaryHeaderEnum.MessageEnd); - private static void LogAssertInfo(BinaryReader reader, ref T assertHeaderEnum, ref int currentInt) - where T : struct, Enum - { - int intAssertCasted = Unsafe.As(ref assertHeaderEnum); - if (intAssertCasted != currentInt) + private void Write(T value) + where T : unmanaged { - string? assertHeaderEnumValueName = Enum.GetName(assertHeaderEnum); - T comparedEnumCasted = Unsafe.As(ref currentInt); - string? comparedHeaderEnumValueName = Enum.GetName(comparedEnumCasted); - - throw new InvalidDataException($"[Sleepy::LogAssertInfo] BinaryFormatter header is not valid at stream pos: {reader.BaseStream.Position:x8}. Expecting object enum: {assertHeaderEnumValueName} but getting: {comparedHeaderEnumValueName} instead!"); + ReadOnlySpan buffer = MemoryMarshal.AsBytes(new ReadOnlySpan(ref value)); + writer.BaseStream.Write(buffer); } } - private static int GetBinaryFormatterDataLength(this BinaryReader reader) => reader.Read7BitEncodedInt(); + private static bool IsEqual(ref T from, ref T to) + where T : unmanaged + => sizeof(T) switch + { + 1 => Unsafe.As(ref from) == Unsafe.As(ref to), + 2 => Unsafe.As(ref from) == Unsafe.As(ref to), + 4 => Unsafe.As(ref from) == Unsafe.As(ref to), + 8 => Unsafe.As(ref from) == Unsafe.As(ref to), + 16 => Unsafe.As(ref from) == Unsafe.As(ref to), + _ => MemoryMarshal.AsBytes(new Span(ref from)).SequenceEqual(MemoryMarshal.AsBytes(new Span(ref to))) + }; } \ No newline at end of file diff --git a/CollapseLauncher/Classes/GameManagement/ImageBackground/ClipboardUtility.cs b/CollapseLauncher/Classes/GameManagement/ImageBackground/ClipboardUtility.cs index 40a7ac5db1..d42914b0d8 100644 --- a/CollapseLauncher/Classes/GameManagement/ImageBackground/ClipboardUtility.cs +++ b/CollapseLauncher/Classes/GameManagement/ImageBackground/ClipboardUtility.cs @@ -1,5 +1,4 @@ -using CollapseLauncher.Extension; -using CollapseLauncher.XAMLs.Theme.CustomControls; +using CollapseLauncher.XAMLs.Theme.CustomControls; using Hi3Helper; using Hi3Helper.Win32.ManagedTools; using Hi3Helper.Win32.Native.Enums; @@ -8,13 +7,11 @@ using Hi3Helper.Win32.WinRT.WindowsStream; using Microsoft.Extensions.Logging; using Microsoft.Graphics.Canvas; -using Microsoft.UI.Dispatching; using System; using System.IO; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using System.Runtime.InteropServices.WindowsRuntime; -using System.Threading; using System.Threading.Tasks; using Windows.Graphics.DirectX; using Windows.Graphics.DirectX.Direct3D11; @@ -30,10 +27,8 @@ public static class ClipboardUtility { public static async void CopyCurrentFrameToClipboard( ImageBackgroundManager? instance, - FrameToCopyType frameToCopyType) + FrameToCopyType frameToCopyType) { - bool isBackgroundVideo = false; - try { if (instance == null) @@ -43,67 +38,14 @@ public static async void CopyCurrentFrameToClipboard( bool hasBackground = frameToCopyType.HasFlag(FrameToCopyType.Background); bool hasForeground = frameToCopyType.HasFlag(FrameToCopyType.Foreground); - bool hasBoth = frameToCopyType == FrameToCopyType.Both; // Try to get video frame buffer first (if the background is video). LayeredBackgroundImage? currentElement = instance.CurrentBackgroundElement; - bool isStaticBackgroundShowing = currentElement is { BackgroundStaticSource: not null, IsVideoPlay: false }; - - string? usedBackgroundPath = isStaticBackgroundShowing - ? instance.CurrentSelectedBackgroundContext? - .OriginBackgroundStaticImagePath - : instance.CurrentSelectedBackgroundContext? - .OriginBackgroundImagePath; - - CanvasRenderTarget? backgroundCanvas = hasBackground - ? isStaticBackgroundShowing ? null : currentElement?.LockCanvasRenderTarget() - : null; - - ICanvasResourceCreator? canvasDevice = backgroundCanvas; - canvasDevice ??= CanvasDevice.GetSharedDevice(); - - if (backgroundCanvas != null) - { - // Copy bitmap to avoid arbitrary write to video frame buffer. - CanvasRenderTarget oldCanvas = backgroundCanvas; - backgroundCanvas = new CanvasRenderTarget(canvasDevice, backgroundCanvas.Size._width, backgroundCanvas.Size._height, 96f); - backgroundCanvas.CopyPixelsFromBitmap(oldCanvas); - - isBackgroundVideo = true; // Mark as video so we can unlock the frame later. - } - - // If it's still null (which most likely an image), get the canvas. - backgroundCanvas ??= hasBackground - ? await GetImageCanvasTargetBuffer(canvasDevice, - usedBackgroundPath) - : await GetImageCanvasTargetBuffer(canvasDevice, - instance.CurrentSelectedBackgroundContext? - .OriginOverlayImagePath); - - // If background canvas is still null, abort. - if (backgroundCanvas == null) - { + if (currentElement == null) return; - } - - // Get foreground canvas if available. - CanvasRenderTarget? foregroundCanvas = hasBoth && hasForeground - ? await GetImageCanvasTargetBuffer(canvasDevice, - instance.CurrentSelectedBackgroundContext? - .OriginOverlayImagePath) - : null; - - int widthOfForeground = (int)(foregroundCanvas?.SizeInPixels.Width ?? 0u); - int heightOfForeground = (int)(foregroundCanvas?.SizeInPixels.Height ?? 0u); - if (foregroundCanvas != null && - (backgroundCanvas.SizeInPixels.Width != widthOfForeground || - backgroundCanvas.SizeInPixels.Height != heightOfForeground)) - { - backgroundCanvas = ResizeCanvasToSizeOf(backgroundCanvas, widthOfForeground, heightOfForeground); - } - MergeCanvasTargetWith(backgroundCanvas, foregroundCanvas); // Merge overlay (foreground) to background. - await CopyCanvasTargetBufferToClipboard(backgroundCanvas); // Copy to clipboard + using CanvasRenderTarget? sourceFrameCanvas = await currentElement.CopyCurrentFrame(hasBackground, hasForeground); + await CopyCanvasTargetBufferToClipboard(sourceFrameCanvas); // Copy to clipboard } catch (Exception ex) { @@ -111,89 +53,6 @@ public static async void CopyCurrentFrameToClipboard( LogType.Error, true); } - finally - { - if (isBackgroundVideo) - { - instance?.CurrentBackgroundElement?.UnlockCanvasRenderTarget(); - } - } - } - - private static CanvasRenderTarget ResizeCanvasToSizeOf(CanvasRenderTarget canvasToResize, int width, int height) - { - CanvasRenderTarget resizedCanvas = new(canvasToResize, - width, - height, - canvasToResize.Dpi); - using CanvasDrawingSession ds = resizedCanvas.CreateDrawingSession(); - ds.DrawImage(canvasToResize, - new Windows.Foundation.Rect(0, 0, width, height), - new Windows.Foundation.Rect(0, 0, - canvasToResize.SizeInPixels.Width, - canvasToResize.SizeInPixels.Height), - 1f, - CanvasImageInterpolation.HighQualityCubic); - - canvasToResize.Dispose(); - return resizedCanvas; - } - - private static async Task GetImageCanvasTargetBuffer(ICanvasResourceCreator canvasDevice, string? path) - { - if (string.IsNullOrEmpty(path)) - { - return null; // Ignore if path is null or empty. - } - - // Get source image as native or decoded image (if it requires external codec decoding). - Uri sourceUri = new(path); - (sourceUri, _) = await ImageBackgroundManager - .GetNativeOrDecodedImagePath(sourceUri, CancellationToken.None); - - using CanvasBitmap bitmap = sourceUri.IsFile ? - await CanvasBitmap.LoadAsync(canvasDevice, - sourceUri.LocalPath, - 96f, - CanvasAlphaMode.Premultiplied) : - await CanvasBitmap.LoadAsync(canvasDevice, - sourceUri, - 96f, - CanvasAlphaMode.Premultiplied); - - // Load to render target - return await Task.Run(() => - { - CanvasRenderTarget renderTarget = new(canvasDevice, - bitmap.SizeInPixels.Width, - bitmap.SizeInPixels.Height, bitmap.Dpi); - using CanvasDrawingSession ds = renderTarget.CreateDrawingSession(); - ds.DrawImage(bitmap); - return renderTarget; - }); - } - - private static void MergeCanvasTargetWith(CanvasRenderTarget sourceCanvasTarget, - CanvasRenderTarget? withCanvasTarget) - { - if (withCanvasTarget == null) - { - return; // Abort - } - - CanvasDrawingSession? ds = sourceCanvasTarget.CreateDrawingSession(); - ds.DrawImage(withCanvasTarget); - - // Dispose the overlay canvas - withCanvasTarget.Dispose(); - - if (DispatcherQueueExtensions.CurrentDispatcherQueue.HasThreadAccess) - { - ds.Dispose(); - return; - } - - DispatcherQueueExtensions.CurrentDispatcherQueue.TryEnqueue(DispatcherQueuePriority.High, ds.Dispose); } private static double GetBitmapBitSize(DirectXPixelFormat pixelFormat) @@ -300,9 +159,7 @@ private static async Task WriteCanvasTargetBufferType( private static async Task CopyCanvasTargetBufferToClipboard(CanvasRenderTarget? sourceCanvasTarget) { if (sourceCanvasTarget == null) - { return; - } bool isClipboardOpened = true; byte[]? buffer = null; diff --git a/CollapseLauncher/Classes/GameManagement/ImageBackground/ImageBackgroundManager.CodecDetect.cs b/CollapseLauncher/Classes/GameManagement/ImageBackground/ImageBackgroundManager.CodecDetect.cs index da68852c83..ab907d7c89 100644 --- a/CollapseLauncher/Classes/GameManagement/ImageBackground/ImageBackgroundManager.CodecDetect.cs +++ b/CollapseLauncher/Classes/GameManagement/ImageBackground/ImageBackgroundManager.CodecDetect.cs @@ -1,13 +1,16 @@ using CollapseLauncher.Dialogs; +using CollapseLauncher.Helper.InternalPInvoke; +using CollapseLauncher.Helper.InternalPInvoke.FFmpeg; using CollapseLauncher.XAMLs.Theme.CustomControls; using Hi3Helper.Win32.WinRT.WindowsCodec; using System; using System.IO; using System.Threading.Tasks; - +// ReSharper disable CommentTypo // ReSharper disable IdentifierTypo // ReSharper disable StringLiteralTypo // ReSharper disable CheckNamespace +#pragma warning disable IDE0130 #nullable enable namespace CollapseLauncher.GameManagement.ImageBackground; @@ -16,52 +19,56 @@ public partial class ImageBackgroundManager { #region Codec Checks - private async ValueTask<(bool IsSupported, bool IsVideo)> CheckCodecOrSpawnDialog(Uri? fileUri) + private async ValueTask<(bool IsSupported, bool ForceFFmpeg)> CheckCodecOrSpawnDialog(string? localPath) { - // -- Cancel if null - if (fileUri == null) - { + if (localPath == null) return (false, false); - } - - string filePath = fileUri.IsFile ? fileUri.LocalPath : fileUri.ToString(); // -- Check for supported extension first - if (!IsMediaFileExtensionSupported(filePath)) + if (!IsMediaFileExtensionSupported(localPath)) { - await SimpleDialogs.Dialog_SpawnMediaExtensionNotSupportedDialog(filePath); + await SimpleDialogs.Dialog_SpawnMediaExtensionNotSupportedDialog(localPath); return (false, false); } // -- Check for supported image codec - if (IsImageMediaFileExtensionSupported(filePath)) + if (IsImageMediaFileExtensionSupported(localPath)) { - if (WindowsCodecHelper.IsFileSupportedImage(filePath)) + if (WindowsCodecHelper.IsFileSupportedImage(localPath)) { return (true, false); } - await SimpleDialogs.Dialog_SpawnImageNotSupportedDialog(filePath); + await SimpleDialogs.Dialog_SpawnImageNotSupportedDialog(localPath); return (false, false); } + // -- Check using FFmpeg if available + MediaSupport codecInfo = FFmpegPInvoke.GetMediaSupport(localPath); + PixelColorModel pixelFormat = codecInfo.VideoPixelFormatInfo.ColorModel; + if ((GlobalIsUseFFmpeg && GlobalIsFFmpegAvailable) || + (codecInfo.Video.DecoderAvailable && pixelFormat.HasFlag(PixelColorModel.Rgb))) + { + return (true, true); + } + // -- Check for supported video codec - if (WindowsCodecHelper.IsFileSupportedVideo(filePath, + if (WindowsCodecHelper.IsFileSupportedVideo(localPath, out bool canPlayVideo, out bool canPlayAudio, out Guid videoCodecGuid, out Guid audioCodecGuid) || (GlobalIsUseFFmpeg && GlobalIsFFmpegAvailable)) { - return (true, true); + return (true, false); } return (await SimpleDialogs - .Dialog_SpawnVideoNotSupportedDialog(filePath, + .Dialog_SpawnVideoNotSupportedDialog(localPath, canPlayVideo, canPlayAudio, videoCodecGuid, - audioCodecGuid), true); + audioCodecGuid), false); } #endregion @@ -80,7 +87,7 @@ private static bool IsImageMediaFileExtensionSupported(ReadOnlySpan filePa LayeredBackgroundImage.SupportedImageVectorExtensionsLookup.Contains(extension); } - private static bool IsVideoMediaFileExtensionSupported(ReadOnlySpan filePath) + public static bool IsVideoMediaFileExtensionSupported(ReadOnlySpan filePath) { ReadOnlySpan extension = Path.GetExtension(filePath); return LayeredBackgroundImage.SupportedVideoExtensionsLookup.Contains(extension); diff --git a/CollapseLauncher/Classes/GameManagement/ImageBackground/ImageBackgroundManager.Control.cs b/CollapseLauncher/Classes/GameManagement/ImageBackground/ImageBackgroundManager.Control.cs index 58f6c6ab97..1492aae22f 100644 --- a/CollapseLauncher/Classes/GameManagement/ImageBackground/ImageBackgroundManager.Control.cs +++ b/CollapseLauncher/Classes/GameManagement/ImageBackground/ImageBackgroundManager.Control.cs @@ -1,7 +1,4 @@ -using CollapseLauncher.XAMLs.Theme.CustomControls; -using System.Threading; - -// ReSharper disable ConvertIfStatementToSwitchStatement +// ReSharper disable ConvertIfStatementToSwitchStatement // ReSharper disable CheckNamespace #pragma warning disable IDE0130 @@ -10,61 +7,56 @@ namespace CollapseLauncher.GameManagement.ImageBackground; public partial class ImageBackgroundManager { - private bool _isPausedByUser; - public void SetWindowFocusedEvent() { - if (GlobalKeepPlayVideoWhenWindowUnfocused) - { - CurrentBackgroundElement?.FadeInAudio(); - } - else + if (!GlobalKeepPlayVideoWhenWindowUnfocused) { Play(false); + return; } + + CurrentBackgroundElement?.SetVolume(GlobalBackgroundAudioVolume / 100d); } public void SetWindowUnfocusedEvent() { - if (GlobalKeepPlayVideoWhenWindowUnfocused) - { - CurrentBackgroundElement?.FadeOutAudio(); - } - else + if (!GlobalKeepPlayVideoWhenWindowUnfocused) { Pause(false); + return; } + + CurrentBackgroundElement?.SetVolume(0); } - public void SetWindowMinimizeEvent() => Pause(false); + public void SetWindowMinimizeEvent() + { + CurrentBackgroundElement?.ReleaseViewAllocation(); + } - public void SetWindowRestoreEvent() => Play(false); + public void SetWindowRestoreEvent() + { + // Trynna spawn the background element if not loaded from first start-up. + if (CurrentBackgroundElement == null) + LoadImage(); + + CurrentBackgroundElement?.RestoreViewAllocation(); + } public void Play(bool isUserRequest = true) { - // Block play request for window event if paused by user - if (!isUserRequest && (_isPausedByUser || !CurrentIsEnableBackgroundAutoPlay)) - { - return; - } - if (isUserRequest) { CurrentIsEnableBackgroundAutoPlay = true; } - - // Alter to load static image if it has one instead of pausing. - LayeredImageBackgroundContext? context = CurrentSelectedBackgroundContext; - bool hasStaticImage = !string.IsNullOrEmpty(context?.BackgroundImageStaticPath); - if (isUserRequest && hasStaticImage && context != null) + + // Alter to restore video if it has static image. + if (isUserRequest && !string.IsNullOrEmpty(CurrentSelectedBackgroundContext?.OriginPlaceholderImagePath)) { - LoadImageAtIndex(CurrentSelectedBackgroundIndex, false, CancellationToken.None, true); + LoadImage(); + return; } - // Force to restore autoplay status to true. - CurrentBackgroundElement?.SetValue(LayeredBackgroundImage.IsVideoAutoplayProperty, true); - - Interlocked.Exchange(ref _isPausedByUser, false); CurrentBackgroundElement?.Play(); } @@ -72,16 +64,13 @@ public void Pause(bool isUserRequest = true) { if (isUserRequest) { - Interlocked.Exchange(ref _isPausedByUser, true); CurrentIsEnableBackgroundAutoPlay = false; } - + // Alter to load static image if it has one instead of pausing. - LayeredImageBackgroundContext? context = CurrentSelectedBackgroundContext; - bool hasStaticImage = !string.IsNullOrEmpty(context?.BackgroundImageStaticPath); - if (isUserRequest && hasStaticImage && context != null) + if (isUserRequest && !string.IsNullOrEmpty(CurrentSelectedBackgroundContext?.OriginPlaceholderImagePath)) { - LoadImageAtIndex(CurrentSelectedBackgroundIndex, true, CancellationToken.None, true); + LoadImage(); return; } diff --git a/CollapseLauncher/Classes/GameManagement/ImageBackground/ImageBackgroundManager.FFmpeg.cs b/CollapseLauncher/Classes/GameManagement/ImageBackground/ImageBackgroundManager.FFmpeg.cs index 7338a8a66c..74f688ba40 100644 --- a/CollapseLauncher/Classes/GameManagement/ImageBackground/ImageBackgroundManager.FFmpeg.cs +++ b/CollapseLauncher/Classes/GameManagement/ImageBackground/ImageBackgroundManager.FFmpeg.cs @@ -1,14 +1,13 @@ using CollapseLauncher.Helper; -using CollapseLauncher.Helper.FFmpegPInvoke; +using CollapseLauncher.Helper.InternalPInvoke; +using CollapseLauncher.Helper.InternalPInvoke.FFmpeg; using CollapseLauncher.Helper.StreamUtility; using FFmpegInteropX; using Hi3Helper; using Hi3Helper.Shared.Region; using System; -using System.Collections; using System.Diagnostics.CodeAnalysis; using System.IO; -using System.Linq; using System.Runtime.CompilerServices; // ReSharper disable StringLiteralTypo // ReSharper disable IdentifierTypo @@ -23,70 +22,44 @@ public partial class ImageBackgroundManager #region Shared/Static Properties and Fields private const string GlobalIsUseFFmpegConfigKey = "GlobalIsUseFFmpeg"; - private const string GlobalFFmpegVersionToUseConfigKey = "GlobalFFmpegVersionToUse"; - private const string GlobalFFmpegCustomPathConfigKey = "GlobalFFmpegCustomPath"; private const string GlobalFFmpegDecodingModeConfigKey = "GlobalFFmpegDecodingMode"; - private const int GlobalFFmpegDefaultVersionKey = 80; - public VideoDecoderMode[] AvailableFFmpegDecodingModes => field ??= Enum.GetValues(); public int GlobalFFmpegVersionToUse { - get - { - int version = LauncherConfig.GetAppConfigValue(GlobalFFmpegVersionToUseConfigKey); - return FFmpegPInvoke.FFmpegVersionLibNames.ContainsKey(version) ? - version : - GlobalFFmpegDefaultVersionKey; - } + get => FFmpegPInvoke.FFmpegVersionToUse; set { - if (!FFmpegPInvoke.FFmpegVersionLibNames.ContainsKey(value)) - { - return; - } - - LauncherConfig.SetAndSaveConfigValue(GlobalFFmpegVersionToUseConfigKey, value); + FFmpegPInvoke.FFmpegVersionToUse = value; OnPropertyChanged(); OnPropertyChanged(nameof(GlobalFFmpegLibraryNames)); // Notify FFmpeg library names update too. } } - public FFmpegPInvoke.FFmpegLibraryNames GlobalFFmpegLibraryNames - { - get - { - if (FFmpegPInvoke.FFmpegVersionLibNames.TryGetValue(GlobalFFmpegVersionToUse, out FFmpegPInvoke.FFmpegLibraryNames names)) - { - return names; - } - - return FFmpegPInvoke.FFmpegVersionLibNames.Values.FirstOrDefault(); - } - } + public FFmpegLibraryNames GlobalFFmpegLibraryNames => FFmpegPInvoke.FFmpegLibraryNamings; - public bool GlobalIsUseFFmpeg + public string? GlobalCustomFFmpegPath { - get => LauncherConfig.GetAppConfigValue(GlobalIsUseFFmpegConfigKey); + get => FFmpegPInvoke.CustomFFmpegPath; set { - LauncherConfig.SetAndSaveConfigValue(GlobalIsUseFFmpegConfigKey, value); + FFmpegPInvoke.CustomFFmpegPath = value; OnPropertyChanged(); } } - public string? GlobalCustomFFmpegPath + public bool GlobalIsUseFFmpeg { - get => LauncherConfig.GetAppConfigValue(GlobalFFmpegCustomPathConfigKey); + get => LauncherConfig.GetAppConfigValue(GlobalIsUseFFmpegConfigKey); set { - LauncherConfig.SetAndSaveConfigValue(GlobalFFmpegCustomPathConfigKey, value); + LauncherConfig.SetAndSaveConfigValue(GlobalIsUseFFmpegConfigKey, value); OnPropertyChanged(); } } - public bool GlobalIsFFmpegAvailable => IsFFmpegAvailable(Directory.GetCurrentDirectory(), GlobalFFmpegLibraryNames, out _); + public bool GlobalIsFFmpegAvailable => FFmpegPInvoke.IsFFmpegAvailable(Directory.GetCurrentDirectory(), GlobalFFmpegLibraryNames, out _); public bool GlobalIsFFmpegCurrentlyUsed { @@ -144,17 +117,17 @@ public bool TryRelinkFFmpegPath() return false; } - FFmpegPInvoke.FFmpegLibraryNames names = GlobalFFmpegLibraryNames; + FFmpegLibraryNames names = GlobalFFmpegLibraryNames; string curDir = LauncherConfig.AppExecutableDir; string stockDir = Path.Combine(curDir, "Lib"); - string? stockFindDir = FindFFmpegInstallFolder(stockDir, names); + string? stockFindDir = FFmpegPInvoke.FindFFmpegInstallFolder(stockDir, names); string? customFFmpegDirPath = GlobalCustomFFmpegPath; // -- 1. Check from custom path first. If it exists, then pass. if (!string.IsNullOrEmpty(customFFmpegDirPath) && - IsFFmpegAvailable(customFFmpegDirPath, names, out exception) && + FFmpegPInvoke.IsFFmpegAvailable(customFFmpegDirPath, names, out exception) && TryLinkFFmpegLibrary(customFFmpegDirPath, curDir, names, out exception)) { return result = true; @@ -162,7 +135,7 @@ public bool TryRelinkFFmpegPath() // -- 2. Link stock library to the root directory if (!string.IsNullOrEmpty(stockFindDir) && - IsFFmpegAvailable(stockFindDir, names, out exception) && + FFmpegPInvoke.IsFFmpegAvailable(stockFindDir, names, out exception) && TryLinkFFmpegLibrary(stockFindDir, curDir, names, out exception)) { return result = true; @@ -170,8 +143,14 @@ public bool TryRelinkFFmpegPath() // -- 3. Find one from environment variables. If it exists, then pass. // Otherwise, return false. - return result = TryFindFFmpegInstallFromEnvVar(names, out string? envVarPath, out exception) && - TryLinkFFmpegLibrary(envVarPath, curDir, names, out exception); + if (!FFmpegPInvoke.TryFindFFmpegInstallFromEnvVar(names, out string? envVarPath, out exception) || + !TryLinkFFmpegLibrary(envVarPath, curDir, names, out exception)) + { + return false; + } + + GlobalCustomFFmpegPath = envVarPath; // Set as custom path + return true; } finally { @@ -184,81 +163,9 @@ public bool TryRelinkFFmpegPath() } } - internal bool TryFindFFmpegInstallFromEnvVar(FFmpegPInvoke.FFmpegLibraryNames libraries, [NotNullWhen(true)] out string? path, out Exception? exception) - { - return FindIn(EnvironmentVariableTarget.User, out path, out exception) || - FindIn(EnvironmentVariableTarget.Machine, out path, out exception); - - bool FindIn(EnvironmentVariableTarget target, [NotNullWhen(true)] out string? innerPath, out Exception? exception) - { - const string separators = ";,"; - Unsafe.SkipInit(out innerPath); - Unsafe.SkipInit(out exception); - - foreach (object? varValue in Environment.GetEnvironmentVariables(target)) - { - if (varValue is not DictionaryEntry { Value: string varValueStr }) - { - continue; - } - - ReadOnlySpan envVar = varValueStr; - foreach (Range envVarRange in envVar.SplitAny(separators)) - { - ReadOnlySpan envVarPath = envVar[envVarRange].Trim(" '\""); - if (envVarPath.IsEmpty) - { - continue; - } - - string thisPath = envVarPath.ToString(); - - if (!Path.IsPathFullyQualified(thisPath) || - !IsFFmpegAvailable(thisPath, libraries, out exception)) continue; - - innerPath = thisPath; - GlobalCustomFFmpegPath = thisPath; // Set as custom path - return true; - } - } - - return false; - } - } - - internal static bool IsFFmpegAvailable(string? checkOnDirectory, - FFmpegPInvoke.FFmpegLibraryNames libraries, - [NotNullWhen(false)] - out Exception? exception) - { - if (string.IsNullOrEmpty(checkOnDirectory)) - { - exception = new NullReferenceException($"Argument: {nameof(checkOnDirectory)} is null!"); - return false; - } - - checkOnDirectory = FileUtility.GetFullyQualifiedPath(checkOnDirectory); - - string dllPathAvcodec = Path.Combine(checkOnDirectory, libraries.Codec); - string dllPathAvdevice = Path.Combine(checkOnDirectory, libraries.Device); - string dllPathAvfilter = Path.Combine(checkOnDirectory, libraries.Filter); - string dllPathAvformat = Path.Combine(checkOnDirectory, libraries.Format); - string dllPathAvutil = Path.Combine(checkOnDirectory, libraries.Util); - string dllPathSwresample = Path.Combine(checkOnDirectory, libraries.Resample); - string dllPathSwscale = Path.Combine(checkOnDirectory, libraries.Scale); - - return FileUtility.IsFileExistOrSymbolicLinkResolved(dllPathAvcodec, out _, out exception) && - FileUtility.IsFileExistOrSymbolicLinkResolved(dllPathAvdevice, out _, out exception) && - FileUtility.IsFileExistOrSymbolicLinkResolved(dllPathAvfilter, out _, out exception) && - FileUtility.IsFileExistOrSymbolicLinkResolved(dllPathAvformat, out _, out exception) && - FileUtility.IsFileExistOrSymbolicLinkResolved(dllPathAvutil, out _, out exception) && - FileUtility.IsFileExistOrSymbolicLinkResolved(dllPathSwresample, out _, out exception) && - FileUtility.IsFileExistOrSymbolicLinkResolved(dllPathSwscale, out _, out exception); - } - internal static string[] GetFFmpegRequiredDllFilenames() { - FFmpegPInvoke.FFmpegLibraryNames names = Shared.GlobalFFmpegLibraryNames; + FFmpegLibraryNames names = Shared.GlobalFFmpegLibraryNames; return [ names.Codec, names.Device, @@ -270,35 +177,10 @@ internal static string[] GetFFmpegRequiredDllFilenames() ]; } - internal static string? FindFFmpegInstallFolder(string checkOnDirectory, FFmpegPInvoke.FFmpegLibraryNames libraries) - { - try - { - if (IsFFmpegAvailable(checkOnDirectory, libraries, out _)) - { - return checkOnDirectory; - } - - foreach (string dirPath in FileUtility.EnumerateDirectoryRecursive(checkOnDirectory)) - { - if (IsFFmpegAvailable(dirPath, libraries, out _)) - { - return dirPath; - } - } - } - catch - { - // ignored - } - - return null; - } - public static bool TryLinkFFmpegLibrary( - string? sourceDir, - string? targetDir, - FFmpegPInvoke.FFmpegLibraryNames libraries, + string? sourceDir, + string? targetDir, + FFmpegLibraryNames libraries, [NotNullWhen(false)] out Exception? exception) { diff --git a/CollapseLauncher/Classes/GameManagement/ImageBackground/ImageBackgroundManager.ImageCropperAndConvert.cs b/CollapseLauncher/Classes/GameManagement/ImageBackground/ImageBackgroundManager.ImageCropperAndConvert.cs index dca17f19f6..6e16b4acd7 100644 --- a/CollapseLauncher/Classes/GameManagement/ImageBackground/ImageBackgroundManager.ImageCropperAndConvert.cs +++ b/CollapseLauncher/Classes/GameManagement/ImageBackground/ImageBackgroundManager.ImageCropperAndConvert.cs @@ -1,6 +1,7 @@ using CollapseLauncher.Dialogs; using CollapseLauncher.Extension; using CollapseLauncher.Helper; +using CollapseLauncher.Helper.Image; using CollapseLauncher.Helper.StreamUtility; using CollapseLauncher.XAMLs.Theme.ContentDialog; using CollapseLauncher.XAMLs.Theme.CustomControls; @@ -35,6 +36,39 @@ namespace CollapseLauncher.GameManagement.ImageBackground; public partial class ImageBackgroundManager { + private readonly struct GetCroppedResult + { + public readonly string? ProcessedOverlayPath; + public readonly string? ProcessedBackgroundPath; + public readonly bool IsCancelResize; + public readonly bool WasCropPerformed; + + private GetCroppedResult(string? processedOverlayPath, + string? processedBackgroundPath, + bool isCancelResize, + bool wasCropPerformed) + { + ProcessedOverlayPath = processedOverlayPath; + ProcessedBackgroundPath = processedBackgroundPath; + IsCancelResize = isCancelResize; + WasCropPerformed = wasCropPerformed; + } + + public static GetCroppedResult Create(string? processedOverlayPath, + string? processedBackgroundPath, + bool isCancelResize, + bool wasCropPerformed) + { + return new GetCroppedResult(processedOverlayPath, + processedBackgroundPath, + isCancelResize, + wasCropPerformed); + } + + public static readonly GetCroppedResult Cancelled = Create(null, null, true, false); + public static readonly GetCroppedResult Skip = Create(null, null, false, false); + } + /// /// Get local path of the resized/cropped custom image. The return values are cropped image path. /// If the input path is a video, then it will return the input path. @@ -42,7 +76,7 @@ public partial class ImageBackgroundManager /// To perform cropping request, you must set to . /// Otherwise, it will return the input paths. /// - private static async Task<(string? ProcessedOverlayPath, string? ProcessedBackgroundPath, bool IsCancelResize)> + private static async Task GetCroppedCustomImage(string? overlayUrlOrPath, string? backgroundUrlOrPath, bool performCropRequest, @@ -52,21 +86,21 @@ public partial class ImageBackgroundManager string.IsNullOrEmpty(backgroundUrlOrPath)) || string.IsNullOrEmpty(backgroundUrlOrPath)) { - return (null, null, false); // Skip from processing + return GetCroppedResult.Skip; } // -- Check if the file is video or image if (LayeredBackgroundImage.SupportedVideoExtensionsLookup .Contains(Path.GetExtension(backgroundUrlOrPath))) { - return (overlayUrlOrPath, backgroundUrlOrPath, false); + return GetCroppedResult.Create(overlayUrlOrPath, backgroundUrlOrPath, false, false); } // -- Try to get existing image file path if (TryGetCroppedImageFilePath(backgroundUrlOrPath, out string croppedBackgroundPath) && !performCropRequest) { - return (null, croppedBackgroundPath, false); + return GetCroppedResult.Create(null, croppedBackgroundPath, false, false); } try @@ -90,26 +124,21 @@ public partial class ImageBackgroundManager Uri backgroundImageUri = new(decodedBackgroundPath); // -- Create elements, load image and spawn the dialog box. - Grid parentGrid = UIElementExtensions - .Create(SetParentGridProperties); - - ContentDialogOverlay dialog = UIElementExtensions - .Create(SetDialogProperties); - - ImageCropper cropper = UIElementExtensions - .Create(SetImageCropperProperties); + Grid parentGrid = UIElementExtensions.Create(SetParentGridProperties); + ContentDialogOverlay dialog = UIElementExtensions.Create(SetDialogProperties); + ImageCropper cropper = UIElementExtensions.Create(SetImageCropperProperties); // Skip spawning crop overlay when source dimensions do not meet minimum crop size. if (!await CanOpenCropOverlay(backgroundImageUri, backgroundCodecType == ImageExternalCodecType.Svg, cropper, token)) { - return (overlayUrlOrPath, backgroundUrlOrPath, false); + return GetCroppedResult.Create(overlayUrlOrPath, backgroundUrlOrPath, false, false); } // -- Register to close dialog if cancellation is triggered outside the event. token.Register(dialog.Hide); if (token.IsCancellationRequested) { - return (null, null, true); + return GetCroppedResult.Cancelled; } DispatcherQueueExtensions @@ -124,29 +153,18 @@ public partial class ImageBackgroundManager ContentDialogResult dialogResult = await dialog.QueueAndSpawnDialog(); if (dialogResult is ContentDialogResult.Secondary or ContentDialogResult.None) { - return (null, null, true); + return GetCroppedResult.Cancelled; } // -- Save cropped image await SaveCroppedImageToFilePath(backgroundUrlOrPath, croppedBackgroundPath, cropper, token); // -- If there's an upscaled image, delete it. - if (TryGetUpscaledFilePath(croppedBackgroundPath, - out string croppedImagePath)) - { - try - { - File.Delete(croppedImagePath); - } - catch (Exception ex) - { - Logger.LogWriteLine($"An error has occurred while trying to delete previous upscaled image: {ex}", - LogType.Error, - true); - } - } + if (TryGetUpscaledFilePath(croppedBackgroundPath, out string croppedImagePath)) + StreamExtension.TryDeleteFile(croppedImagePath); + - return (null, croppedBackgroundPath, false); + return GetCroppedResult.Create(null, croppedBackgroundPath, false, true); void SetDialogProperties(ContentDialogOverlay element) { @@ -189,7 +207,73 @@ void SetImageCropperProperties(ImageCropper element) } catch (OperationCanceledException) { - return (null, null, true); + return GetCroppedResult.Cancelled; + } + } + + public static bool TryGetUpscaledFilePath(string inputFilePath, + out string upscaledFilePath) + { + upscaledFilePath = Path.Combine(LauncherConfig.AppGameImgCachedFolder, $"scaled_{Path.GetFileNameWithoutExtension(inputFilePath)}.png"); + if (LayeredBackgroundImage.SupportedVideoExtensionsLookup.Contains(Path.GetExtension(inputFilePath))) // Ignore if file is a video. + { + upscaledFilePath = inputFilePath; + return true; + } + + FileInfo outputFileInfo = new(upscaledFilePath); + return outputFileInfo is { Exists: true, Length: > 0 }; + } + + public static async Task TryGetScaledWaifu2XImagePath(Uri uri, CancellationToken token) + { + if (uri == null!) + { + return null!; + } + + if (!uri.IsFile) + { + throw new InvalidOperationException("You must provide a local file path!"); + } + + // Ignore if the input file is a video + string inputFilePath = uri.LocalPath; + if (TryGetUpscaledFilePath(inputFilePath, out string outputFilePath)) + { + return new Uri(outputFilePath); + } + + FileInfo outputFileInfo = new(outputFilePath); + outputFileInfo.Directory?.Create(); + + await Task.Run(Impl, token); + outputFileInfo.Refresh(); + + try + { + token.ThrowIfCancellationRequested(); + return new Uri(outputFileInfo.FullName); + } + catch + { + outputFileInfo.TryDeleteFile(); + throw; + } + + void Impl() + { + using ProcessingPipeline pipeline = + MagicImageProcessor + .BuildPipeline(inputFilePath, + ProcessImageSettings.Default); + + using FileStream outputFileStream = outputFileInfo.Open(FileMode.Create, + FileAccess.Write, + FileShare.ReadWrite); + + pipeline.AddTransform(new Waifu2XTransform(ImageLoaderHelper.Waifu2X)); + pipeline.WriteOutput(outputFileStream); } } @@ -382,7 +466,7 @@ private static async Task ConvertImageToPngFromStream( /// /// Converts any (seekable) image stream to PNG. /// - private static Task ConvertImageToPngFromStream( + private static async Task ConvertImageToPngFromStream( Stream sourceStream, Stream targetStream, Rectangle cropArea = default, @@ -394,7 +478,8 @@ private static Task ConvertImageToPngFromStream( IsBackground = true }.Start((sourceStream, targetStream, tcs, token, cropArea)); - return tcs.Task; + await tcs.Task; + return; static void Impl(object? state) { @@ -407,6 +492,8 @@ static void Impl(object? state) try { + innerSourceStream.Position = 0; + // We won't do any post-processing and just do a straight conversion to PNG. // The post-processing (like resizing with Waifu2X) will be done on image loading process. ProcessImageSettings settings = new() @@ -478,40 +565,51 @@ private static bool IsWebpExtendedFormat(Stream stream, out ColorProfileMode col /// Get native or redirected decoded image path. /// /// If the codec of the image is not supported. - internal static async Task<(string, ImageExternalCodecType)> GetNativeOrDecodedImagePath(string filePath, CancellationToken token) + internal static ValueTask<(string, ImageExternalCodecType)> GetNativeOrDecodedImagePath(string filePath, CancellationToken token = default) { // Try to get decoded temporary file. If it exists, return the file path. if (TryGetDecodedTemporaryFile(filePath, out string decodedFilePath)) { - return (decodedFilePath, ImageExternalCodecType.Default); + return new ValueTask<(string, ImageExternalCodecType)>((decodedFilePath, ImageExternalCodecType.Default)); } - // Check the extension type. If the type is native or a video file, then just return the original path. - if (await TryGetImageCodecType(filePath, token) is var codecType && - codecType is ImageExternalCodecType.Default or ImageExternalCodecType.Svg) + Task<(string, ImageExternalCodecType)> processTask = ImplAsync(); + if (processTask.IsCompleted) { - return (filePath, codecType); + return new ValueTask<(string, ImageExternalCodecType)>(processTask.Result); } - // Return null if the codec type is not supported. - if (codecType == ImageExternalCodecType.NotSupported) + return new ValueTask<(string, ImageExternalCodecType)>(processTask); + + async Task<(string, ImageExternalCodecType)> ImplAsync() { - throw new NotSupportedException($"The format of the image: {filePath} is not supported!"); - } + // Check the extension type. If the type is native or a video file, then just return the original path. + if (await TryGetImageCodecType(filePath, token) is var codecType && + codecType is ImageExternalCodecType.Default or ImageExternalCodecType.Svg) + { + return (filePath, codecType); + } - // Otherwise, try to convert the image to png and write it to given decoded file path. - await using Stream sourceStream = await OpenStreamFromFileOrUrl(filePath, token); - await ConvertImageToPngFromStream(sourceStream, decodedFilePath, token: token); + // Return null if the codec type is not supported. + if (codecType == ImageExternalCodecType.NotSupported) + { + throw new NotSupportedException($"The format of the image: {filePath} is not supported!"); + } + + // Otherwise, try to convert the image to png and write it to given decoded file path. + await using Stream sourceStream = await OpenStreamFromFileOrUrl(filePath, token); + await ConvertImageToPngFromStream(sourceStream, decodedFilePath, token: token); - return (decodedFilePath, codecType); + return (decodedFilePath, codecType); + } } /// /// Get native or redirected decoded image path. /// /// If the codec of the image is not supported. - internal static async Task<(Uri, ImageExternalCodecType)> GetNativeOrDecodedImagePath( - Uri filePath, CancellationToken token) + internal static async ValueTask<(Uri, ImageExternalCodecType)> GetNativeOrDecodedImagePath( + Uri filePath, CancellationToken token = default) { (string, ImageExternalCodecType) result = await GetNativeOrDecodedImagePath(filePath.ToString(), token); return (new Uri(result.Item1), result.Item2); diff --git a/CollapseLauncher/Classes/GameManagement/ImageBackground/ImageBackgroundManager.Loaders.cs b/CollapseLauncher/Classes/GameManagement/ImageBackground/ImageBackgroundManager.Loaders.cs index 3441f50acd..1c93090f12 100644 --- a/CollapseLauncher/Classes/GameManagement/ImageBackground/ImageBackgroundManager.Loaders.cs +++ b/CollapseLauncher/Classes/GameManagement/ImageBackground/ImageBackgroundManager.Loaders.cs @@ -1,34 +1,21 @@ using CollapseLauncher.Extension; using CollapseLauncher.Helper; using CollapseLauncher.Helper.Background; -using CollapseLauncher.Helper.Image; -using CollapseLauncher.Helper.Metadata; -using CollapseLauncher.Helper.StreamUtility; using CollapseLauncher.Pages; using CollapseLauncher.XAMLs.Theme.CustomControls; using Hi3Helper; using Hi3Helper.SentryHelper; using Hi3Helper.Shared.Region; -using Microsoft.UI.Xaml; -using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Dispatching; using Microsoft.UI.Xaml.Data; -using Microsoft.UI.Xaml.Media; -using Microsoft.UI.Xaml.Media.Animation; -using Microsoft.UI.Xaml.Media.Imaging; -using PhotoSauce.MagicScaler; using System; -using System.Collections.Generic; using System.Diagnostics; -using System.Globalization; -using System.IO; -using System.Runtime.InteropServices; -using System.Linq; -using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; using Windows.UI; // ReSharper disable IdentifierTypo // ReSharper disable CheckNamespace +// ReSharper disable CommentTypo #nullable enable namespace CollapseLauncher.GameManagement.ImageBackground; @@ -39,626 +26,254 @@ public partial class ImageBackgroundManager private CancellationTokenSource? _imageLoadingTokenSource; + private static readonly MinMaxToPercentConverter MinMaxToPercentConverter = new(0d, 100d); + + private string? _lastBackgroundSource; + #endregion - private void LoadImageAtIndex(int index, bool forceLoadToStatic, CancellationToken token, bool forceReload = false) + private void LoadImage(CancellationToken token = default) { - if (ImageContextSources.Count <= index || - index < 0) + if (ImageContextSources.Count == 0 || + CurrentSelectedBackgroundIndex < 0 || + PresenterGrid == null) { return; } - if (IsBackgroundLoading) - { - CancelCurrentBackgroundLoad(); - } + new Thread(Impl) { IsBackground = true }.Start(); + return; - // Only show loading indicator when a download is required. - if (!CheckIsContextCached(ImageContextSources[index])) - { - IsBackgroundLoading = true; - } - new Thread(async void () => + async void Impl() { + Stopwatch? stopwatch = null; try { - await LoadImageAtIndexCore(index, forceLoadToStatic, token, forceReload).ConfigureAwait(false); - } - catch (Exception e) - { - Logger.LogWriteLine($"{e}", LogType.Error, true); - } - }) - { - IsBackground = true, - Priority = ThreadPriority.Lowest - }.UnsafeStart(); - } + // -- Get context and invalidate previous CTS + LayeredImageBackgroundContext? context = CurrentSelectedBackgroundContext; + if (context == null) + return; - private void CancelCurrentBackgroundLoad() - { - if (Interlocked.Exchange(ref _imageLoadingTokenSource, null) is { } lastCts) - { - lastCts.Cancel(); - lastCts.Dispose(); - } - IsBackgroundLoading = false; - } + // -- Invalidate last Cancellation Token (and thus, cancel all asynchronous routines) + // and then renew the token. Also toggle the background loading indicator. + DispatcherQueueExtensions.CurrentDispatcherQueue.TryEnqueue(() => IsBackgroundLoading = true ); + token = InvalidateAndRenewCancelToken(token); - public void PreloadBackground(PresetConfig? preset, Grid? presenterGrid) - { - if (preset == null || presenterGrid == null) return; + stopwatch = Stopwatch.StartNew(); - CancelCurrentBackgroundLoad(); - Interlocked.Increment(ref _loadGeneration); + // -- Notify changes on context menu properties + DispatcherQueueExtensions.CurrentDispatcherQueue.TryEnqueue(() => OnPropertyChanged(nameof(CurrentSelectedBackgroundHasOverlayImage))); - PresenterGrid = presenterGrid; - string autoPlayKey = $"LastIsEnableBackgroundAutoPlay-{preset.GameName}-{preset.ZoneName}"; - CurrentIsEnableBackgroundAutoPlayKey = autoPlayKey; + // -- Start clean-up if force reload requested + if (context.ForceReload) + { + context.DeleteCachedImagePath(context.OriginOverlayImagePath); + context.DeleteCachedImagePath(context.OriginBackgroundImagePath); + context.DeleteCachedImagePath(context.OriginPlaceholderImagePath); + } - string cachedBgKey = $"CachedBg-{preset.GameName}-{preset.ZoneName}"; - string? backgroundUrl = LauncherConfig.GetAppConfigValue($"{cachedBgKey}-Background").ToString(); - string? staticBgUrl = LauncherConfig.GetAppConfigValue($"{cachedBgKey}-StaticBackground").ToString(); - string? overlayUrl = LauncherConfig.GetAppConfigValue($"{cachedBgKey}-Overlay").ToString(); + // -- Get Cached path. + string? downloadedOverlayUri = null; + string? downloadedBackgroundUri = null; + string? downloadedPlaceholderUri = null; - if (string.IsNullOrEmpty(backgroundUrl) && string.IsNullOrEmpty(staticBgUrl)) - { - IsBackgroundLoading = true; - return; - } + Task overlayLoadTask = context.GetCachedImagePathAsync(context.OriginOverlayImagePath, token).GetResultFromAction(result => downloadedOverlayUri = result); + Task backgroundLoadTask = context.GetCachedImagePathAsync(context.OriginBackgroundImagePath, token).GetResultFromAction(result => downloadedBackgroundUri = result); + Task placeholderLoadTask = context.GetCachedImagePathAsync(context.OriginPlaceholderImagePath, token).GetResultFromAction(result => downloadedPlaceholderUri = result); - string? primaryBg = backgroundUrl ?? staticBgUrl; + await Task.WhenAll(overlayLoadTask, + backgroundLoadTask, + placeholderLoadTask); - LayeredImageBackgroundContext context = new() - { - OriginOverlayImagePath = overlayUrl, - OriginBackgroundImagePath = backgroundUrl, - OriginBackgroundStaticImagePath = staticBgUrl, - OverlayImagePath = overlayUrl, - BackgroundImagePath = primaryBg, - BackgroundImageStaticPath = staticBgUrl, - IsVideo = IsVideoMediaFileExtensionSupported(primaryBg ?? staticBgUrl!), - IsCustom = false - }; - - if (CheckIsContextCached(context)) - { - string? localOverlay = TryGetCachedLocalPath(overlayUrl); - string? localBackground = TryGetCachedLocalPath(primaryBg); - string? localStatic = TryGetCachedLocalPath(staticBgUrl); + token.ThrowIfCancellationRequested(); - bool overlayRequired = !string.IsNullOrEmpty(overlayUrl); - bool staticRequired = !string.IsNullOrEmpty(staticBgUrl); + if (!string.IsNullOrEmpty(CurrentCachedBgKey)) + { + LauncherConfig.SetAndSaveConfigValue($"{CurrentCachedBgKey}-Background", context.OriginBackgroundImagePath ?? ""); + LauncherConfig.SetAndSaveConfigValue($"{CurrentCachedBgKey}-StaticBackground", context.OriginPlaceholderImagePath ?? ""); + LauncherConfig.SetAndSaveConfigValue($"{CurrentCachedBgKey}-Overlay", context.OriginOverlayImagePath ?? ""); + } - if (localBackground != null && - (!overlayRequired || localOverlay != null) && - (!staticRequired || localStatic != null)) - { - bool willDisplayStatic = (!IsVideoMediaFileExtensionSupported(primaryBg ?? staticBgUrl!) || - (!CurrentIsEnableCustomImage && - !GlobalIsEnableCustomImage && - !CurrentIsEnableBackgroundAutoPlay)) - && localStatic != null; - Uri accentPreviewUri = willDisplayStatic ? new Uri(localStatic!) : new Uri(localBackground); - - _ = RestoreSavedAccent(cachedBgKey, accentPreviewUri); - - ImageContextSources.Clear(); - ImageContextSources.Add(context); - OnPropertyChanged(nameof(CurrentSelectedBackgroundContext)); - OnPropertyChanged(nameof(CurrentBackgroundCount)); - - CurrentBackgroundElement = CreateLayerElement(localOverlay != null ? new Uri(localOverlay) : null, - new Uri(localBackground), - localStatic != null ? new Uri(localStatic) : null, - context, - IsVideoMediaFileExtensionSupported(primaryBg ?? staticBgUrl!)); - - bool isUseFFmpeg = GlobalIsUseFFmpeg && GlobalIsFFmpegAvailable; - new Thread(async void (ctx) => + // -- Use UI thread and load image layer + DispatcherQueueExtensions + .CurrentDispatcherQueue + .TryEnqueue(DispatcherQueuePriority.High, + AssignSource); + + return; + + async void AssignSource() { try { - await GetMediaAccentColor(ctx).ConfigureAwait(false); + string? backgroundToUse = (CurrentIsEnableBackgroundAutoPlay ? downloadedBackgroundUri : downloadedPlaceholderUri) ?? downloadedBackgroundUri; + + // -- Avoid spawning element while window is currently not visible + // (for example: Minimized at start). + if (!WindowUtility.CurrentWindowIsVisible) + { + return; + } + + // -- Avoid the same background to get loaded (unless Force reload is requested). + if (_lastBackgroundSource == backgroundToUse && !context.ForceReload) + { + return; + } + + // -- Update last background source. + _lastBackgroundSource = backgroundToUse; + + // -- Reset force reload request. + bool forceReloadRequested = context.ForceReload; + context.ForceReload = false; + + // -- Check media support and whether to force FFmpeg in some cases. + (bool isSupported, bool forceFFmpeg) = await CheckCodecOrSpawnDialog(backgroundToUse); + if (!isSupported) + { + return; + } + + // -- Notify whether the background is a video or not. + CurrentBackgroundIsVideo = context.IsVideo; + + // -- Create or Get element and set background. + LayeredBackgroundImage element = CreateOrGetLayerElement(); + bool useFFmpeg = forceFFmpeg || (context.IsVideo && GlobalIsUseFFmpeg && GlobalIsFFmpegAvailable); + + // -- If Force Reload previously requested, rapidly nullify the background source. + if (forceReloadRequested) + element.BackgroundSource = null; + + element.UseFfmpegDecoder = useFFmpeg; + element.BackgroundSource = backgroundToUse; + element.ForegroundSource = downloadedOverlayUri; + + // -- Load color accent + if (!string.IsNullOrEmpty(backgroundToUse)) + LoadMediaAccentColor(new Uri(backgroundToUse), useFFmpeg); } catch (Exception e) { - Logger.LogWriteLine($"{e}", LogType.Error, true); + Console.WriteLine(e); } - }) - { - IsBackground = true - }.UnsafeStart((accentPreviewUri, isUseFFmpeg, cachedBgKey)); - return; - } - } - - // Show preset placeholder. - string placeholderPath = GetPlaceholderBackgroundImageFrom(null); - PresenterGrid.Background = new ImageBrush - { - ImageSource = new BitmapImage(new Uri(placeholderPath)), - Stretch = Stretch.UniformToFill - }; - CurrentBackgroundElement = null; - _displayedContext = null; - IsBackgroundLoading = true; - } - - private async Task LoadImageAtIndexCore(int index, bool forceLoadToStatic, CancellationToken token, bool forceReload = false) - { - Stopwatch? stopwatch = null; - try - { - bool isUseFFmpeg = GlobalIsUseFFmpeg && GlobalIsFFmpegAvailable; - stopwatch = Stopwatch.StartNew(); - - // -- Notify changes on context menu properties - DispatcherQueueExtensions - .CurrentDispatcherQueue - .TryEnqueue(() => OnPropertyChanged(nameof(CurrentSelectedBackgroundHasOverlayImage))); - - // -- Get context and invalidate previous CTS - LayeredImageBackgroundContext context = ImageContextSources[index]; - if (Interlocked.Exchange(ref _imageLoadingTokenSource, - CancellationTokenSource.CreateLinkedTokenSource(token)) is { } lastCts) - { - await lastCts.CancelAsync(); - lastCts.Dispose(); - } - - token = _imageLoadingTokenSource.Token; - - // -- Download overlay. - Unsafe.SkipInit(out Uri? downloadedOverlayUri); - if (Uri.TryCreate(context.OverlayImagePath, UriKind.Absolute, out Uri? overlayImageUri)) - { - 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).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).ConfigureAwait(false); - (downloadedBackgroundStaticUri, _) = await GetNativeOrDecodedImagePath(downloadedBackgroundStaticUri, token).ConfigureAwait(false); - } - - // Try to use static bg URL if normal bg is not available. - downloadedBackgroundUri ??= downloadedBackgroundStaticUri; - if (downloadedBackgroundUri == null) - { - if (!context.IsCustom) - { - NeedFallbackPlaceholder = false; - OnBackgroundLoadFailed?.Invoke(); } - return; } - - // -- Get upscaled image file if Waifu2X is enabled - if (GlobalIsWaifu2XEnabled) + catch (Exception ex) when (ex is not OperationCanceledException) { - downloadedOverlayUri = await TryGetScaledWaifu2XImagePath(downloadedOverlayUri, token).ConfigureAwait(false); - downloadedBackgroundUri = await TryGetScaledWaifu2XImagePath(downloadedBackgroundUri, token).ConfigureAwait(false); - downloadedBackgroundStaticUri = await TryGetScaledWaifu2XImagePath(downloadedBackgroundStaticUri, token).ConfigureAwait(false); + _ = SentryHelper.ExceptionHandlerAsync(ex); + Logger.LogWriteLine($"[ImageBackgroundManager::LoadImage] {ex}", + LogType.Error, + true); } - - token.ThrowIfCancellationRequested(); - - // -- Check for codec support (Also spawn dialog to install either native WIC/MediaFoundation decoder or using Ffmpeg decoder) - (bool isSupported, bool isVideo) = await CheckCodecOrSpawnDialog(downloadedBackgroundUri); - if (!isSupported) + catch (OperationCanceledException) { - return; + // Suppress: cancellation is expected on game switch or rapid load cycles. } - - // Try to force loading static image if requested. - if (forceLoadToStatic && downloadedBackgroundStaticUri != null) + finally { - isVideo = false; - downloadedBackgroundUri = downloadedBackgroundStaticUri; - downloadedBackgroundStaticUri = null; - } - - // Read accent color from the source that is actually displayed. - bool willDisplayStatic = !isVideo || - (!CurrentIsEnableCustomImage && - !GlobalIsEnableCustomImage && - !CurrentIsEnableBackgroundAutoPlay); - Uri? accentSourceUri = willDisplayStatic - ? downloadedBackgroundStaticUri ?? downloadedBackgroundUri - : downloadedBackgroundUri; + stopwatch?.Stop(); + Logger.LogWriteLine($"Background image loading took: {stopwatch?.Elapsed.TotalSeconds} second(s)"); - new Thread(async void (ctx) => - { - try - { - await GetMediaAccentColor(ctx).ConfigureAwait(false); - } - catch (Exception e) - { - Logger.LogWriteLine($"{e}", LogType.Error, true); - } - }) - { - IsBackground = true - }.UnsafeStart((accentSourceUri, isUseFFmpeg, CurrentCachedBgKey)); - - if (!context.IsCustom && - !string.IsNullOrEmpty(CurrentCachedBgKey)) - { - LauncherConfig.SetAndSaveConfigValue($"{CurrentCachedBgKey}-Background", context.OriginBackgroundImagePath ?? ""); - LauncherConfig.SetAndSaveConfigValue($"{CurrentCachedBgKey}-StaticBackground", context.OriginBackgroundStaticImagePath ?? ""); - LauncherConfig.SetAndSaveConfigValue($"{CurrentCachedBgKey}-Overlay", context.OriginOverlayImagePath ?? ""); - } - - // -- Use UI thread and load image layer - int captureGen = _loadGeneration; - bool captureForceReload = forceReload; - DispatcherQueueExtensions - .CurrentDispatcherQueue - .TryEnqueue(() => SpawnImageLayer(downloadedOverlayUri, - downloadedBackgroundUri, - downloadedBackgroundStaticUri, - isVideo, - context, - captureGen, - forceReload: captureForceReload)); - - GlobalIsFFmpegCurrentlyUsed = isVideo && isUseFFmpeg; - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - _ = SentryHelper.ExceptionHandlerAsync(ex); - Logger.LogWriteLine($"[ImageBackgroundManager::LoadImageAtIndex] {ex}", - LogType.Error, - true); - } - catch (OperationCanceledException) - { - // Suppress: cancellation is expected on game switch or rapid load cycles. - } - finally - { - stopwatch?.Stop(); - Logger.LogWriteLine($"Background image loading took: {stopwatch?.Elapsed.TotalSeconds} second(s)"); - if (!token.IsCancellationRequested) - { - IsBackgroundLoading = false; + DispatcherQueueExtensions.CurrentDispatcherQueue.TryEnqueue(() => IsBackgroundLoading = false); } } } - private void SpawnImageLayer(Uri? overlayFilePath, - Uri? backgroundFilePath, - Uri? backgroundStaticFilePath, - bool isVideo, - LayeredImageBackgroundContext context, - int loadGeneration, - bool forceReload = false) + private CancellationToken InvalidateAndRenewCancelToken(CancellationToken coopToken = default) { - if (loadGeneration != _loadGeneration) - { - return; - } + CancellationTokenSource newCts; + CancellationTokenSource? lastCts = + Interlocked.Exchange(ref _imageLoadingTokenSource, + newCts = CancellationTokenSource.CreateLinkedTokenSource(coopToken)); - if (!forceReload && - CurrentBackgroundElement != null && - _displayedContext != null) - { - if (context.Equals(_displayedContext)) - { - return; - } + lastCts?.Cancel(); + lastCts?.Dispose(); - if (CurrentBackgroundElement is LayeredBackgroundImage existingLayer && - IsSameLocalFile(existingLayer.BackgroundSource, backgroundFilePath) && - IsSameLocalFile(existingLayer.BackgroundStaticSource, backgroundStaticFilePath)) - { - return; - } - } - - CurrentBackgroundElement = CreateLayerElement(overlayFilePath, backgroundFilePath, backgroundStaticFilePath, context, isVideo); - } - - private static bool IsSameLocalFile(object? currentSource, Uri? newFilePath) - { - if (newFilePath == null) return currentSource == null; - string? newPath = newFilePath.IsFile ? newFilePath.LocalPath : newFilePath.OriginalString; - string? currentPath = currentSource switch - { - Uri uri => uri.IsFile ? uri.LocalPath : uri.OriginalString, - string str => str, - _ => null - }; - return string.Equals(currentPath, newPath, StringComparison.OrdinalIgnoreCase); + return newCts.Token; } - private async Task RestoreSavedAccent(string cachedBgKey, Uri? fallbackSourceUri = null) + private LayeredBackgroundImage CreateOrGetLayerElement() { - string? savedHex = LauncherConfig.GetAppConfigValue($"{cachedBgKey}-AccentColor").ToString(); - if (!string.IsNullOrEmpty(savedHex) && savedHex!.Length >= 6 && ThemeRootElement != null) + if (CurrentBackgroundElement == null) { - if (TryParseHexColor(savedHex, out Color accentColor)) + CurrentBackgroundElement = new LayeredBackgroundImage { - ThemeRootElement.ChangeAccentColor(accentColor); - return; - } + ParallaxResetOnUnfocused = false, + BackgroundElevationPixels = 64d + }; + + CurrentBackgroundElement.BindProperty(LayeredBackgroundImage.FfmpegDecoderModeProperty, + this, + nameof(GlobalFFmpegDecodingMode), + bindingMode: BindingMode.OneWay); + + CurrentBackgroundElement.BindProperty(LayeredBackgroundImage.ParallaxHoverSourceProperty, + this, + nameof(GlobalParallaxHoverSource), + bindingMode: BindingMode.OneWay); + + CurrentBackgroundElement.BindProperty(LayeredBackgroundImage.IsParallaxEnabledProperty, + this, + nameof(GlobalIsBackgroundParallaxEffectEnabled), + bindingMode: BindingMode.OneWay); + + CurrentBackgroundElement.BindProperty(LayeredBackgroundImage.ParallaxHorizontalShiftProperty, + this, + nameof(GlobalBackgroundParallaxPixelShift), + bindingMode: BindingMode.OneWay); + + CurrentBackgroundElement.BindProperty(LayeredBackgroundImage.ParallaxVerticalShiftProperty, + this, + nameof(GlobalBackgroundParallaxPixelShift), + bindingMode: BindingMode.OneWay); + + CurrentBackgroundElement.BindProperty(LayeredBackgroundImage.IsAudioEnabledProperty, + this, + nameof(GlobalBackgroundAudioEnabled), + bindingMode: BindingMode.OneWay); + + CurrentBackgroundElement.BindProperty(LayeredBackgroundImage.AudioVolumeProperty, + this, + nameof(GlobalBackgroundAudioVolume), + bindingMode: BindingMode.OneWay, + converter: MinMaxToPercentConverter); + + CurrentBackgroundElement.BindProperty(LayeredBackgroundImage.IsBackgroundElevatedProperty, + this, + nameof(IsBackgroundElevated), + bindingMode: BindingMode.OneWay); + + CurrentBackgroundElement.BindProperty(LayeredBackgroundImage.ForegroundOpacityProperty, + this, + nameof(ForegroundOpacity), + bindingMode: BindingMode.OneWay); + + CurrentBackgroundElement.BindProperty(LayeredBackgroundImage.SmokeOpacityProperty, + this, + nameof(SmokeOpacity), + bindingMode: BindingMode.OneWay); + + PresenterGrid?.Children.Clear(); + PresenterGrid?.Children.Add(CurrentBackgroundElement); } - if (fallbackSourceUri != null && ThemeRootElement != null) - { - try - { - Color syncColor = await ColorPaletteUtility.GetMediaAccentColorFromAsync(fallbackSourceUri, false); - ThemeRootElement.ChangeAccentColor(syncColor); - LauncherConfig.SetAndSaveConfigValue($"{cachedBgKey}-AccentColor", - $"{syncColor.R:X2}{syncColor.G:X2}{syncColor.B:X2}"); - } - catch (Exception ex) - { - Logger.LogWriteLine($"RestoreSavedAccent failed: {ex}", LogType.Error, true); - } - } - } - - private static bool TryParseHexColor(string hex, out Color color) - { - color = default; - if (hex.Length < 6) return false; - if (hex[0] == '#') - hex = hex[1..]; - if (!uint.TryParse(hex, NumberStyles.HexNumber, null, out uint argb)) return false; - if (hex.Length <= 6) - argb = 0xFF_000000 | argb; - color = Color.FromArgb((byte)(argb >> 24), (byte)(argb >> 16), (byte)(argb >> 8), (byte)argb); - return true; + return CurrentBackgroundElement; } - private LayeredBackgroundImage CreateLayerElement(Uri? overlayFilePath, - Uri? backgroundFilePath, - Uri? backgroundStaticFilePath, - LayeredImageBackgroundContext context, - bool isVideo) - { - LayeredBackgroundImage layerElement = new() - { - BackgroundSource = backgroundFilePath, - BackgroundStaticSource = backgroundStaticFilePath, - ForegroundSource = overlayFilePath, - UseFfmpegDecoder = GlobalIsUseFFmpeg && GlobalIsFFmpegAvailable, - Tag = context, - ParallaxResetOnUnfocused = false, - BackgroundElevationPixels = 64d - }; - - if (!CurrentIsEnableCustomImage && - !GlobalIsEnableCustomImage) - { - layerElement.BindProperty(LayeredBackgroundImage.IsVideoAutoplayProperty, - this, - nameof(CurrentIsEnableBackgroundAutoPlay), - bindingMode: BindingMode.OneWay, - converter: StaticConverter.Shared); - } - else - { - layerElement.IsVideoAutoplay = WindowUtility.CurrentWindowIsVisible; - } - - layerElement.BindProperty(LayeredBackgroundImage.FfmpegDecoderModeProperty, - this, - nameof(GlobalFFmpegDecodingMode), - bindingMode: BindingMode.OneWay); - - layerElement.BindProperty(LayeredBackgroundImage.ParallaxHoverSourceProperty, - this, - nameof(GlobalParallaxHoverSource), - bindingMode: BindingMode.OneWay); - - layerElement.BindProperty(LayeredBackgroundImage.IsParallaxEnabledProperty, - this, - nameof(GlobalIsBackgroundParallaxEffectEnabled), - bindingMode: BindingMode.OneWay); - - layerElement.BindProperty(LayeredBackgroundImage.ParallaxHorizontalShiftProperty, - this, - nameof(GlobalBackgroundParallaxPixelShift), - bindingMode: BindingMode.OneWay); - - layerElement.BindProperty(LayeredBackgroundImage.ParallaxVerticalShiftProperty, - this, - nameof(GlobalBackgroundParallaxPixelShift), - bindingMode: BindingMode.OneWay); - - layerElement.BindProperty(LayeredBackgroundImage.IsAudioEnabledProperty, - this, - nameof(GlobalBackgroundAudioEnabled), - bindingMode: BindingMode.OneWay); - - layerElement.BindProperty(LayeredBackgroundImage.AudioVolumeProperty, - this, - nameof(GlobalBackgroundAudioVolume), - bindingMode: BindingMode.OneWay); - - layerElement.BindProperty(LayeredBackgroundImage.IsBackgroundElevatedProperty, - this, - nameof(IsBackgroundElevated), - bindingMode: BindingMode.OneWay); - - layerElement.BindProperty(LayeredBackgroundImage.ForegroundOpacityProperty, - this, - nameof(ForegroundOpacity), - bindingMode: BindingMode.OneWay); - - layerElement.BindProperty(LayeredBackgroundImage.SmokeOpacityProperty, - this, - nameof(SmokeOpacity), - bindingMode: BindingMode.OneWay); - - layerElement.BindProperty(LayeredBackgroundImage.UseImageCacheProperty, - context, - nameof(context.ForceReload), - bindingMode: BindingMode.OneWay, - converter: StaticConverter.Shared); - - layerElement.ImageLoaded += LayerElementOnLoaded; - PresenterGrid?.Children.Add(layerElement); - - layerElement.Tag = isVideo; - _displayedContext = context; - return layerElement; - } - - private void LayerElementOnLoaded(LayeredBackgroundImage layerElement) - { - layerElement.ImageLoaded -= LayerElementOnLoaded; - layerElement.Transitions.Add(new PopupThemeTransition()); - - if (PresenterGrid != null) - { - List toRemove = PresenterGrid.Children.Where(element => element != layerElement).ToList(); - foreach (UIElement oldElement in toRemove) - { - if (oldElement is LayeredBackgroundImage oldLayer) - { - oldLayer.ImageLoaded -= LayerElementOnLoaded; - - if (oldLayer.Tag == null) - { - oldLayer.Opacity = 0; - } - else - { - PresenterGrid.Children.Remove(oldElement); - } - } - else - { - PresenterGrid.Children.Remove(oldElement); - } - } - - PresenterGrid.Background = null; - } - - if (CurrentIsEnableBackgroundAutoPlay && WindowUtility.CurrentWindowIsVisible) - { - Play(false); - } - - CurrentBackgroundIsVideo = CurrentSelectedBackgroundContext?.IsVideo ?? false; - if (layerElement.Tag is bool isDisplayControl) - { - CurrentBackgroundIsSeekable = isDisplayControl; - } - } - - private static bool TryGetUpscaledFilePath(string inputFilePath, - out string upscaledFilePath) - { - upscaledFilePath = Path.Combine(LauncherConfig.AppGameImgCachedFolder, $"scaled_{Path.GetFileNameWithoutExtension(inputFilePath)}.png"); - if (LayeredBackgroundImage.SupportedVideoExtensionsLookup.Contains(Path.GetExtension(inputFilePath))) // Ignore if file is a video. - { - upscaledFilePath = inputFilePath; - return true; - } - - FileInfo outputFileInfo = new(upscaledFilePath); - return outputFileInfo is { Exists: true, Length: > 0 }; - } - - private static async Task TryGetScaledWaifu2XImagePath(Uri? uri, CancellationToken token) - { - if (uri == null) - { - return null; - } - - if (!uri.IsFile) - { - throw new InvalidOperationException("You must provide a local file path!"); - } - - // Ignore if the input file is a video - string inputFilePath = uri.LocalPath; - if (TryGetUpscaledFilePath(inputFilePath, out string outputFilePath)) - { - return new Uri(outputFilePath); - } - - FileInfo outputFileInfo = new(outputFilePath); - outputFileInfo.Directory?.Create(); - - await Task.Run(Impl, token); - outputFileInfo.Refresh(); - - try - { - token.ThrowIfCancellationRequested(); - return new Uri(outputFileInfo.FullName); - } - catch - { - outputFileInfo.TryDeleteFile(); - throw; - } - - void Impl() - { - using ProcessingPipeline pipeline = - MagicImageProcessor - .BuildPipeline(inputFilePath, - ProcessImageSettings.Default); - - using FileStream outputFileStream = outputFileInfo.Open(FileMode.Create, - FileAccess.Write, - FileShare.ReadWrite); - - pipeline.AddTransform(new Waifu2XTransform(ImageLoaderHelper.Waifu2X)); - pipeline.WriteOutput(outputFileStream); - } - } - - private async Task GetMediaAccentColor(object? context) + private async void LoadMediaAccentColor(Uri mediaUri, bool useFFmpeg) { try { - if (context is not (Uri asUri, bool useFfmpegForVideo, string configKey)) - { - return; - } - - Color color = await ColorPaletteUtility.GetMediaAccentColorFromAsync(asUri, useFfmpegForVideo) + Color color = await ColorPaletteUtility.GetMediaAccentColorFromAsync(mediaUri, useFFmpeg) .ConfigureAwait(false); - if (color == default(Color)) return; - - string hex = $"{color.R:X2}{color.G:X2}{color.B:X2}"; - if (!string.IsNullOrEmpty(configKey)) - { - string? savedHex = LauncherConfig.GetAppConfigValue($"{configKey}-AccentColor").ToString(); - if (savedHex == hex) return; - LauncherConfig.SetAndSaveConfigValue($"{configKey}-AccentColor", hex); - } - ColorAccentChanged?.Invoke(color); } catch (Exception ex) { - Logger.LogWriteLine($"GetMediaAccentColor failed: {ex}", LogType.Error, true); + Logger.LogWriteLine($"LoadMediaAccentColor failed: {ex}", LogType.Error, true); SentryHelper.ExceptionHandler(ex); } } diff --git a/CollapseLauncher/Classes/GameManagement/ImageBackground/ImageBackgroundManager.StreamAndPath.cs b/CollapseLauncher/Classes/GameManagement/ImageBackground/ImageBackgroundManager.StreamAndPath.cs index ec50a11353..1af6185487 100644 --- a/CollapseLauncher/Classes/GameManagement/ImageBackground/ImageBackgroundManager.StreamAndPath.cs +++ b/CollapseLauncher/Classes/GameManagement/ImageBackground/ImageBackgroundManager.StreamAndPath.cs @@ -1,5 +1,6 @@ using CollapseLauncher.Helper.Metadata; using CollapseLauncher.Helper.StreamUtility; +using Hi3Helper.Data; using Hi3Helper.EncTool; using Hi3Helper.Shared.Region; using System; @@ -21,38 +22,47 @@ public partial class ImageBackgroundManager #region Fields private static readonly string[] PlaceholderBackgroundImageRelPath = [ - @"Assets\Images\PageBackground\default_genshin.webp", - @"Assets\Images\PageBackground\default_honkai.webp", - @"Assets\Images\PageBackground\default_starrail.webp", - @"Assets\Images\PageBackground\default_zzz.webp", - ]; - - private static readonly string[] PlaceholderBackgroundImagePngRelPath = [ - @"Assets\Images\GamePoster\poster_genshin.png", - @"Assets\Images\GamePoster\poster_honkai.png", - @"Assets\Images\GamePoster\poster_starrail.png", - @"Assets\Images\GamePoster\poster_zzz.png", + @"Assets\Images\PageBackground\default_genshin.avif", + @"Assets\Images\PageBackground\default_honkai.avif", + @"Assets\Images\PageBackground\default_starrail.avif", + @"Assets\Images\PageBackground\default_zzz.avif" ]; #endregion - private static Task OpenStreamFromFileOrUrl(string? filePath, CancellationToken token) + public static ValueTask OpenStreamFromFileOrUrl(string? filePath, CancellationToken token) { - return !Uri.TryCreate(filePath, UriKind.Absolute, out Uri? uri) + return !Uri.TryCreate(filePath, UriKind.RelativeOrAbsolute, out Uri? uri) ? throw new InvalidOperationException($"File path or URL is misformed! {filePath}") : OpenStreamFromFileOrUrl(uri, token); } - private static async Task OpenStreamFromFileOrUrl(Uri uri, CancellationToken token) + public static async ValueTask OpenStreamFromFileOrUrl(Uri uri, CancellationToken token) { + if (!uri.IsAbsoluteUri && !uri.IsWellFormedOriginalString()) + { + string localPath = uri.OriginalString.GetFullyQualifiedPath(); + return !File.Exists(localPath) + ? throw new FileNotFoundException($"File: {localPath} does not exist!") + : File.Open(localPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + } + if (uri.IsFile) { - string localPath = uri.LocalPath; + string localPath = uri.LocalPath.GetFullyQualifiedPath(); return !File.Exists(localPath) ? throw new FileNotFoundException($"File: {localPath} does not exist!") : File.Open(localPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); } + if (uri.Scheme.StartsWith("ms-appx")) + { + string uwpResPath = uri.LocalPath.TrimStart("\\/").GetFullyQualifiedPath(); + return !File.Exists(uwpResPath) + ? throw new FileNotFoundException($"File: {uwpResPath} does not exist!") + : File.Open(uwpResPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + } + // Find stamp file. Try read the metadata in it and just use locally cached files if matches. if (TryGetDownloadedFile(uri, out FileInfo downloadedFilePath, @@ -63,14 +73,9 @@ private static async Task OpenStreamFromFileOrUrl(Uri uri, Cancellat } HttpClient sharedClient = FallbackCDNUtil.GetGlobalHttpClient(true); - status = await sharedClient.GetCachedUrlStatus(uri, token); + status = await sharedClient.GetCachedUrlStatus(uri, token).ConfigureAwait(false); status.EnsureSuccessStatusCode(); - if (status.FileSize == 0) - { - throw new HttpRequestException($"File URL is reachable but it returns 0 bytes. {uri}"); - } - downloadedFilePath = downloadedFilePath .EnsureCreationOfDirectory() .EnsureNoReadOnly(); @@ -82,9 +87,14 @@ private static async Task OpenStreamFromFileOrUrl(Uri uri, Cancellat downloadedFilePath.Open(FileMode.Create, FileAccess.ReadWrite, FileShare.ReadWrite); using HttpResponseMessage responseMessage = - await sharedClient.GetAsync(uri, HttpCompletionOption.ResponseHeadersRead, token); - await using Stream responseStream = await responseMessage.Content.ReadAsStreamAsync(token); - await responseStream.CopyToAsync(downloadedFileStream, token); + await sharedClient.GetAsync(uri, HttpCompletionOption.ResponseHeadersRead, token) + .ConfigureAwait(false); + await using Stream responseStream = + await responseMessage.Content + .ReadAsStreamAsync(token) + .ConfigureAwait(false); + await responseStream.CopyToAsync(downloadedFileStream, token) + .ConfigureAwait(false); // Write stamp for future cache metadata ReadOnlySpan stampData = AsSpan(in status); @@ -97,13 +107,13 @@ static unsafe ReadOnlySpan AsSpan(in T data) where T : unmanaged => new(Unsafe.AsPointer(in data), sizeof(T)); } - internal static async Task GetLocalOrDownloadedFilePath(Uri uri, CancellationToken token) + internal static async ValueTask GetLocalOrDownloadedFilePath(Uri uri, CancellationToken token) { await using FileStream stream = await OpenStreamFromFileOrUrl(uri, token); return new Uri(stream.Name); } - internal static async Task GetLocalOrDownloadedFilePath(string path, CancellationToken token) + internal static async ValueTask GetLocalOrDownloadedFilePath(string path, CancellationToken token) { Uri uri = await GetLocalOrDownloadedFilePath(new Uri(path), token); return uri.IsFile @@ -111,7 +121,7 @@ internal static async Task GetLocalOrDownloadedFilePath(string path, Can : uri.ToString(); } - private static bool TryGetCroppedImageFilePath(string filePath, + internal static bool TryGetCroppedImageFilePath(string filePath, out string croppedFilePath) { string imageDirPath = LauncherConfig.AppGameImgFolder; @@ -121,8 +131,8 @@ private static bool TryGetCroppedImageFilePath(string filePath, return IsFileExistAndNotEmpty(croppedFilePath); } - private static bool TryGetDecodedTemporaryFile(string filePath, - out string decodedFilePath) + internal static bool TryGetDecodedTemporaryFile(string filePath, + out string decodedFilePath) { string imageDirPath = LauncherConfig.AppGameImgFolder; string fileName = $"decoded_{Path.GetFileNameWithoutExtension(filePath)}.png"; @@ -137,10 +147,10 @@ private static bool IsFileExistAndNotEmpty(string filePath) return fileInfo is { Exists: true, Length: > 0 }; } - private static bool TryGetDownloadedFile(Uri filePath, - out FileInfo downloadedFilePath, - out FileInfo downloadStampFilePath, - out UrlStatus urlStatusStamp) + internal static bool TryGetDownloadedFile(Uri filePath, + out FileInfo downloadedFilePath, + out FileInfo downloadStampFilePath, + out UrlStatus urlStatusStamp) { string imageDirPath = LauncherConfig.AppGameImgFolder; string fileHashedName = $"{Path.GetFileName(filePath.AbsolutePath)}"; @@ -180,68 +190,24 @@ private static unsafe bool TryReadDownloadStampFile( return true; } - internal static string GetPlaceholderBackgroundImageFrom(PresetConfig? presetConfig, bool usePngVersion = false) + internal static string GetPlaceholderBackgroundImageFrom(PresetConfig? presetConfig) { - string[] source = usePngVersion - ? PlaceholderBackgroundImagePngRelPath - : PlaceholderBackgroundImageRelPath; - - string relPath = presetConfig?.GameName ?? - LauncherConfig.GetAppConfigValue("GameCategory").Value switch + string[] source = PlaceholderBackgroundImageRelPath; + string relPath = presetConfig?.GameName switch { "Genshin Impact" => source[0], "Honkai Impact 3rd" => source[1], "Honkai: Star Rail" => source[2], "Zenless Zone Zero" => source[3], - _ => GetRandomPlaceholderImage(usePngVersion) + _ => GetRandomPlaceholderImage() }; return Path.Combine(LauncherConfig.AppExecutableDir, relPath); } - public static string GetRandomPlaceholderImage(bool usePngVersion = false) - { - string[] source = usePngVersion - ? PlaceholderBackgroundImagePngRelPath - : PlaceholderBackgroundImageRelPath; - - return source[Random.Shared.Next(0, source.Length - 1)]; - } - - internal static bool CheckIsContextCached(LayeredImageBackgroundContext ctx) - { - bool backgroundCached = false; - if (!string.IsNullOrEmpty(ctx.BackgroundImagePath) && - TryGetDownloadedFile(new Uri(ctx.BackgroundImagePath), out _, out _, out _)) - { - backgroundCached = true; - } - else if (!string.IsNullOrEmpty(ctx.BackgroundImageStaticPath) && - TryGetDownloadedFile(new Uri(ctx.BackgroundImageStaticPath), out _, out _, out _)) - { - backgroundCached = true; - } - - return backgroundCached; - } - - internal static string? TryGetCachedLocalPath(string? urlOrPath) + public static string GetRandomPlaceholderImage() { - if (string.IsNullOrEmpty(urlOrPath)) - { - return null; - } - - if (TryGetDecodedTemporaryFile(urlOrPath, out string decodedFilePath)) - { - return decodedFilePath; - } - - if (TryGetDownloadedFile(new Uri(urlOrPath), out FileInfo downloadedFile, out _, out _)) - { - return downloadedFile.FullName; - } - - return null; + string[] source = PlaceholderBackgroundImageRelPath; + return source[new Random(Environment.TickCount).Next(0, source.Length - 1)]; } } diff --git a/CollapseLauncher/Classes/GameManagement/ImageBackground/ImageBackgroundManager.cs b/CollapseLauncher/Classes/GameManagement/ImageBackground/ImageBackgroundManager.cs index 0af623e028..b229bca895 100644 --- a/CollapseLauncher/Classes/GameManagement/ImageBackground/ImageBackgroundManager.cs +++ b/CollapseLauncher/Classes/GameManagement/ImageBackground/ImageBackgroundManager.cs @@ -2,6 +2,7 @@ using CollapseLauncher.Helper.Image; using CollapseLauncher.Helper.LauncherApiLoader.HoYoPlay; using CollapseLauncher.Helper.Metadata; +using CollapseLauncher.Helper.StreamUtility; using CollapseLauncher.Interfaces; using CollapseLauncher.Interfaces.Class; using CollapseLauncher.XAMLs.Theme.CustomControls; @@ -13,12 +14,14 @@ using System; using System.Collections.Generic; using System.Collections.ObjectModel; +using System.IO; using System.Linq; using System.Runtime.InteropServices; using System.Threading; using System.Threading.Tasks; using Windows.UI; using WinRT; +// ReSharper disable NonReadonlyMemberInGetHashCode // ReSharper disable CheckNamespace #pragma warning disable IDE0130 @@ -199,8 +202,6 @@ public double SmokeOpacity private Grid? PresenterGrid { get; set; } private PresetConfig? PresetConfig { get; set; } - internal bool NeedFallbackPlaceholder { get; set; } - public Action? OnBackgroundLoadFailed { get; set; } public string? CurrentCustomBackgroundImagePath @@ -257,7 +258,7 @@ public int CurrentSelectedBackgroundIndex LauncherConfig.SetAndSaveConfigValue(CurrentSelectedBackgroundIndexKey, value); OnPropertyChanged(); - LoadImageAtIndex(value, false, CancellationToken.None, true); + LoadImage(); } } @@ -287,16 +288,6 @@ private set public int CurrentBackgroundCount => ImageContextSources.Count; - public bool CurrentBackgroundIsSeekable - { - get; - private set - { - field = value; - OnPropertyChanged(); - } - } - public bool CurrentBackgroundIsVideo { get; @@ -321,10 +312,6 @@ public ObservableCollection ImageContextSources private int _previousContextHash; - private volatile int _loadGeneration; - - private LayeredImageBackgroundContext? _displayedContext; - internal FrameworkElement? ThemeRootElement { get; set; } #endregion @@ -383,6 +370,7 @@ async void Impl() // -- Try to initialize custom image first // -- A. Check for per-region custom background if (CurrentIsEnableCustomImage && + !string.IsNullOrEmpty(CurrentCustomBackgroundImagePath) && await SetCurrentCustomBackground(CurrentCustomBackgroundImagePath, false, false, token)) { return; @@ -391,6 +379,7 @@ await SetCurrentCustomBackground(CurrentCustomBackgroundImagePath, false, false, // -- B. Check for global custom background if (!CurrentIsEnableCustomImage && GlobalIsEnableCustomImage && + !string.IsNullOrEmpty(GlobalCustomBackgroundImagePath) && await SetGlobalCustomBackground(GlobalCustomBackgroundImagePath, false, false, token)) { return; @@ -408,8 +397,6 @@ await SetGlobalCustomBackground(GlobalCustomBackgroundImagePath, false, false, t imageContexts.Add(new LayeredImageBackgroundContext { OriginBackgroundImagePath = bgPlaceholderPath, - BackgroundImagePath = bgPlaceholderPath, - IsVideo = IsVideoMediaFileExtensionSupported(bgPlaceholderPath), IsCustom = true }); return; @@ -419,20 +406,15 @@ await SetGlobalCustomBackground(GlobalCustomBackgroundImagePath, false, false, t // ReSharper disable once LoopCanBeConvertedToQuery foreach (HypLauncherBackgroundContentKindData contextEntry in contextList.SelectMany(x => x.Backgrounds)) { - string? overlayImagePath = contextEntry.BackgroundOverlay?.ImageUrl; - string? backgroundImagePath = contextEntry.BackgroundVideo?.ImageUrl ?? - contextEntry.BackgroundImage?.ImageUrl; - string? backgroundImageStaticPath = contextEntry.BackgroundImage?.ImageUrl; + string? overlayImagePath = contextEntry.BackgroundOverlay?.ImageUrl; + string? backgroundImagePath = contextEntry.BackgroundVideo?.ImageUrl ?? contextEntry.BackgroundImage?.ImageUrl; + string? placeholderImagePath = contextEntry.BackgroundImage?.ImageUrl; imageContexts.Add(new LayeredImageBackgroundContext { - OriginOverlayImagePath = overlayImagePath, - OriginBackgroundImagePath = backgroundImagePath, - OriginBackgroundStaticImagePath = backgroundImageStaticPath, - OverlayImagePath = overlayImagePath, - BackgroundImagePath = backgroundImagePath, - BackgroundImageStaticPath = backgroundImageStaticPath, - IsVideo = IsVideoMediaFileExtensionSupported(backgroundImagePath) + OriginOverlayImagePath = overlayImagePath, + OriginBackgroundImagePath = backgroundImagePath, + OriginPlaceholderImagePath = placeholderImagePath }); } } @@ -485,14 +467,13 @@ private async Task SetCustomBackgroundCore(string? imagePath, } // -- Try to perform cropped or pass through processing - (_, string? resultBackgroundPath, bool isCancelProcess) = - await GetCroppedCustomImage(null, - imagePath, - performCropRequest, - token); + GetCroppedResult cropResult = await GetCroppedCustomImage(null, + imagePath, + performCropRequest, + token); // -- Do not process custom background if cancelled. - if (isCancelProcess) + if (cropResult.IsCancelResize) { return false; } @@ -507,11 +488,8 @@ await GetCroppedCustomImage(null, // -- Apply background LayeredImageBackgroundContext context = new() { - OriginBackgroundImagePath = imagePath, - BackgroundImagePath = resultBackgroundPath, - ForceReload = true, // Request force reload (skip cache) - IsVideo = IsVideoMediaFileExtensionSupported(resultBackgroundPath), - IsCustom = true + OriginBackgroundImagePath = cropResult.ProcessedBackgroundPath, + ForceReload = cropResult.WasCropPerformed // Only do force reload if crop was performed. }; UpdateContextListCore(token, skipPreviousContextCheck, context); @@ -538,33 +516,14 @@ private void UpdateContextListCore( return; } - UpdateContextListCore(token, skipPreviousContextCheck, imageContexts.ToArray()); + UpdateContextListCore(token, skipPreviousContextCheck, [.. imageContexts]); } private void UpdateContextListCore( CancellationToken token, - bool skipPreviousContextCheck, + bool forceReload, params ReadOnlySpan imageContexts) { - UpdateContextListCore(token, skipPreviousContextCheck, false, imageContexts); - } - - private void UpdateContextListCore( - CancellationToken token, - bool skipPreviousContextCheck, - bool forceLoadToStatic, - params ReadOnlySpan imageContexts) - { - // Do not update if the previous contexts are equal - if ((!skipPreviousContextCheck && - IsContextEqual(imageContexts)) || - imageContexts.IsEmpty) - { - return; - } - - Interlocked.Increment(ref _loadGeneration); - ImageContextSources.Clear(); // Flush list ref IList? backedList = ref ObservableCollectionExtension @@ -591,32 +550,14 @@ ref ObservableCollectionExtension OnPropertyChanged(nameof(CurrentSelectedBackgroundIndex)); OnPropertyChanged(nameof(CurrentSelectedBackgroundContext)); OnPropertyChanged(nameof(CurrentBackgroundCount)); - LoadImageAtIndex(CurrentSelectedBackgroundIndex, forceLoadToStatic, token); + LoadImage(token); } #pragma warning restore CA1068 - private bool IsContextEqual(ReadOnlySpan imageContexts) - { - if (ImageContextSources.Count != imageContexts.Length) - { - return false; - } - - HashCode hashCode = default; - foreach (LayeredImageBackgroundContext context in imageContexts) - { - hashCode.Add(context); - } - - int hashCodeRes = hashCode.ToHashCode(); - return Interlocked.Exchange(ref _previousContextHash, hashCodeRes) == hashCodeRes; - } - public void ResetContexts() { Interlocked.Exchange(ref _previousContextHash, 0); ImageContextSources.Clear(); - NeedFallbackPlaceholder = false; } void INotifyAllPropertyChanged.NotifyAllChanged() @@ -638,49 +579,79 @@ public string? OriginOverlayImagePath public string? OriginBackgroundImagePath { get; - init; + init + { + IsVideo = ImageBackgroundManager.IsVideoMediaFileExtensionSupported(value); + field = value; + } } - public string? OriginBackgroundStaticImagePath + public string? OriginPlaceholderImagePath { get; init; } - public string? OverlayImagePath + public bool ForceReload { get; - init; + set; } - public string? BackgroundImagePath + public bool IsVideo { get; - init; + private init; } - public string? BackgroundImageStaticPath + public bool IsCustom { get; init; } - public bool ForceReload + public async Task GetCachedImagePathAsync(string? path, CancellationToken token) { - get; - init; - } + if (path == null) + return null; - public bool IsVideo - { - get; - init; + path = await ImageBackgroundManager.GetLocalOrDownloadedFilePath(path, token).ConfigureAwait(false); + (path, _) = await ImageBackgroundManager.GetNativeOrDecodedImagePath(path, token).ConfigureAwait(false); + + if (IsVideo || !ImageBackgroundManager.Shared.GlobalIsWaifu2XEnabled) + { + return path; + } + + Uri upscaledUri = await ImageBackgroundManager.TryGetScaledWaifu2XImagePath(new Uri(path), token).ConfigureAwait(false); + return upscaledUri.LocalPath; } - public bool IsCustom + public void DeleteCachedImagePath(string? path) { - get; - init; + if (string.IsNullOrEmpty(path)) + return; + + Uri uri = new(path); + if (ImageBackgroundManager.TryGetDownloadedFile(uri, + out FileInfo downloadedFilePath, + out _, + out _)) + { + downloadedFilePath.TryDeleteFile(); + } + + if (ImageBackgroundManager.TryGetDecodedTemporaryFile(downloadedFilePath.FullName, + out string decodedFilePath)) + { + StreamExtension.TryDeleteFile(decodedFilePath); + } + + if (ImageBackgroundManager.TryGetUpscaledFilePath(decodedFilePath, + out string upscaledFilePath)) + { + StreamExtension.TryDeleteFile(upscaledFilePath); + } } public bool Equals(LayeredImageBackgroundContext? other) => other?.GetHashCode() == GetHashCode(); @@ -699,10 +670,7 @@ public override bool Equals(object? obj) public override int GetHashCode() => HashCode.Combine(OriginOverlayImagePath, OriginBackgroundImagePath, - OriginBackgroundStaticImagePath, - OverlayImagePath, - BackgroundImagePath, - BackgroundImageStaticPath, - ForceReload, - IsVideo); + OriginPlaceholderImagePath, + IsVideo, + IsCustom); } \ No newline at end of file diff --git a/CollapseLauncher/Classes/GameManagement/WpfPackage/WpfPackageContext.Methods.cs b/CollapseLauncher/Classes/GameManagement/WpfPackage/WpfPackageContext.Methods.cs index 9d770bc49e..3743c09ae7 100644 --- a/CollapseLauncher/Classes/GameManagement/WpfPackage/WpfPackageContext.Methods.cs +++ b/CollapseLauncher/Classes/GameManagement/WpfPackage/WpfPackageContext.Methods.cs @@ -256,10 +256,7 @@ private async void SpawnUpdateFinishedNotification() string gameNameTranslated = MetadataHelper.GetTranslatedTitle(gameName); string gameRegionTranslated = MetadataHelper.GetTranslatedRegion(regionName); - string icon = await ImageLoaderHelper - .GetCachedSpritesAsync(WpfPackageIconUrl, - false, - CancellationToken.None) + string icon = await ImageLoaderHelper.GetCachedSpritesAsync(WpfPackageIconUrl, false, CancellationToken.None) ?? ""; // Create Toast Notification Content diff --git a/CollapseLauncher/Classes/Helper/Background/ColorPaletteUtility.cs b/CollapseLauncher/Classes/Helper/Background/ColorPaletteUtility.cs index a6f698043d..23d6a92975 100644 --- a/CollapseLauncher/Classes/Helper/Background/ColorPaletteUtility.cs +++ b/CollapseLauncher/Classes/Helper/Background/ColorPaletteUtility.cs @@ -62,17 +62,16 @@ private static byte[] GetSharedBufferOrResizeTo(int size) } } - public static async Task GetMediaAccentColorFromAsync( + public static ValueTask GetMediaAccentColorFromAsync( Uri uri, bool useFfmpegForVideo, CancellationToken token = default) { // -- Ignore if file doesn't exist or not a file URI string filePath = uri.LocalPath; - if (!uri.IsFile || - !File.Exists(filePath)) + if (!uri.IsFile || !File.Exists(filePath)) { - return default; + return ValueTask.FromResult(Color.FromArgb(255, 255, 255, 255)); } // -- Try to get cached palette data @@ -80,47 +79,63 @@ public static async Task GetMediaAccentColorFromAsync( out Color cachedColors, out string cachedPaletteFile)) { - return cachedColors; + return ValueTask.FromResult(cachedColors); } - // -- If not cached due to it not existing or has invalid format, generate new palette data - FileInfo cachedPaletteFileInfo = new FileInfo(cachedPaletteFile) - .EnsureCreationOfDirectory(); - cachedPaletteFileInfo.Directory?.Create(); - - // -- Get source stream - object? sourceStreamObj = await GetMediaFrameStream(filePath, token); - - return await Task.Factory.StartNew(Impl, TaskCreationOptions.DenyChildAttach); - - Color Impl() + TaskCompletionSource processTcs = new(); + new Thread(Impl) { - using IRandomAccessStream? sourceRandomStream = sourceStreamObj as IRandomAccessStream; - using Stream? sourceStream = sourceStreamObj as Stream ?? sourceRandomStream?.AsStream(); + IsBackground = true, + Name = "Collapse Launcher Media Accent Color Picker" + }.UnsafeStart(); + return new ValueTask(processTcs.Task); - if (sourceStream == null) + async void Impl() + { + try { - return default; - } + // -- If not cached due to it not existing or has invalid format, generate new palette data + FileInfo cachedPaletteFileInfo = new FileInfo(cachedPaletteFile).EnsureCreationOfDirectory(); + cachedPaletteFileInfo.Directory?.Create(); - ImageFileInfo fileInfo = ImageFileInfo.Load(sourceStream); - ImageFileInfo.FrameInfo frameInfo = fileInfo.Frames[0]; - sourceStream.Seek(0, SeekOrigin.Begin); + // -- Get source stream + object? sourceStreamObj = await GetMediaFrameStream(filePath, token).ConfigureAwait(false); - // -- Process palette - int channels = frameInfo.HasAlpha ? 4 : 3; - byte[] tempBuffer = GetSharedBufferOrResizeTo(frameInfo.Width * frameInfo.Height * channels + 54); - using MemoryStream tempStream = new(tempBuffer); - ProcessImageSettings settings = new(); - settings.TrySetEncoderFormat(ImageMimeTypes.Bmp); - MagicImageProcessor.ProcessImage(sourceStream, tempStream, settings); + using IRandomAccessStream? sourceRandomStream = sourceStreamObj as IRandomAccessStream; + await using Stream? sourceStream = sourceStreamObj as Stream ?? sourceRandomStream?.AsStream(); - Span tempDataSpan = tempBuffer.AsSpan(54, (int)tempStream.Position - 54); - Color colorArray = GetPaletteFromSpan(tempDataSpan, frameInfo.Width, frameInfo.Height, channels); - ReadOnlySpan colorSpanAsBytes = MemoryMarshal.AsBytes([colorArray]); + if (sourceStream == null) + { + processTcs.SetResult(Color.FromArgb(255, 255, 255, 255)); + return; + } - File.WriteAllBytes(cachedPaletteFile, colorSpanAsBytes); - return colorArray; + processTcs.SetResult(await Task.Run(() => + { + ImageFileInfo fileInfo = ImageFileInfo.Load(sourceStream); + ImageFileInfo.FrameInfo frameInfo = fileInfo.Frames[0]; + sourceStream.Seek(0, SeekOrigin.Begin); + + // -- Process palette + int channels = frameInfo.HasAlpha ? 4 : 3; + byte[] tempBuffer = GetSharedBufferOrResizeTo(frameInfo.Width * frameInfo.Height * channels + 54); + using MemoryStream tempStream = new(tempBuffer); + ProcessImageSettings settings = new(); + settings.TrySetEncoderFormat(ImageMimeTypes.Bmp); + MagicImageProcessor.ProcessImage(sourceStream, tempStream, settings); + + Span tempDataSpan = tempBuffer.AsSpan(54, (int)tempStream.Position - 54); + Color colorArray = GetPaletteFromSpan(tempDataSpan, frameInfo.Width, frameInfo.Height, channels); + ReadOnlySpan colorSpanAsBytes = MemoryMarshal.AsBytes([colorArray]); + + File.WriteAllBytes(cachedPaletteFile, colorSpanAsBytes); + return colorArray; + }, token)); + } + catch (Exception ex) + { + processTcs.SetException(ex); + } } } @@ -336,7 +351,7 @@ static unsafe FileStream CreateSvgTempStream(int width, int height, Span b } static unsafe ref T AsRef(Span span) - => ref Unsafe.AsRef(Unsafe.AsPointer(ref MemoryMarshal.AsRef(span))); + => ref Unsafe.As(ref MemoryMarshal.AsRef(span)); } catch (Exception ex) { diff --git a/CollapseLauncher/Classes/Helper/DaftarPustaka/DaftarPustakaSettings.cs b/CollapseLauncher/Classes/Helper/DaftarPustaka/DaftarPustakaSettings.cs new file mode 100644 index 0000000000..0244969673 --- /dev/null +++ b/CollapseLauncher/Classes/Helper/DaftarPustaka/DaftarPustakaSettings.cs @@ -0,0 +1,32 @@ +// ReSharper disable IdentifierTypo +#pragma warning disable IDE0130 + +#nullable enable +namespace CollapseLauncher.Helper.DaftarPustaka; + +public enum DaftarPustakaCompression : ushort +{ + Uncompressed, + Brotli, + Zstandard, + Deflate, + Zlib, + Lzma, + Lzma2, + BZip2 +} + +public enum DaftarPustakaEncryption : ushort +{ + Unencrypted, + Aes, + Rsa, + Xor +} + + +internal static class DaftarPustakaSettings +{ + internal const ulong CollapseHeader = 7310310183885631299u; + internal const ushort CurrentVersion = 3; +} diff --git a/CollapseLauncher/Classes/Helper/DaftarPustaka/DaftarPustakaUtil.Decompressor.cs b/CollapseLauncher/Classes/Helper/DaftarPustaka/DaftarPustakaUtil.Decompressor.cs new file mode 100644 index 0000000000..972d2fe50b --- /dev/null +++ b/CollapseLauncher/Classes/Helper/DaftarPustaka/DaftarPustakaUtil.Decompressor.cs @@ -0,0 +1,96 @@ +// ReSharper disable IdentifierTypo +using System; +using System.IO; +using System.IO.Compression; +using CollapseLauncher.Helper.StreamUtility; + +#if NET11_0_OR_GREATER +using ZstandardNativeStream = System.IO.Compression.ZstandardStream; +#else +using System.Collections.Generic; +using ZstandardNativeStream = ZstdNet.DecompressionStream; +using ZstandardNativeDecompressionParameter = ZstdNet.ZSTD_dParameter; +using ZstandardNativeDecompressionOptions = ZstdNet.DecompressionOptions; +#endif + +#if !NET11_0_OR_GREATER +using ZstandardManagedDecompressorParameter = ZstdSharp.Unsafe.ZSTD_dParameter; +using ZstandardManagedStream = ZstdSharp.DecompressionStream; +using ZstdNet; +#endif +#pragma warning disable IDE0130 + +#nullable enable +namespace CollapseLauncher.Helper.DaftarPustaka; + +internal static partial class DaftarPustakaUtil +{ + public static class Decompressor + { + public static BrotliStream CreateBrotliStream(Stream source, bool leaveOpen) => + new(source, CompressionMode.Decompress, leaveOpen); + + public static Stream CreateZStandardStream(Stream source, bool leaveOpen) + { + int dictionaryBufferLength = source.Read(); + int maxWindowLog2 = source.Read(); + byte[]? dictionaryBuffer = null; + +#if NET11_0_OR_GREATER + int quality = 0; +#endif + + // Read customized dictionary settings. + if (dictionaryBufferLength > 0) + { +#if NET11_0_OR_GREATER + quality = source.Read(); +#else + _ = source.Read(); +#endif + dictionaryBuffer = source.ReadArray(dictionaryBufferLength); + } + +#if NET11_0_OR_GREATER + ZstandardDictionary? dictionary = ZstandardDictionary.Create(dictionaryBuffer, quality); + + return dictionary != null + ? new ZstandardStream(source, CompressionMode.Decompress, dictionary, leaveOpen) + : new ZstandardStream(source, new ZstandardDecompressionOptions + { + Dictionary = dictionary, + MaxWindowLog2 = maxWindowLog2 + }, leaveOpen); +#else + if (DllUtils.IsLibraryExist(DllUtils.DllName)) + { + var parameters = new Dictionary + { + { ZstandardNativeDecompressionParameter.ZSTD_d_windowLogMax, maxWindowLog2 } + }; + return new ZstandardNativeStream(source, + new ZstandardNativeDecompressionOptions(dictionaryBuffer, parameters), + leaveOpen: leaveOpen); + } + + ZstandardManagedStream managedStream = new(source, leaveOpen: leaveOpen); + managedStream.SetParameter(ZstandardManagedDecompressorParameter.ZSTD_d_windowLogMax, maxWindowLog2); + if (dictionaryBuffer != null) managedStream.LoadDictionary(dictionaryBuffer); + + return managedStream; +#endif + } + + public static DeflateStream CreateDeflateStream(Stream source, bool leaveOpen) + => new(source, CompressionMode.Decompress, leaveOpen); + + public static ZLibStream CreateZlibStream(Stream source, bool leaveOpen) + => new(source, CompressionMode.Decompress, leaveOpen); + + public static ZLibStream CreateLzmaStream(Stream source, bool leaveOpen) + => throw new NotImplementedException(); + + public static ZLibStream CreateBZip2Stream(Stream source, bool leaveOpen) + => throw new NotImplementedException(); + } +} diff --git a/CollapseLauncher/Classes/Helper/DaftarPustaka/DaftarPustakaUtil.Decryptor.cs b/CollapseLauncher/Classes/Helper/DaftarPustaka/DaftarPustakaUtil.Decryptor.cs new file mode 100644 index 0000000000..f43b1e9776 --- /dev/null +++ b/CollapseLauncher/Classes/Helper/DaftarPustaka/DaftarPustakaUtil.Decryptor.cs @@ -0,0 +1,56 @@ +using CollapseLauncher.Helper.StreamUtility; +using System; +using System.IO; +using System.Security.Cryptography; +#pragma warning disable IDE0130 + +#nullable enable +namespace CollapseLauncher.Helper.DaftarPustaka; + +internal static partial class DaftarPustakaUtil +{ + public static class Decryptor + { + public static CryptoStream CreateAesStream( + Stream inputStream, + ServeV3FileContext context, + bool leaveOpen = false) + { + CipherMode cipherMode = inputStream.Read(); + PaddingMode paddingMode = inputStream.Read(); + byte keySize = inputStream.Read(); + byte ivSize = inputStream.Read(); + + context.GetKeyAndIvFromLength(keySize, + ivSize, + out byte[] key, + out byte[] iv); + + Aes aesInstance = Aes.Create(); + aesInstance.Mode = cipherMode; + aesInstance.Padding = paddingMode; + aesInstance.Key = key; + + if (cipherMode != CipherMode.ECB) + aesInstance.IV = iv; + + return new CryptoStream(inputStream, aesInstance.CreateDecryptor(), CryptoStreamMode.Read, leaveOpen); + } + + public static CryptoStream CreateRsaStream( + Stream inputStream, + ServeV3FileContext context, + bool leaveOpen = false) + { + throw new NotImplementedException(); + } + + public static CryptoStream CreateXorStream( + Stream inputStream, + ServeV3FileContext context, + bool leaveOpen = false) + { + throw new NotImplementedException(); + } + } +} diff --git a/CollapseLauncher/Classes/Helper/DaftarPustaka/DaftarPustakaUtil.Reader.cs b/CollapseLauncher/Classes/Helper/DaftarPustaka/DaftarPustakaUtil.Reader.cs new file mode 100644 index 0000000000..b18ec10fdd --- /dev/null +++ b/CollapseLauncher/Classes/Helper/DaftarPustaka/DaftarPustakaUtil.Reader.cs @@ -0,0 +1,60 @@ +using CollapseLauncher.Helper.StreamUtility; +using System; +using System.IO; +#pragma warning disable IDE0130 + +#nullable enable +namespace CollapseLauncher.Helper.DaftarPustaka; + +internal static partial class DaftarPustakaUtil +{ + public static class Reader + { + public static Stream CreateCryptoStream( + Stream inputStream, + ServeV3FileContext? context = null, + bool leaveOpen = false) + { + ulong signature = inputStream.Read(); + ushort version = inputStream.Read(); + + if (signature != DaftarPustakaSettings.CollapseHeader) + throw new InvalidDataException("Stream does not contain Daftar Pustaka formatted file."); + + if (version != DaftarPustakaSettings.CurrentVersion) + throw new InvalidDataException($"Daftar Pustaka version: {version} is not supported!"); + + DaftarPustakaCompression compressionType = inputStream.Read(); + DaftarPustakaEncryption encryptionType = inputStream.Read(); + + if (context == null && encryptionType != DaftarPustakaEncryption.Unencrypted) + throw new ArgumentNullException(nameof(context), "Context cannot be null while encryption is set!"); + + inputStream = encryptionType switch + { + DaftarPustakaEncryption.Aes => Decryptor.CreateAesStream(inputStream, context!, leaveOpen), + DaftarPustakaEncryption.Rsa => Decryptor.CreateRsaStream(inputStream, context!, leaveOpen), + DaftarPustakaEncryption.Xor => Decryptor.CreateXorStream(inputStream, context!, leaveOpen), + DaftarPustakaEncryption.Unencrypted => inputStream, + + _ => throw new NotSupportedException($"Encryption type: {encryptionType} is not implemented!") + }; + + inputStream = compressionType switch + { + DaftarPustakaCompression.Brotli => Decompressor.CreateBrotliStream(inputStream, leaveOpen), + DaftarPustakaCompression.Zstandard => Decompressor.CreateZStandardStream(inputStream, leaveOpen), + DaftarPustakaCompression.Deflate => Decompressor.CreateDeflateStream(inputStream, leaveOpen), + DaftarPustakaCompression.Zlib => Decompressor.CreateZlibStream(inputStream, leaveOpen), + DaftarPustakaCompression.Lzma => Decompressor.CreateLzmaStream(inputStream, leaveOpen), + DaftarPustakaCompression.Lzma2 => Decompressor.CreateLzmaStream(inputStream, leaveOpen), + DaftarPustakaCompression.BZip2 => Decompressor.CreateBZip2Stream(inputStream, leaveOpen), + DaftarPustakaCompression.Uncompressed => inputStream, + + _ => throw new NotSupportedException($"Compression type: {compressionType} is not implemented!") + }; + + return inputStream; + } + } +} diff --git a/CollapseLauncher/Classes/Helper/DaftarPustaka/ServeV3FileContext.cs b/CollapseLauncher/Classes/Helper/DaftarPustaka/ServeV3FileContext.cs new file mode 100644 index 0000000000..fcec6a95a4 --- /dev/null +++ b/CollapseLauncher/Classes/Helper/DaftarPustaka/ServeV3FileContext.cs @@ -0,0 +1,158 @@ +using Hi3Helper.EncTool; +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Hashing; +using System.Net.Http; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Text; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; +#pragma warning disable IDE0130 + +#nullable enable +namespace CollapseLauncher.Helper.DaftarPustaka; + +[JsonSerializable(typeof(Dictionary))] +[JsonSourceGenerationOptions(WriteIndented = true, + IndentCharacter = ' ', + IndentSize = 2, + AllowTrailingCommas = true)] +internal partial class ServeV3FileContextContext : JsonSerializerContext; + +public class ServeV3FileContext +{ + public byte[] OriginFileHash { get; set; } = []; + public byte[] ContentFileHash { get; set; } = []; + + public string? DataHashType { get; set; } + public string? FilenameHashType { get; set; } + + public DateTimeOffset FileTime { get; set; } + public long Salt { get; set; } + + public Stream? Stream { get; set; } + + public Dictionary? Metadata { get; set; } + + public void GetKeyAndIvFromLength(int keyLength, int ivLength, out byte[] key, out byte[] iv) + { + Unsafe.SkipInit(out key); + Unsafe.SkipInit(out iv); + + if (FileTime == default) throw new InvalidOperationException("FileTime property cannot be empty!"); + if (Salt == 0) throw new InvalidOperationException("Salt property cannot be empty!"); + + key = new byte[keyLength]; + iv = new byte[ivLength]; + + DateTimeOffset fileTime = FileTime; + long dateTimeAsLong = MemoryMarshal.Read(MemoryMarshal.AsBytes(new ReadOnlySpan(ref fileTime))); + + int saltLow = unchecked((int)Salt); + int saltHigh = unchecked((int)(Salt >> 32)); + + int dateTimeLow = unchecked((int)dateTimeAsLong); + int dateTimeHigh = unchecked((int)(dateTimeAsLong >> 32)); + + Random random = new(((saltLow | saltHigh) - (dateTimeLow | dateTimeHigh)) | (keyLength * ivLength)); + + if (key.Length != 0) + { + Array.Copy(ContentFileHash, + 0, + key, + 0, + Math.Min(ContentFileHash.Length, key.Length)); + + for (int i = 0; i < keyLength; i++) + key[i] |= (byte)random.Next(); + } + + if (iv.Length != 0) + { + Array.Copy(ContentFileHash, + 0, + iv, + 0, + Math.Min(ContentFileHash.Length, iv.Length)); + + for (int i = 0; i < ivLength; i++) + iv[i] |= (byte)random.Next(); + } + } + + public bool IsKeyStoreExist(string key) => Metadata?.ContainsKey(key) ?? false; + + public string GetOriginalFileUrl() + { + const string dictKey = "origUrl"; + if (!TryReadStringStoreAs(dictKey, out string? result)) + { + throw new KeyNotFoundException("origUrl from pustaka's store is not exist. Please report this issue to our Discord Server!"); + } + + return string.IsNullOrEmpty(result) + ? throw new NullReferenceException("origUrl from pustaka's store is null or just an empty string. Please report this issue to our Discord Server!") + : result; + } + + public bool TryReadStringStoreAs(string key, out string? result) + { + result = null; + if (!IsKeyStoreExist(key)) + { + return false; + } + + ReadOnlySpan dataSpan = Metadata?[key]; + result = Encoding.UTF8.GetString(dataSpan); + return true; + } + + public async Task GetOriginalFileHttpResponse( + HttpClient client, + HttpMethod? method = null, + CancellationToken token = default) + { + string originalUrl = GetOriginalFileUrl(); + return await client.TryGetCachedStreamFrom(originalUrl, method, token); + } + + public static string GetHashStringAuto(string source, ServeV3FileContext context) + => GetHashStringAuto(source.AsSpan(), context); + + public static string GetHashStringAuto(ReadOnlySpan source, ServeV3FileContext context) + { + return context.FilenameHashType switch + { + nameof(XxHash3) => GetHashString(source), + nameof(XxHash32) => GetHashString(source), + nameof(XxHash64) => GetHashString(source), + nameof(XxHash128) => GetHashString(source), + nameof(Crc32) => GetHashString(source), + nameof(Crc64) => GetHashString(source), + + _ => throw new NotSupportedException($"FilenameHashType: {context.FilenameHashType} is not supported") + }; + } + + public static string GetHashString(ReadOnlySpan source) + where T : NonCryptographicHashAlgorithm, new() + { + T hasher = new(); + + Span hashBuffer = stackalloc byte[hasher.HashLengthInBytes]; + Span hashCharBuffer = stackalloc char[hasher.HashLengthInBytes * 2]; + + ReadOnlySpan sourceAsBytes = MemoryMarshal.AsBytes(source); + hasher.Append(sourceAsBytes); + hasher.GetHashAndReset(hashBuffer); + + Convert.TryToHexStringLower(hashBuffer, hashCharBuffer, out _); + + return new string(hashCharBuffer); + } +} \ No newline at end of file diff --git a/CollapseLauncher/Classes/Helper/Database/DBHandler.cs b/CollapseLauncher/Classes/Helper/Database/DBHandler.cs index 69b5bb6e6e..e5f5e467fc 100644 --- a/CollapseLauncher/Classes/Helper/Database/DBHandler.cs +++ b/CollapseLauncher/Classes/Helper/Database/DBHandler.cs @@ -2,6 +2,7 @@ using Hi3Helper.SentryHelper; using Libsql.Client; using System; +using System.Collections.Generic; using System.Diagnostics; using System.Linq; using System.Text; @@ -29,7 +30,7 @@ public static bool? IsEnabled field = value; DbConfig.DbEnabled = value ?? false; - _isFirstInit = true; // Force first init + IsInitialized = false; // Force first init if (!(value ?? false)) Dispose(); // Dispose instance if user disabled database function globally } } @@ -45,11 +46,11 @@ public static string? Uri } set { - if (value != field) _isFirstInit = true; // Force first init if value changed + if (value != field) IsInitialized = false; // Force first init if value changed field = value; DbConfig.DbUrl = value; - _isFirstInit = true; + IsInitialized = false; } } @@ -65,11 +66,11 @@ public static string? Token } set { - if (value != field) _isFirstInit = true; // Force first init if value changed + if (value != field) IsInitialized = false; // Force first init if value changed field = value; DbConfig.DbToken = value; - _isFirstInit = true; + IsInitialized = false; } } @@ -93,7 +94,7 @@ public static string? UserId } set { - if (value != field) _isFirstInit = true; // Force first init if value changed + if (value != field) IsInitialized = false; // Force first init if value changed field = value; DbConfig.UserGuid = value; @@ -101,17 +102,18 @@ public static string? UserId if (string.IsNullOrWhiteSpace(value)) { _userIdHash = null; - _isFirstInit = true; + IsInitialized = false; return; } var byteUidH = System.IO.Hashing.XxHash64.Hash(Encoding.ASCII.GetBytes(value)); _userIdHash = Convert.ToHexStringLower(byteUidH); - _isFirstInit = true; + IsInitialized = false; } } + + public static bool IsInitialized { get; private set; } = false; - private static bool _isFirstInit = true; #endregion [DebuggerBrowsable(DebuggerBrowsableState.Never)] @@ -156,14 +158,18 @@ public static async Task Init(bool redirectThrow = false, bool bypassEnableFlag opts.AuthToken = Token; }); - if (_isFirstInit) + if (!IsInitialized) { LogWriteLine("[DbHandler::Init] Initializing database system..."); // Ensure table exist at first initialization await _database .Execute($"CREATE TABLE IF NOT EXISTS \"uid-{_userIdHash}\" (Id INTEGER PRIMARY KEY AUTOINCREMENT, 'key' TEXT UNIQUE NOT NULL, 'value' TEXT)"); - _isFirstInit = false; + + await + _database + .Execute($"CREATE TABLE IF NOT EXISTS \"uid-{_userIdHash}-blob\" (Id INTEGER PRIMARY KEY AUTOINCREMENT, 'key' TEXT UNIQUE NOT NULL, 'value' BLOB)"); + IsInitialized = true; } else LogWriteLine("[DbHandler::Init] Reinitializing database system..."); } @@ -218,7 +224,7 @@ private static void Dispose() private const int MaxAttempts = 5; - public static async Task QueryKey(string key, bool redirectThrow = false) + public static async Task QueryKey(string key, bool redirectThrow = false, bool isBlob = false) { if (!(IsEnabled ?? false)) return null; #if DEBUG @@ -229,7 +235,7 @@ private static void Dispose() #endif for (var i = 0; i < MaxAttempts; i++) { - var retVal = await QueryKeyInternal(key + var retVal = await QueryKeyInternal(key, isBlob #if DEBUG , sId #endif @@ -237,8 +243,16 @@ private static void Dispose() if (retVal.result == 200) { #if DEBUG - LogWriteLine($"[DBHandler::QueryKey][{sId}] Got value!\r\n\tKey: {key}\r\n\tValue:\r\n{retVal.returnedValue}", - LogType.Debug, true); + if (isBlob) + { + LogWriteLine($"[DBHandler::QueryKey][{sId}] Got value!\r\n\tKey: {key}\r\n\tIS BLOB", + LogType.Debug, true); + } + else + { + LogWriteLine($"[DBHandler::QueryKey][{sId}] Got value!\r\n\tKey: {key}\r\n\tValue:\r\n{retVal.returnedValue}", + LogType.Debug, true); + } #endif return retVal.returnedValue; } @@ -278,24 +292,43 @@ private static void Dispose() return null; } - public static async Task StoreKeyValue(string key, string value, bool redirectThrow = false) + public static async Task StoreKeyValue(string key, string value, bool redirectThrow = false, + bool isBlob = false, byte[]? blobValue = null) { if (!(IsEnabled ?? false)) return; #if DEBUG var t = Stopwatch.StartNew(); var r = new Random(); var sId = Math.Abs(r.Next(0, 1000).ToString().GetHashCode()); - LogWriteLine($"[DBHandler::StoreKeyValue][{sId}] Invoked!\r\n\tKey: {key}\r\n\tValue: {value}", LogType.Debug, - true); + if (isBlob) + { + LogWriteLine($"[DBHandler::StoreKeyValue][{sId}] Invoked!\r\n\tKey: {key}\r\n\tIS BLOB", LogType.Debug, + true); + } + else + { + LogWriteLine($"[DBHandler::StoreKeyValue][{sId}] Invoked!\r\n\tKey: {key}\r\n\tValue: {value}", LogType.Debug, + true); + } + #endif for (var i = 0; i < MaxAttempts; i++) { - var retVal = await StoreKeyValueInternal(key, value); + var retVal = await StoreKeyValueInternal(key, value, isBlob, blobValue); if (retVal.result == 200) { #if DEBUG - LogWriteLine($"[DBHandler::StoreKeyValue][{sId}] Saved value!\r\n\tKey: {key}\r\n\tValue: {value}", - LogType.Debug, true); + if (isBlob) + { + LogWriteLine($"[DBHandler::StoreKeyValue][{sId}] Saved value!\r\n\tKey: {key}\r\n\tIS BLOB", + LogType.Debug, true); + } + else + { + LogWriteLine($"[DBHandler::StoreKeyValue][{sId}] Saved value!\r\n\tKey: {key}\r\n\tValue: {value}", + LogType.Debug, true); + } + #endif return; } @@ -338,7 +371,7 @@ public static async Task StoreKeyValue(string key, string value, bool redirectTh #region Private Methods - private static async Task<(int result, string? returnedValue, Exception? exceptionValue)> QueryKeyInternal(string key + private static async Task<(int result, string? returnedValue, Exception? exceptionValue)> QueryKeyInternal(string key, bool isBlob #if DEBUG , int sId = 0 #endif @@ -347,22 +380,53 @@ public static async Task StoreKeyValue(string key, string value, bool redirectTh try { if (_database == null) await Init(true); + var tableName = "uid-" + _userIdHash + (isBlob ? "-blob" : ""); // Get table row for exact key var rs = await _database! - .Execute($"SELECT value FROM \"uid-{_userIdHash}\" WHERE key = ?", key); + .Execute($"SELECT value FROM \"{tableName}\" WHERE key = ?", key); if (rs == null) { return (200, null, null); } + + string str = ""; - // freaking black magic to convert the column row to the value - var str = - string.Join("", rs.Rows.Select(row => string.Join("", row.Select(x => x.ToString())))); + if (isBlob) + { + var firstRow = rs.Rows.FirstOrDefault(); + object? rcv = firstRow?.FirstOrDefault(); + + if (rcv is Blob { Value: IEnumerable byteEnumerable }) + { + str = Convert.ToHexString(byteEnumerable.ToArray()); + } + // ReSharper disable once ConvertTypeCheckPatternToNullCheck + else if (rcv is Blob { Value: byte[] directBytes }) + { + str = Convert.ToHexString(directBytes); + } + } + else + { + // freaking black magic to convert the column row to the value + str = + string.Join("", rs.Rows.Select(row => string.Join("", row.Select(x => x.ToString())))); + } + #if DEBUG - LogWriteLine($"[DBHandler::QueryKey][{sId}] Got value!\r\n\tKey: {key}\r\n\tValue:\r\n{str}", LogType.Debug, - true); + if (isBlob) + { + LogWriteLine($"[DBHandler::QueryKey][{sId}] Got value!\r\n\tKey: {key}\r\n\tIS BLOB", LogType.Debug, + true); + } + else + { + LogWriteLine($"[DBHandler::QueryKey][{sId}] Got value!\r\n\tKey: {key}\r\n\tValue:\r\n{str}", LogType.Debug, + true); + } + #endif return (200, str, null); // 200: OK, return value } @@ -386,16 +450,18 @@ public static async Task StoreKeyValue(string key, string value, bool redirectTh } } - private static async Task<(int result, Exception? exceptionValue)> StoreKeyValueInternal(string key, string value) + private static async Task<(int result, Exception? exceptionValue)> StoreKeyValueInternal(string key, string value, bool isBlob = false, byte[]? blobValue = null) { try { if (_database == null) await Init(true); + var tableName = "uid-" + _userIdHash + (isBlob ? "-blob" : ""); + object dbValue = isBlob && blobValue != null ? blobValue : value; // Create key for storing value, if key already exist, just update the value (key column is set to UNIQUE) - var command = $"INSERT INTO \"uid-{_userIdHash}\" (key, value) VALUES (?, ?) " + + var command = $"INSERT INTO \"{tableName}\" (key, value) VALUES (?, ?) " + $"ON CONFLICT(key) DO UPDATE SET value = ?"; - var parameters = new object[] { key, value, value }; + var parameters = new object[] { key, dbValue, dbValue }; await _database!.Execute(command, parameters); return (200, null); // 200: OK diff --git a/CollapseLauncher/Classes/Helper/FFmpegCodecInstaller.cs b/CollapseLauncher/Classes/Helper/FFmpegCodecInstaller.cs index 52765a5a4e..2791dee277 100644 --- a/CollapseLauncher/Classes/Helper/FFmpegCodecInstaller.cs +++ b/CollapseLauncher/Classes/Helper/FFmpegCodecInstaller.cs @@ -1,5 +1,6 @@ using CollapseLauncher.Extension; using CollapseLauncher.GameManagement.ImageBackground; +using CollapseLauncher.Helper.InternalPInvoke; using CollapseLauncher.Helper.StreamUtility; using CollapseLauncher.Interfaces; using Hi3Helper.Data; @@ -112,7 +113,7 @@ await extractDelegate(() => File.Open(packageFile, extractFolder, token); - string innerFfmpegDir = ImageBackgroundManager.FindFFmpegInstallFolder(extractFolder, ImageBackgroundManager.Shared.GlobalFFmpegLibraryNames) ?? + string innerFfmpegDir = FFmpegPInvoke.FindFFmpegInstallFolder(extractFolder, ImageBackgroundManager.Shared.GlobalFFmpegLibraryNames) ?? throw new FileNotFoundException("Library files are not found!"); foreach (string dllFile in Directory.EnumerateFiles(innerFfmpegDir, "*.dll", SearchOption.TopDirectoryOnly)) diff --git a/CollapseLauncher/Classes/Helper/Image/ImageLoaderHelper.cs b/CollapseLauncher/Classes/Helper/Image/ImageLoaderHelper.cs index c839b16361..1c6abf9950 100644 --- a/CollapseLauncher/Classes/Helper/Image/ImageLoaderHelper.cs +++ b/CollapseLauncher/Classes/Helper/Image/ImageLoaderHelper.cs @@ -1,4 +1,5 @@ -using CollapseLauncher.Helper.StreamUtility; +using CollapseLauncher.GameManagement.ImageBackground; +using CollapseLauncher.Helper.StreamUtility; using CollapseLauncher.Plugins; using CollapseLauncher.XAMLs.Theme.CustomControls; using Hi3Helper; @@ -35,21 +36,6 @@ namespace CollapseLauncher.Helper.Image { internal static class ImageLoaderHelper { - internal static readonly string SupportedImageFormats = - string.Join(";", LayeredBackgroundImage.SupportedImageBitmapExtensions.Select(x => $"*{x}")) + ";" + - string.Join(";", LayeredBackgroundImage.SupportedImageBitmapExternalCodecExtensions.Select(x => $"*{x}")) + ";" + - string.Join(";", LayeredBackgroundImage.SupportedImageVectorExtensions.Select(x => $"*{x}")); - - internal static readonly string SupportedVideoFormats = - string.Join(";", LayeredBackgroundImage.SupportedVideoExtensions.Select(x => $"*{x}")); - - internal static readonly Dictionary SupportedBackgroundFormats = - new() { - { "All supported formats", SupportedImageFormats + ';' + SupportedVideoFormats }, - { "Image formats", SupportedImageFormats }, - { "Video formats", SupportedVideoFormats } - }; - #region Waifu2X public static Waifu2X Waifu2X; private static Waifu2XStatus _cachedStatus = Waifu2XStatus.NotInitialized; @@ -420,8 +406,12 @@ private static async Task GetFallbackStreamUrlInner(HttpClient? client, public static async Task GetCachedSpritesAsync(HttpClient? httpClient, string? url, bool isSkipHashCheck, CancellationToken token) { - if (string.IsNullOrEmpty(url) || - ProcessingUrls.Contains(url)) + if (string.IsNullOrEmpty(url)) + { + return null; + } + + if (ProcessingUrls.Contains(url)) { Logger.LogWriteLine("Found duplicate download request, skipping...\r\n\t" + $"URL : {url}", LogType.Warning, true); @@ -432,7 +422,7 @@ private static async Task GetFallbackStreamUrlInner(HttpClient? client, if (!Directory.Exists(AppGameImgCachedFolder)) Directory.CreateDirectory(AppGameImgCachedFolder); - FileInfo fInfo = new FileInfo(cachePath); + FileInfo fInfo = new(cachePath); if (await IsFileCompletelyDownloadedAsync(fInfo, !isSkipHashCheck)) { return cachePath; @@ -442,6 +432,7 @@ private static async Task GetFallbackStreamUrlInner(HttpClient? client, { return url; } + return cachePath; } diff --git a/CollapseLauncher/Classes/Helper/Image/Waifu2X.cs b/CollapseLauncher/Classes/Helper/Image/Waifu2X.cs index cb4cbf279b..0fe47486db 100644 --- a/CollapseLauncher/Classes/Helper/Image/Waifu2X.cs +++ b/CollapseLauncher/Classes/Helper/Image/Waifu2X.cs @@ -219,7 +219,7 @@ public static Waifu2XStatus VulkanTest() Logger.LogWriteLine("D3DMappingLayers package detected. Fallback to CPU mode.", LogType.Warning, true); return Waifu2XStatus.D3DMappingLayers; } - var status = Waifu2XPInvoke.waifu2x_self_test(0); + Waifu2XStatus status = Waifu2XPInvoke.waifu2x_self_test(0); switch (status) { case Waifu2XStatus.CpuMode: @@ -248,7 +248,7 @@ public static Waifu2XStatus VulkanTest() return ReturnAsFailedDllInit(ex); } - Waifu2XStatus ReturnAsFailedDllInit(T ex) + static Waifu2XStatus ReturnAsFailedDllInit(T ex) where T : Exception { Logger.LogWriteLine($"Cannot load Waifu2X as the library failed to load!\r\n{ex}", LogType.Error, true); diff --git a/CollapseLauncher/Classes/Helper/InternalPInvoke/CollapsePInvoke.cs b/CollapseLauncher/Classes/Helper/InternalPInvoke/CollapsePInvoke.cs index 228113679b..fcc5d6b478 100644 --- a/CollapseLauncher/Classes/Helper/InternalPInvoke/CollapsePInvoke.cs +++ b/CollapseLauncher/Classes/Helper/InternalPInvoke/CollapsePInvoke.cs @@ -1,19 +1,34 @@ using Hi3Helper; using Hi3Helper.Shared.Region; using System; +using System.Collections.Generic; using System.IO; using System.Reflection; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; +// ReSharper disable StringLiteralTypo +// ReSharper disable IdentifierTypo +// ReSharper disable InconsistentNaming +#pragma warning disable IDE0130 namespace CollapseLauncher.Helper.InternalPInvoke; internal static class CollapsePInvoke { - private const string LibraryExtension = ".dll"; + internal const string LibraryExtension = ".dll"; + + public static Dictionary CustomResolvers = new(StringComparer.OrdinalIgnoreCase); internal static nint DllImportResolver(string libraryName, Assembly assembly, DllImportSearchPath? searchPath) { + // Try to resolve the library from the Custom Resolvers first. + if (CustomResolvers.TryGetValue(libraryName, out DllImportResolver customResolver) && + customResolver(libraryName, assembly, searchPath) is var customResolverResult && + customResolverResult != nint.Zero) + { + return customResolverResult; + } + bool retryFirst = false; string pathToLoad = libraryName; LoadFirst: diff --git a/CollapseLauncher/Classes/Helper/InternalPInvoke/FFmpeg/AVCodecParameters.cs b/CollapseLauncher/Classes/Helper/InternalPInvoke/FFmpeg/AVCodecParameters.cs new file mode 100644 index 0000000000..451fca1ffb --- /dev/null +++ b/CollapseLauncher/Classes/Helper/InternalPInvoke/FFmpeg/AVCodecParameters.cs @@ -0,0 +1,24 @@ +using System.Runtime.InteropServices; +// ReSharper disable IdentifierTypo +// ReSharper disable UnusedMember.Global +// ReSharper disable InconsistentNaming +#pragma warning disable IDE0130 + +namespace CollapseLauncher.Helper.InternalPInvoke.FFmpeg; + +[StructLayout(LayoutKind.Sequential)] +public struct AVCodecParameters +{ + public AVMediaType codec_type; + public AVCodecID codec_id; + + public uint codec_tag; + + public byte* extradata; + public int extradata_size; + + public void* coded_side_data; + public int nb_coded_side_data; + + public int format; +} \ No newline at end of file diff --git a/CollapseLauncher/Classes/Helper/InternalPInvoke/FFmpeg/AVFormatContext.cs b/CollapseLauncher/Classes/Helper/InternalPInvoke/FFmpeg/AVFormatContext.cs new file mode 100644 index 0000000000..128c0f349b --- /dev/null +++ b/CollapseLauncher/Classes/Helper/InternalPInvoke/FFmpeg/AVFormatContext.cs @@ -0,0 +1,22 @@ +using System.Runtime.InteropServices; +// ReSharper disable IdentifierTypo +// ReSharper disable UnusedMember.Global +// ReSharper disable InconsistentNaming +#pragma warning disable IDE0130 + +namespace CollapseLauncher.Helper.InternalPInvoke.FFmpeg; + +[StructLayout(LayoutKind.Sequential)] +public unsafe struct AVFormatContext +{ + public void* av_class; + public void* iformat; + public void* oformat; + public void* priv_data; + public void* pb; + + public int ctx_flags; + public uint nb_streams; + + public AVStream** streams; +} \ No newline at end of file diff --git a/CollapseLauncher/Classes/Helper/InternalPInvoke/FFmpeg/AVMediaType.cs b/CollapseLauncher/Classes/Helper/InternalPInvoke/FFmpeg/AVMediaType.cs index 56516236df..a907e5a1c6 100644 --- a/CollapseLauncher/Classes/Helper/InternalPInvoke/FFmpeg/AVMediaType.cs +++ b/CollapseLauncher/Classes/Helper/InternalPInvoke/FFmpeg/AVMediaType.cs @@ -1,4 +1,9 @@ -namespace CollapseLauncher.Helper.InternalPInvoke.FFmpeg; +// ReSharper disable StringLiteralTypo +// ReSharper disable IdentifierTypo +// ReSharper disable InconsistentNaming +// ReSharper disable CommentTypo +#pragma warning disable IDE0130 +namespace CollapseLauncher.Helper.InternalPInvoke.FFmpeg; // https://ffmpeg.org/doxygen/7.0/group__lavu__misc.html#ga9a84bba4713dfced21a1a56163be1f48 public enum AVMediaType diff --git a/CollapseLauncher/Classes/Helper/InternalPInvoke/FFmpeg/AVPixFmtDescriptor.cs b/CollapseLauncher/Classes/Helper/InternalPInvoke/FFmpeg/AVPixFmtDescriptor.cs new file mode 100644 index 0000000000..81a7840efd --- /dev/null +++ b/CollapseLauncher/Classes/Helper/InternalPInvoke/FFmpeg/AVPixFmtDescriptor.cs @@ -0,0 +1,19 @@ +// ReSharper disable StringLiteralTypo +// ReSharper disable IdentifierTypo +// ReSharper disable InconsistentNaming +#pragma warning disable IDE0130 +using System.Runtime.InteropServices; + +namespace CollapseLauncher.Helper.InternalPInvoke.FFmpeg; + +[StructLayout(LayoutKind.Sequential)] +public struct AVPixFmtDescriptor +{ + public byte* Name; + + public byte NbComponents; + public byte Log2ChromaW; + public byte Log2ChromaH; + + public ulong Flags; +} \ No newline at end of file diff --git a/CollapseLauncher/Classes/Helper/InternalPInvoke/FFmpeg/AVStream.cs b/CollapseLauncher/Classes/Helper/InternalPInvoke/FFmpeg/AVStream.cs new file mode 100644 index 0000000000..b33c100f1a --- /dev/null +++ b/CollapseLauncher/Classes/Helper/InternalPInvoke/FFmpeg/AVStream.cs @@ -0,0 +1,18 @@ +using System.Runtime.InteropServices; +// ReSharper disable IdentifierTypo +// ReSharper disable UnusedMember.Global +// ReSharper disable InconsistentNaming +#pragma warning disable IDE0130 + +namespace CollapseLauncher.Helper.InternalPInvoke.FFmpeg; + +[StructLayout(LayoutKind.Sequential)] +public struct AVStream +{ + public void* av_class; + + public int index; + public int id; + + public AVCodecParameters* codecpar; +} \ No newline at end of file diff --git a/CollapseLauncher/Classes/Helper/InternalPInvoke/FFmpeg/Const.cs b/CollapseLauncher/Classes/Helper/InternalPInvoke/FFmpeg/Const.cs new file mode 100644 index 0000000000..aceb494b97 --- /dev/null +++ b/CollapseLauncher/Classes/Helper/InternalPInvoke/FFmpeg/Const.cs @@ -0,0 +1,17 @@ +// ReSharper disable IdentifierTypo +// ReSharper disable InconsistentNaming +#pragma warning disable IDE0130 +namespace CollapseLauncher.Helper.InternalPInvoke.FFmpeg; + +public static class Const +{ + public const string AVCodecPrefix = "avcodec"; + public const string AVDevicePrefix = "avdevice"; + public const string AVFilterPrefix = "avfilter"; + public const string AVFormatPrefix = "avformat"; + public const string AVUtilPrefix = "avutil"; + public const string SWResamplePrefix = "swresample"; + public const string SWScalePrefix = "swscale"; + public const string PostprocPrefix = "postproc"; +} + diff --git a/CollapseLauncher/Classes/Helper/InternalPInvoke/FFmpeg/FFmpegLibraryNames.cs b/CollapseLauncher/Classes/Helper/InternalPInvoke/FFmpeg/FFmpegLibraryNames.cs new file mode 100644 index 0000000000..99ada37807 --- /dev/null +++ b/CollapseLauncher/Classes/Helper/InternalPInvoke/FFmpeg/FFmpegLibraryNames.cs @@ -0,0 +1,35 @@ +// ReSharper disable IdentifierTypo +#pragma warning disable IDE0130 +namespace CollapseLauncher.Helper.InternalPInvoke.FFmpeg; + +public struct FFmpegLibraryNames +{ + internal FFmpegLibraryNames( + int codecNum, + int deviceNum, + int filterNum, + int formatNum, + int utilNum, + int resampleNum, + int scaleNum, + int postprocNum) + { + Codec = Const.AVCodecPrefix + '-' + codecNum + CollapsePInvoke.LibraryExtension; + Device = Const.AVDevicePrefix + '-' + deviceNum + CollapsePInvoke.LibraryExtension; + Filter = Const.AVFilterPrefix + '-' + filterNum + CollapsePInvoke.LibraryExtension; + Format = Const.AVFormatPrefix + '-' + formatNum + CollapsePInvoke.LibraryExtension; + Util = Const.AVUtilPrefix + '-' + utilNum + CollapsePInvoke.LibraryExtension; + Resample = Const.SWResamplePrefix + '-' + resampleNum + CollapsePInvoke.LibraryExtension; + Scale = Const.SWScalePrefix + '-' + scaleNum + CollapsePInvoke.LibraryExtension; + PostProc = Const.PostprocPrefix + '-' + postprocNum + CollapsePInvoke.LibraryExtension; + } + + public string Codec; + public string Device; + public string Filter; + public string Format; + public string Util; + public string Resample; + public string Scale; + public string PostProc; +} diff --git a/CollapseLauncher/Classes/Helper/InternalPInvoke/FFmpeg/MediaSupport.cs b/CollapseLauncher/Classes/Helper/InternalPInvoke/FFmpeg/MediaSupport.cs new file mode 100644 index 0000000000..2d3efd50ec --- /dev/null +++ b/CollapseLauncher/Classes/Helper/InternalPInvoke/FFmpeg/MediaSupport.cs @@ -0,0 +1,31 @@ +// ReSharper disable StringLiteralTypo +// ReSharper disable IdentifierTypo +// ReSharper disable InconsistentNaming +#pragma warning disable IDE0130 + +#nullable enable + +namespace CollapseLauncher.Helper.InternalPInvoke.FFmpeg; +internal readonly record struct StreamSupport( + bool Exists, + bool DecoderAvailable, + int StreamIndex); + +internal readonly record struct VideoPixelFormatInfo( + AVPixelFormat Format, + string? Name, + PixelColorModel ColorModel, + bool IsGrayscale, + int ComponentCount, + int Log2ChromaWidth, + int Log2ChromaHeight); + +internal readonly record struct MediaSupport( + StreamSupport Video, + VideoPixelFormatInfo VideoPixelFormatInfo, + StreamSupport Audio) +{ + public bool IsSupported => + (!Video.Exists || Video.DecoderAvailable) && + (!Audio.Exists || Audio.DecoderAvailable); +} \ No newline at end of file diff --git a/CollapseLauncher/Classes/Helper/InternalPInvoke/FFmpeg/PixelColorModel.cs b/CollapseLauncher/Classes/Helper/InternalPInvoke/FFmpeg/PixelColorModel.cs new file mode 100644 index 0000000000..aa275e8484 --- /dev/null +++ b/CollapseLauncher/Classes/Helper/InternalPInvoke/FFmpeg/PixelColorModel.cs @@ -0,0 +1,25 @@ +// ReSharper disable StringLiteralTypo +// ReSharper disable IdentifierTypo +// ReSharper disable InconsistentNaming + +using System; + +#pragma warning disable IDE0130 + +namespace CollapseLauncher.Helper.InternalPInvoke.FFmpeg; + +[Flags] +public enum PixelColorModel : ulong +{ + Unknown, + BigEndian = 1UL << 0, + Pal = 1UL << 1, + Bitstream = 1UL << 2, + Hardware = 1UL << 3, + Planar = 1UL << 4, + Rgb = 1UL << 5, + Alpha = 1UL << 7, + Bayer = 1UL << 8, + Float = 1UL << 9, + Xyz = 1UL << 10, +} \ No newline at end of file diff --git a/CollapseLauncher/Classes/Helper/InternalPInvoke/FFmpegPInvoke.ExternFormat.cs b/CollapseLauncher/Classes/Helper/InternalPInvoke/FFmpegPInvoke.ExternFormat.cs new file mode 100644 index 0000000000..d50b01b0f6 --- /dev/null +++ b/CollapseLauncher/Classes/Helper/InternalPInvoke/FFmpegPInvoke.ExternFormat.cs @@ -0,0 +1,44 @@ +using CollapseLauncher.Helper.InternalPInvoke.FFmpeg; +using System.Runtime.InteropServices; +// ReSharper disable StringLiteralTypo +// ReSharper disable IdentifierTypo +// ReSharper disable InconsistentNaming +#pragma warning disable IDE0130 + +namespace CollapseLauncher.Helper.InternalPInvoke; + +public static unsafe partial class FFmpegPInvoke +{ + public static unsafe partial class ExternFormat + { + [LibraryImport(Const.AVFormatPrefix, + EntryPoint = "avformat_open_input", + StringMarshalling = StringMarshalling.Utf8)] + public static partial int avformat_open_input( + AVFormatContext** context, + string url, + void* fmt, + void* options); + + [LibraryImport(Const.AVFormatPrefix, + EntryPoint = "avformat_find_stream_info")] + public static partial int avformat_find_stream_info( + AVFormatContext* context, + void* options); + + [LibraryImport(Const.AVFormatPrefix, + EntryPoint = "av_find_best_stream")] + public static partial int av_find_best_stream( + AVFormatContext* context, + AVMediaType type, + int wantedStreamNumber, + int relatedStream, + ref nint decoder, + int flags); + + [LibraryImport(Const.AVFormatPrefix, + EntryPoint = "avformat_close_input")] + public static partial void avformat_close_input( + AVFormatContext** context); + } +} diff --git a/CollapseLauncher/Classes/Helper/InternalPInvoke/FFmpegPInvoke.ExternUtil.cs b/CollapseLauncher/Classes/Helper/InternalPInvoke/FFmpegPInvoke.ExternUtil.cs new file mode 100644 index 0000000000..2384ae0fb6 --- /dev/null +++ b/CollapseLauncher/Classes/Helper/InternalPInvoke/FFmpegPInvoke.ExternUtil.cs @@ -0,0 +1,19 @@ +using CollapseLauncher.Helper.InternalPInvoke.FFmpeg; +using System.Runtime.InteropServices; +// ReSharper disable StringLiteralTypo +// ReSharper disable IdentifierTypo +// ReSharper disable InconsistentNaming +#pragma warning disable IDE0130 + +namespace CollapseLauncher.Helper.InternalPInvoke; + +public static unsafe partial class FFmpegPInvoke +{ + public static unsafe partial class ExternUtil + { + [LibraryImport(Const.AVUtilPrefix, + EntryPoint = "av_pix_fmt_desc_get")] + public static partial AVPixFmtDescriptor* av_pix_fmt_desc_get( + AVPixelFormat pixelFormat); + } +} diff --git a/CollapseLauncher/Classes/Helper/InternalPInvoke/FFmpegPInvoke.cs b/CollapseLauncher/Classes/Helper/InternalPInvoke/FFmpegPInvoke.cs index b24012c92a..5c9ab43dda 100644 --- a/CollapseLauncher/Classes/Helper/InternalPInvoke/FFmpegPInvoke.cs +++ b/CollapseLauncher/Classes/Helper/InternalPInvoke/FFmpegPInvoke.cs @@ -1,50 +1,387 @@ using CollapseLauncher.Helper.InternalPInvoke.FFmpeg; +using Hi3Helper.Shared.Region; using System; +using System.Collections; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Linq; +using System.Reflection; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; +// ReSharper disable StringLiteralTypo +// ReSharper disable IdentifierTypo +// ReSharper disable InconsistentNaming +#pragma warning disable IDE0130 #nullable enable -namespace CollapseLauncher.Helper.FFmpegPInvoke; +namespace CollapseLauncher.Helper.InternalPInvoke; public static unsafe partial class FFmpegPInvoke { + static FFmpegPInvoke() + { + CollapsePInvoke.CustomResolvers.Add(Const.AVCodecPrefix, FFmpegCustomDllImportResolver); + CollapsePInvoke.CustomResolvers.Add(Const.AVDevicePrefix, FFmpegCustomDllImportResolver); + CollapsePInvoke.CustomResolvers.Add(Const.AVFilterPrefix, FFmpegCustomDllImportResolver); + CollapsePInvoke.CustomResolvers.Add(Const.AVFormatPrefix, FFmpegCustomDllImportResolver); + CollapsePInvoke.CustomResolvers.Add(Const.AVUtilPrefix, FFmpegCustomDllImportResolver); + CollapsePInvoke.CustomResolvers.Add(Const.SWResamplePrefix, FFmpegCustomDllImportResolver); + CollapsePInvoke.CustomResolvers.Add(Const.SWScalePrefix, FFmpegCustomDllImportResolver); + CollapsePInvoke.CustomResolvers.Add(Const.PostprocPrefix, FFmpegCustomDllImportResolver); + } + + private const int GlobalFFmpegDefaultVersionKey = 80; + private const string GlobalFFmpegCustomPathConfigKey = "GlobalFFmpegCustomPath"; + private const string GlobalFFmpegVersionToUseConfigKey = "GlobalFFmpegVersionToUse"; + public static Dictionary FFmpegVersionLibNames = new() { - { 70, new FFmpegLibraryNames("avcodec-61.dll", "avdevice-61.dll", "avfilter-10.dll", "avformat-61.dll", "avutil-59.dll", "swresample-5.dll", "swscale-8.dll", "postproc-58.dll") }, - { 80, new FFmpegLibraryNames("avcodec-62.dll", "avdevice-62.dll", "avfilter-11.dll", "avformat-62.dll", "avutil-60.dll", "swresample-6.dll", "swscale-9.dll", "postproc-59.dll") } + { 70, new FFmpegLibraryNames(61, 61, 10, 61, 59, 5, 8, 58) }, + { 80, new FFmpegLibraryNames(62, 62, 11, 62, 60, 6, 9, 59) } }; - [LibraryImport("avcodec")] - public static partial AVCodec* av_codec_iterate(ref nint opaque); + public static int FFmpegVersionToUse + { + get + { + int version = LauncherConfig.GetAppConfigValue(GlobalFFmpegVersionToUseConfigKey); + return FFmpegVersionLibNames.ContainsKey(version) ? + version : + GlobalFFmpegDefaultVersionKey; + } + set + { + if (!FFmpegVersionLibNames.ContainsKey(value)) + { + return; + } + + LauncherConfig.SetAndSaveConfigValue(GlobalFFmpegVersionToUseConfigKey, value); + } + } + + public static FFmpegLibraryNames FFmpegLibraryNamings + { + get + { + if (FFmpegVersionLibNames.TryGetValue(FFmpegVersionToUse, out FFmpegLibraryNames names)) + { + return names; + } + + return FFmpegVersionLibNames.TryGetValue(GlobalFFmpegDefaultVersionKey, out names) ? names : + FFmpegVersionLibNames.Values.FirstOrDefault(); + } + } - public static IEnumerable EnumerateSupportedCodecs() - => EnumerateSupportedCodecs([]); + public static string? CustomFFmpegPath + { + get => LauncherConfig.GetAppConfigValue(GlobalFFmpegCustomPathConfigKey); + set => LauncherConfig.SetAndSaveConfigValue(GlobalFFmpegCustomPathConfigKey, value); + } - public static IEnumerable EnumerateSupportedCodecs(params AVMediaType[] codecTypes) + private static nint FFmpegCustomDllImportResolver(string libraryName, Assembly assembly, DllImportSearchPath? searchPath) { - nint opaque = nint.Zero; - scoped ref AVCodec codecPtr = ref Unsafe.NullRef(); + FFmpegLibraryNames namings = FFmpegLibraryNamings; + string libraryFilename = libraryName switch + { + Const.AVCodecPrefix => namings.Codec, + Const.AVDevicePrefix => namings.Device, + Const.AVFilterPrefix => namings.Filter, + Const.AVFormatPrefix => namings.Format, + Const.AVUtilPrefix => namings.Util, + Const.SWResamplePrefix => namings.Resample, + Const.SWScalePrefix => namings.Scale, + Const.PostprocPrefix => namings.PostProc, + _ => throw new InvalidOperationException($"{libraryName} is not included in FFmpegPInvoke list") + }; - Iterate: - codecPtr = ref IterateCodec(ref opaque); - if (Unsafe.IsNullRef(in codecPtr)) + string? customPath = CustomFFmpegPath; + + // 1. Try to load from custom path first + if (!string.IsNullOrEmpty(customPath) && + IsFFmpegAvailable(customPath, namings, out _) && + TryLoad(Path.Combine(customPath, libraryFilename), out nint handle)) + { + return handle; + } + + // 2. Try to find on the current linked path + if (IsFFmpegAvailable(LauncherConfig.AppExecutableDir, namings, out _) && + TryLoad(Path.Combine(LauncherConfig.AppExecutableDir, libraryFilename), out handle)) { - yield break; + return handle; } - if (codecTypes.Length != 0 && - !codecTypes.Contains(codecPtr.Type)) + // 3. Try to find from environment variable + if (TryFindFFmpegInstallFromEnvVar(namings, out string? envVarPath, out _) && + TryLoad(Path.Combine(envVarPath, libraryFilename), out handle)) { - goto Iterate; + return handle; } - yield return codecPtr; - goto Iterate; + // Otherwise, return null + return nint.Zero; + + static bool TryLoad(string fullPath, out nint handle) + { + if (FileUtility.IsFileExistOrSymbolicLinkResolved(fullPath, out string? resolvedPath, out _)) + { + fullPath = resolvedPath; + } + + return NativeLibrary.TryLoad(fullPath, out handle); + } } - private static ref AVCodec IterateCodec(ref nint opaque) - => ref Unsafe.AsRef(av_codec_iterate(ref opaque)); + internal static bool IsFFmpegAvailable( + string? checkOnDirectory, + FFmpegLibraryNames libraries, + [NotNullWhen(false)] + out Exception? exception) + { + if (string.IsNullOrEmpty(checkOnDirectory)) + { + exception = new NullReferenceException($"Argument: {nameof(checkOnDirectory)} is null!"); + return false; + } + + checkOnDirectory = FileUtility.GetFullyQualifiedPath(checkOnDirectory); + + string dllPathAvcodec = Path.Combine(checkOnDirectory, libraries.Codec); + string dllPathAvdevice = Path.Combine(checkOnDirectory, libraries.Device); + string dllPathAvfilter = Path.Combine(checkOnDirectory, libraries.Filter); + string dllPathAvformat = Path.Combine(checkOnDirectory, libraries.Format); + string dllPathAvutil = Path.Combine(checkOnDirectory, libraries.Util); + string dllPathSwresample = Path.Combine(checkOnDirectory, libraries.Resample); + string dllPathSwscale = Path.Combine(checkOnDirectory, libraries.Scale); + + return FileUtility.IsFileExistOrSymbolicLinkResolved(dllPathAvcodec, out _, out exception) && + FileUtility.IsFileExistOrSymbolicLinkResolved(dllPathAvdevice, out _, out exception) && + FileUtility.IsFileExistOrSymbolicLinkResolved(dllPathAvfilter, out _, out exception) && + FileUtility.IsFileExistOrSymbolicLinkResolved(dllPathAvformat, out _, out exception) && + FileUtility.IsFileExistOrSymbolicLinkResolved(dllPathAvutil, out _, out exception) && + FileUtility.IsFileExistOrSymbolicLinkResolved(dllPathSwresample, out _, out exception) && + FileUtility.IsFileExistOrSymbolicLinkResolved(dllPathSwscale, out _, out exception); + } + + internal static bool TryFindFFmpegInstallFromEnvVar( + FFmpegLibraryNames libraries, + [NotNullWhen(true)] out string? path, + out Exception? exception) + { + return FindIn(EnvironmentVariableTarget.User, out path, out exception) || + FindIn(EnvironmentVariableTarget.Machine, out path, out exception); + + bool FindIn(EnvironmentVariableTarget target, [NotNullWhen(true)] out string? innerPath, out Exception? exception) + { + const string separators = ";,"; + Unsafe.SkipInit(out innerPath); + Unsafe.SkipInit(out exception); - public record struct FFmpegLibraryNames(string Codec, string Device, string Filter, string Format, string Util, string Resample, string Scale, string PostProc); + foreach (object? varValue in Environment.GetEnvironmentVariables(target)) + { + if (varValue is not DictionaryEntry { Value: string varValueStr }) + { + continue; + } + + ReadOnlySpan envVar = varValueStr; + foreach (Range envVarRange in envVar.SplitAny(separators)) + { + ReadOnlySpan envVarPath = envVar[envVarRange].Trim(" '\""); + if (envVarPath.IsEmpty) + { + continue; + } + + string thisPath = envVarPath.ToString(); + + if (!Path.IsPathFullyQualified(thisPath) || + !IsFFmpegAvailable(thisPath, libraries, out exception)) continue; + + innerPath = thisPath; + return true; + } + } + + return false; + } + } + + internal static string? FindFFmpegInstallFolder(string checkOnDirectory, FFmpegLibraryNames libraries) + { + try + { + if (IsFFmpegAvailable(checkOnDirectory, libraries, out _)) + { + return checkOnDirectory; + } + + foreach (string dirPath in FileUtility.EnumerateDirectoryRecursive(checkOnDirectory)) + { + if (IsFFmpegAvailable(dirPath, libraries, out _)) + { + return dirPath; + } + } + } + catch + { + // ignored + } + + return null; + } + + internal static unsafe MediaSupport GetMediaSupport(string filePath) + { + AVFormatContext* formatContext = null; + int result = ExternFormat.avformat_open_input(&formatContext, + filePath, + null, + null); + + if (result < 0) + { + return default; + } + + try + { + result = ExternFormat.avformat_find_stream_info(formatContext, null); + if (result < 0) + { + return default; + } + + StreamSupport video = CheckAVFormatStream(formatContext, AVMediaType.AVMEDIA_TYPE_VIDEO, out int videoStreamIndex); + StreamSupport audio = CheckAVFormatStream(formatContext, AVMediaType.AVMEDIA_TYPE_AUDIO, out _); + + VideoPixelFormatInfo videoPixelFormat = !video.DecoderAvailable + ? default + : GetAVFormatVideoPixelFormat(formatContext, videoStreamIndex); + + return new MediaSupport(video, videoPixelFormat, audio); + } + finally + { + ExternFormat.avformat_close_input(&formatContext); + } + } + + private static unsafe VideoPixelFormatInfo GetAVFormatVideoPixelFormat(AVFormatContext* context, int streamIndex) + { + AVStream* stream = context->streams[streamIndex]; + AVCodecParameters* parameters = stream->codecpar; + AVPixelFormat pixelFormat = (AVPixelFormat)parameters->format; + + if (pixelFormat == AVPixelFormat.AV_PIX_FMT_NONE) + { + return new VideoPixelFormatInfo(pixelFormat, + null, + PixelColorModel.Unknown, + false, + 0, + 0, + 0); + } + + AVPixFmtDescriptor* descriptor = ExternUtil.av_pix_fmt_desc_get(pixelFormat); + string? name = ToString(descriptor->Name); + + PixelColorModel model = GetPixelColorModel(descriptor, out bool isGrayscale); + return new VideoPixelFormatInfo(pixelFormat, + name, + model, + isGrayscale, + descriptor->NbComponents, + descriptor->Log2ChromaW, + descriptor->Log2ChromaH); + + static string? ToString(byte* ptr) => ptr == null ? null : Marshal.PtrToStringUTF8((nint)ptr); + } + + private static StreamSupport CheckAVFormatStream( + AVFormatContext* context, + AVMediaType type, + out int streamIndex) + { + streamIndex = ExternFormat.av_find_best_stream(context, + type, + -1, + -1, + ref Unsafe.NullRef(), + 0); + + if (streamIndex < 0) + { + return new StreamSupport(Exists: false, + DecoderAvailable: false, + StreamIndex: -1); + } + + nint decoder = nint.Zero; + int result = ExternFormat.av_find_best_stream(context, + type, + streamIndex, + -1, + ref decoder, + 0); + + return new StreamSupport(Exists: true, + DecoderAvailable: result >= 0 && decoder != nint.Zero, + StreamIndex: streamIndex); + } + + private static unsafe PixelColorModel GetPixelColorModel(AVPixFmtDescriptor* desc, out bool isGrayscale) + { + const ulong AV_PIX_FMT_FLAG_BE = 1UL << 0; + const ulong AV_PIX_FMT_FLAG_PAL = 1UL << 1; + const ulong AV_PIX_FMT_FLAG_BITSTREAM = 1UL << 2; + const ulong AV_PIX_FMT_FLAG_HWACCEL = 1UL << 3; + const ulong AV_PIX_FMT_FLAG_PLANAR = 1UL << 4; + const ulong AV_PIX_FMT_FLAG_RGB = 1UL << 5; + const ulong AV_PIX_FMT_FLAG_ALPHA = 1UL << 7; + const ulong AV_PIX_FMT_FLAG_BAYER = 1UL << 8; + const ulong AV_PIX_FMT_FLAG_FLOAT = 1UL << 9; + const ulong AV_PIX_FMT_FLAG_XYZ = 1UL << 10; + + ulong flags = desc->Flags; + PixelColorModel pixelFormat = 0; + + if ((flags & AV_PIX_FMT_FLAG_BE) != 0) + pixelFormat |= PixelColorModel.BigEndian; + + if ((flags & AV_PIX_FMT_FLAG_PAL) != 0) + pixelFormat |= PixelColorModel.Pal; + + if ((flags & AV_PIX_FMT_FLAG_BITSTREAM) != 0) + pixelFormat |= PixelColorModel.Bitstream; + + if ((flags & AV_PIX_FMT_FLAG_HWACCEL) != 0) + pixelFormat |= PixelColorModel.Hardware; + + if ((flags & AV_PIX_FMT_FLAG_PLANAR) != 0) + pixelFormat |= PixelColorModel.Planar; + + if ((flags & AV_PIX_FMT_FLAG_RGB) != 0) + pixelFormat |= PixelColorModel.Rgb; + + if ((flags & AV_PIX_FMT_FLAG_ALPHA) != 0) + pixelFormat |= PixelColorModel.Rgb; + + if ((flags & AV_PIX_FMT_FLAG_BAYER) != 0) + pixelFormat |= PixelColorModel.Bayer; + + if ((flags & AV_PIX_FMT_FLAG_FLOAT) != 0) + pixelFormat |= PixelColorModel.Float; + + if ((flags & AV_PIX_FMT_FLAG_XYZ) != 0) + pixelFormat |= PixelColorModel.Xyz; + + isGrayscale = desc->NbComponents <= 2; + return pixelFormat; + } } diff --git a/CollapseLauncher/Classes/Helper/LauncherApiLoader/HoYoPlay/HypApiLoader.cs b/CollapseLauncher/Classes/Helper/LauncherApiLoader/HoYoPlay/HypApiLoader.cs index bd41ef14e7..fddb575758 100644 --- a/CollapseLauncher/Classes/Helper/LauncherApiLoader/HoYoPlay/HypApiLoader.cs +++ b/CollapseLauncher/Classes/Helper/LauncherApiLoader/HoYoPlay/HypApiLoader.cs @@ -358,14 +358,14 @@ private Task LoadLauncherGameInfo(ActionOnTimeOutRetry? onTimeoutRoutine, bool isUseMultiLang = PresetConfig?.LauncherSpriteURLMultiLang ?? false; - string localeCode = (isUseMultiLang ? Locale.Current.Lang?.LanguageID?.ToLower() : PresetConfig?.LauncherSpriteURLMultiLangFallback) ?? ""; + string localeCode = (isUseMultiLang ? Locale.Current.Lang?.LanguageID?.ToLowerInvariant() : PresetConfig?.LauncherSpriteURLMultiLangFallback) ?? ""; string launcherGameInfoUrl = string.Format(PresetConfig?.LauncherGameInfoDisplayURL!, localeCode); - ActionTimeoutTaskAwaitableCallback hypLauncherGameInfoCallback = + ActionTimeoutTaskAwaitableCallback hypLauncherGameInfoCallback = innerToken => ApiResourceHttpClient .GetFromCachedJsonAsync(launcherGameInfoUrl, - HypApiJsonContext.Default.HypLauncherSophonBranchesApi, + HypApiJsonContext.Default.HypLauncherGetGameApi, null, innerToken) .ConfigureAwait(false); @@ -375,7 +375,16 @@ private Task LoadLauncherGameInfo(ActionOnTimeOutRetry? onTimeoutRoutine, ExecutionTimeoutStep, ExecutionTimeoutAttempt, onTimeoutRoutine, - result => LauncherGameSophonBranches = result, + result => + { + LauncherGetGame = result; + if (result?.Data?.TryFindByBizOrId(PresetConfig?.LauncherBizName, + PresetConfig?.LauncherGameId, + out HypGameInfoData? gameInfoData) ?? false) + { + LauncherGameInfoField = gameInfoData; + } + }, token); } #endregion diff --git a/CollapseLauncher/Classes/Helper/LauncherApiLoader/HoYoPlay/HypLauncherGameResourcePackageApi.cs b/CollapseLauncher/Classes/Helper/LauncherApiLoader/HoYoPlay/HypLauncherGameResourcePackageApi.cs index 049bba709a..c4f8c505de 100644 --- a/CollapseLauncher/Classes/Helper/LauncherApiLoader/HoYoPlay/HypLauncherGameResourcePackageApi.cs +++ b/CollapseLauncher/Classes/Helper/LauncherApiLoader/HoYoPlay/HypLauncherGameResourcePackageApi.cs @@ -69,10 +69,7 @@ public class HypPackageData public byte[]? PackageMD5Hash { get; init; } [JsonIgnore] - public string? PackageMD5HashString - { - get => field ??= HexTool.BytesToHexUnsafe(PackageMD5Hash); - } + public string? PackageMD5HashString => field ??= HexTool.BytesToHexUnsafe(PackageMD5Hash); [JsonPropertyName("size")] [JsonNumberHandling(JsonNumberHandling.AllowReadingFromString)] diff --git a/CollapseLauncher/Classes/Helper/Metadata/DataCooker.cs b/CollapseLauncher/Classes/Helper/Metadata/DataCooker.cs index bd519bfc2c..ba50fd1f71 100644 --- a/CollapseLauncher/Classes/Helper/Metadata/DataCooker.cs +++ b/CollapseLauncher/Classes/Helper/Metadata/DataCooker.cs @@ -4,6 +4,7 @@ using System.Buffers.Text; using System.IO; using System.IO.Compression; +using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using System.Security.Cryptography; using System.Text; @@ -12,7 +13,11 @@ // ReSharper disable IdentifierTypo // ReSharper disable UnusedMember.Global // ReSharper disable StringLiteralTypo +#if NET11_0_OR_GREATER +using ZstdDecompressStream = System.IO.Compression.ZstandardStream; +#else using ZstdDecompressStream = ZstdNet.DecompressionStream; +#endif #pragma warning disable IDE0130 #nullable enable @@ -179,10 +184,10 @@ internal static void ServeV3Data(ReadOnlySpan data, throw new FormatException($"Decompression format is not supported! ({compressionType})"); } - #if DEBUG +#if DEBUG Logger.LogWriteLine($"[DataCooker::ServeV3Data()] Loaded ServeV3 data [IsPooled: {isDecryptPoolUsed}][TCompress: {compressionType} | IsEncrypt: {isUseEncryption}][CompSize: {compressedSize} | UncompSize: {decompressedSize}]", LogType.Debug, true); - #endif +#endif } finally { @@ -214,28 +219,31 @@ private static int DecompressDataFromBrotli(Span outData, int compressedSi private static unsafe int DecompressDataFromZstd(Span outData, int decompressedSize, ReadOnlySpan dataRawBuffer) { - fixed (byte* inputBuffer = &dataRawBuffer[0]) - fixed (byte* outputBuffer = &outData[0]) - { - int decompressedWritten = 0; + byte* inputBuffer = (byte*)Unsafe.AsPointer(ref MemoryMarshal.GetReference(dataRawBuffer)); + byte* outputBuffer = (byte*)Unsafe.AsPointer(ref MemoryMarshal.GetReference(outData)); + int decompressedWritten = 0; - byte[] buffer = new byte[4 << 10]; + Span buffer = stackalloc byte[1 << 10]; - using UnmanagedMemoryStream inputStream = new(inputBuffer, dataRawBuffer.Length); - using UnmanagedMemoryStream outputStream = new(outputBuffer, outData.Length); - using ZstdDecompressStream decompStream = new(inputStream); + using UnmanagedMemoryStream inputStream = new(inputBuffer, dataRawBuffer.Length); + using UnmanagedMemoryStream outputStream = new(outputBuffer, outData.Length); - int read; - while ((read = decompStream.Read(buffer)) > 0) - { - outputStream.Write(buffer, 0, read); - decompressedWritten += read; - } +#if NET11_0_OR_GREATER + using ZstdDecompressStream decompStream = new(inputStream, CompressionMode.Decompress); +#else + using ZstdDecompressStream decompStream = new(inputStream); +#endif - return decompressedSize != decompressedWritten - ? throw new DataMisalignedException("Decompressed data is misaligned!") - : decompressedWritten; - } + int read; + while ((read = decompStream.Read(buffer)) > 0) + { + outputStream.Write(buffer[..read]); + decompressedWritten += read; + } + + return decompressedSize != decompressedWritten + ? throw new DataMisalignedException("Decompressed data is misaligned!") + : decompressedWritten; } } } \ No newline at end of file diff --git a/CollapseLauncher/Classes/Helper/PatternMatcher.cs b/CollapseLauncher/Classes/Helper/PatternMatcher.cs index cad29ade89..f653f6784e 100644 --- a/CollapseLauncher/Classes/Helper/PatternMatcher.cs +++ b/CollapseLauncher/Classes/Helper/PatternMatcher.cs @@ -8,7 +8,7 @@ namespace CollapseLauncher.Helper { - public static class PatternMatcher + public static partial class PatternMatcher { /// /// Determines whether the specified input string matches the given pattern. @@ -132,5 +132,10 @@ public static string MergeRegexPattern(params ReadOnlySpan regexPatterns return builder.ToString(); } + + [GeneratedRegex(@"\.[0-9][0-9][0-9]$", RegexOptions.NonBacktracking)] + public static partial Regex MatchChunkFilePath(); + + public static bool IsChunkedFilePath(this string filePath) => MatchChunkFilePath().IsMatch(filePath); } } \ No newline at end of file diff --git a/CollapseLauncher/Classes/Helper/StreamUtility/StreamExtension.cs b/CollapseLauncher/Classes/Helper/StreamUtility/StreamExtension.cs index 90df5161d7..33df15f4ba 100644 --- a/CollapseLauncher/Classes/Helper/StreamUtility/StreamExtension.cs +++ b/CollapseLauncher/Classes/Helper/StreamUtility/StreamExtension.cs @@ -3,9 +3,12 @@ using Hi3Helper.SentryHelper; using Hi3Helper.Win32.ManagedTools; using System; +using System.Buffers; using System.Collections.Generic; using System.IO; using System.Linq; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; @@ -151,6 +154,9 @@ internal static FileInfo EnsureCreationOfDirectory(this FileInfo filePath) } } + internal static bool TryDeleteFile(string filePath, bool throwIfFailed = false) + => new FileInfo(filePath).TryDeleteFile(throwIfFailed); + internal static bool TryDeleteFile(this FileInfo filePath, bool throwIfFailed = false) { try @@ -389,5 +395,123 @@ public static bool IsPathLocal(this ReadOnlySpan path) path[1] == ':' && path[2] is '\\' or '/'; } + + extension(Stream stream) + { + [SkipLocalsInit] + internal void Write(T source) + where T : unmanaged + { + ReadOnlySpan asSpan = MemoryMarshal.AsBytes(new ReadOnlySpan(in source)); + stream.Write(asSpan); + } + + [SkipLocalsInit] + internal unsafe void WriteArray(ReadOnlySpan source) + where T : unmanaged + { + ReadOnlySpan dataAsBytes = MemoryMarshal.AsBytes(source); + ushort sizeOfElement = (ushort)sizeof(T); + + stream.Write(sizeOfElement); + stream.Write(dataAsBytes.Length); + stream.Write(dataAsBytes); + } + + [SkipLocalsInit] + internal unsafe T Read() + where T : unmanaged + { + T result = default; + Span buffer = MemoryMarshal.AsBytes(new Span(ref result)); + + int read = stream.Read(buffer); + return read != sizeof(T) + ? throw new EndOfStreamException("Stream has been reaching end-of-data") + : result; + } + + [SkipLocalsInit] + internal unsafe T[] ReadArray() + where T : unmanaged + { + int sizeOfElement = stream.Read(); + int lengthInBytes = stream.Read(); + + return sizeOfElement != sizeof(T) + ? throw new InvalidCastException($"Data type size unmatched! Expecting: {sizeOfElement} bytes, but requesting ({typeof(T).Name}): {sizeof(T)} bytes instead!") + : stream.ReadArray(lengthInBytes / sizeOfElement); + } + + internal unsafe T[] ReadArray(int elementCount) + where T : unmanaged + { + T[] array = GC.AllocateUninitializedArray(elementCount); + Span arrayAsBytes = MemoryMarshal.AsBytes(array.AsSpan()); + int length = arrayAsBytes.Length; + + int offsetRead = 0; + while (!arrayAsBytes.IsEmpty) + { + int read = stream.Read(arrayAsBytes = arrayAsBytes.Slice(offsetRead, length - offsetRead)); + offsetRead += read; + + if (read == 0) + throw new EndOfStreamException($"Stream is reaching end-of-data. Expecting: {length} bytes, but got: {offsetRead} bytes instead"); + } + + return array; + } + + internal void SeekTo(int bytesToAdvance) + { + if (bytesToAdvance == 0) + return; + + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(bytesToAdvance); + + // Seek the position if supported. + if (stream.CanSeek) + { + stream.Position += bytesToAdvance; + return; + } + + // Otherwise, dummy read the data. + byte[] buffer = ArrayPool.Shared.Rent(4 << 10); + int read; + + while ((read = stream.Read(buffer.AsSpan(0, Math.Min(buffer.Length, bytesToAdvance)))) > 0) + { + bytesToAdvance -= read; + } + } + + internal async ValueTask SeekToAsync( + int bytesToAdvance, + CancellationToken token) + { + if (bytesToAdvance == 0) + return; + + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(bytesToAdvance); + + // Seek the position if supported. + if (stream.CanSeek) + { + stream.Position += bytesToAdvance; + return; + } + + // Otherwise, dummy read the data. + byte[] buffer = ArrayPool.Shared.Rent(4 << 10); + int read; + + while ((read = await stream.ReadAsync(buffer.AsMemory(0, Math.Min(buffer.Length, bytesToAdvance)), token)) > 0) + { + bytesToAdvance -= read; + } + } + } } } diff --git a/CollapseLauncher/Classes/Helper/StringUtility.cs b/CollapseLauncher/Classes/Helper/StringUtility.cs new file mode 100644 index 0000000000..39a148afad --- /dev/null +++ b/CollapseLauncher/Classes/Helper/StringUtility.cs @@ -0,0 +1,216 @@ +using System; +using System.Buffers; +using System.Buffers.Text; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Runtime.CompilerServices; +using System.Text; +#pragma warning disable IDE0130 + +// ReSharper disable ConvertIfStatementToSwitchStatement + +#nullable enable +namespace CollapseLauncher.Helper; + +public static class StringUtility +{ + public static bool TryGetStreamFromUrlDataString( + ReadOnlySpan originalUriString, + out string? mimeType, + [NotNullWhen(true)] out MemoryStream? stream) + { + const string dataScheme = "data:"; + + Unsafe.SkipInit(out mimeType); + Unsafe.SkipInit(out stream); + + if (originalUriString.IsEmpty) + { + return false; + } + + // -- Check if data is entirely a raw base64 string + if (TryGetStreamFromPureBase64String(originalUriString, out stream)) + { + return true; + } + + // -- Check if data is entirely a hex string + if (TryGetStreamFromPureHexString(originalUriString, out stream)) + { + return true; + } + + // -- Check for data scheme + int indexOfDataToken = originalUriString.IndexOf(dataScheme); + if (indexOfDataToken == -1) + { + return false; + } + + // -- Check for data section + int indexOfDataSection = originalUriString.IndexOf(','); + if (indexOfDataSection == -1) + { + return false; + } + + // -- Try to get data MIME type + ReadOnlySpan mimeToken = originalUriString[dataScheme.Length..indexOfDataSection]; + if (!mimeToken.IsEmpty) + { + mimeType = new string(mimeToken); + } + + // -- Try to decode the data from base64 string + ReadOnlySpan dataSpan = originalUriString[indexOfDataSection..]; + return TryGetStreamFromPureBase64String(dataSpan, out stream) || + // -- The data is probably a hex string? + TryGetStreamFromPureHexString(dataSpan, out stream) || + // -- uhm, probably escaped URL-encoded string? if all these fails, return false. + TryGetStreamFromEscapedUrlString(dataSpan, out stream); + } + + private static bool TryGetStreamFromPureBase64String( + ReadOnlySpan span, + [NotNullWhen(true)] out MemoryStream? stream) + { + Unsafe.SkipInit(out stream); + + int bufferLen = Base64Url.GetMaxDecodedLength(span.Length); + byte[]? buffer; + + // -- Allocate test buffer first before allocating real buffer + int testBufferLen = Math.Min(1 << 10, span.Length); + Span bufferSpan = stackalloc byte[testBufferLen]; + + // -- Try to decode the first with test buffer first. + // If succeeded, then do hot-path copy and allocate stream. + OperationStatus operation = Base64Url.DecodeFromChars(span, bufferSpan, out int offset, out int written); + if (operation == OperationStatus.Done) + { + buffer = GC.AllocateUninitializedArray(written); + bufferSpan[..written].CopyTo(buffer); // Copy stack to heap buffer + + stream = new MemoryStream(buffer, 0, written); + return true; + } + + // -- If failure happened, return false + if (operation is OperationStatus.InvalidData or OperationStatus.NeedMoreData) + { + return false; + } + + // -- If more data need to be processed, continue. + // But first, copy the previous first test stack buffer to heap buffer. + buffer = GC.AllocateUninitializedArray(bufferLen); + bufferSpan[..written].CopyTo(buffer); + + // -- Set buffer span and source span, then continue decoding the rest of the data. + bufferSpan = buffer.AsSpan(written); + span = span[offset..]; + + operation = Base64Url.DecodeFromChars(span, bufferSpan, out _, out int writtenNext); + if (operation != OperationStatus.Done) + { + // :/ We wasted some memory due to failure on decoding. + // Though, return false. + return false; + } + + // -- Then, get the memory stream instance. + written += writtenNext; // Advance written bytes. + stream = new MemoryStream(buffer, 0, written); + return true; + } + + private static bool TryGetStreamFromEscapedUrlString( + ReadOnlySpan span, + [NotNullWhen(true)] out MemoryStream? stream) + { + Unsafe.SkipInit(out stream); + + char[] bufferRent = ArrayPool.Shared.Rent(span.Length); + try + { + if (!Uri.TryUnescapeDataString(span, bufferRent, out int unescapeWritten)) + { + return false; + } + + Span bufferRentSpan = bufferRent.AsSpan(0, unescapeWritten); + + int utf8BufferLen = Encoding.UTF8.GetByteCount(bufferRentSpan); + byte[] utf8Buffer = GC.AllocateUninitializedArray(utf8BufferLen); + + int utf8Written = Encoding.UTF8.GetBytes(bufferRentSpan, utf8Buffer); + stream = new MemoryStream(utf8Buffer, 0, utf8Written); + return true; + } + finally + { + ArrayPool.Shared.Return(bufferRent); + } + } + + private static bool TryGetStreamFromPureHexString( + ReadOnlySpan span, + [NotNullWhen(true)] out MemoryStream? stream) + { + Unsafe.SkipInit(out stream); + + span = span.Trim(); // Trim possible spaces + if (span.Length % 2 != 0) // Not even? definitely invalid. + { + return false; + } + + int bufferLen = span.Length / 2; + byte[]? buffer; + + // -- Allocate test buffer first before allocating real buffer + int testBufferLen = Math.Min(1 << 10, bufferLen); + Span bufferSpan = stackalloc byte[testBufferLen]; + + // -- Try to decode the first with test buffer first. + // If succeeded, then do hot-path copy and allocate stream. + OperationStatus operation = Convert.FromHexString(span, bufferSpan, out int offset, out int written); + if (operation == OperationStatus.Done) + { + buffer = GC.AllocateUninitializedArray(written); + bufferSpan[..written].CopyTo(buffer); // Copy stack to heap buffer + + stream = new MemoryStream(buffer, 0, written); + return true; + } + + // -- If failure happened, return false + if (operation is OperationStatus.InvalidData or OperationStatus.NeedMoreData) + { + return false; + } + + // -- If more data need to be processed, continue. + // But first, copy the previous first test stack buffer to heap buffer. + buffer = GC.AllocateUninitializedArray(bufferLen); + bufferSpan[..written].CopyTo(buffer); + + // -- Set buffer span and source span, then continue decoding the rest of the data. + bufferSpan = buffer.AsSpan(written); + span = span[offset..]; + + operation = Convert.FromHexString(span, bufferSpan, out _, out int writtenNext); + if (operation != OperationStatus.Done) + { + // :/ We wasted some memory due to failure on decoding. + // Though, return false. + return false; + } + + // -- Then, get the memory stream instance. + written += writtenNext; // Advance written bytes. + stream = new MemoryStream(buffer, 0, written); + return true; + } +} diff --git a/CollapseLauncher/Classes/Helper/TaskSchedulerHelper.cs b/CollapseLauncher/Classes/Helper/TaskSchedulerHelper.cs index 188feb099b..8eb5ac137f 100644 --- a/CollapseLauncher/Classes/Helper/TaskSchedulerHelper.cs +++ b/CollapseLauncher/Classes/Helper/TaskSchedulerHelper.cs @@ -2,58 +2,38 @@ using Hi3Helper; using Hi3Helper.SentryHelper; using Hi3Helper.Shared.Region; +using Hi3Helper.TaskScheduler; using Hi3Helper.Win32.ShellLinkCOM; using System; using System.Diagnostics; using System.IO; -using System.Text; // ReSharper disable CommentTypo // ReSharper disable StringLiteralTypo // ReSharper disable GrammarMistakeInComment #nullable enable -namespace CollapseLauncher.Helper -{ - internal static class TaskSchedulerHelper - { - private const string CollapseStartupTaskName = "CollapseLauncherStartupTask"; - - private static bool _isInitialized; - private static bool _cachedIsOnTrayEnabled; - private static bool _cachedIsEnabled; - - internal static bool IsOnTrayEnabled() - { - if (!_isInitialized) - InvokeGetStatusCommand(); +namespace CollapseLauncher.Helper; - return _cachedIsOnTrayEnabled; - } +internal static class TaskSchedulerHelper +{ + private static readonly string StubLocation = VelopackLocatorExtension.FindCollapseStubPath(); + private const string CollapseStartupTaskName = "CollapseLauncherStartupTask"; - internal static bool IsEnabled() - { - if (!_isInitialized) - InvokeGetStatusCommand(); + private static bool _cachedIsOnTrayEnabled; + private static bool _cachedIsEnabled; - return _cachedIsEnabled; - } + internal static bool IsOnTrayEnabled() + { + IsEnabled(); + return _cachedIsOnTrayEnabled; + } - private static void InvokeGetStatusCommand() + internal static bool IsEnabled() + { + try { - // Build the argument and mode to set - var argumentBuilder = new StringBuilder(); - argumentBuilder.Append("IsEnabled"); - - // Append task name and stub path - AppendTaskNameAndPathArgument(argumentBuilder); - - // Store argument builder as string - var argumentString = argumentBuilder.ToString(); - - // Invoke command and get return code - var returnCode = GetInvokeCommandReturnCode(argumentString); - + int returnCode = TaskSchedulerUtil.IsEnabled(CollapseStartupTaskName, StubLocation); (_cachedIsEnabled, _cachedIsOnTrayEnabled) = returnCode switch { // -1 means task is disabled with tray enabled @@ -65,233 +45,131 @@ private static void InvokeGetStatusCommand() // 2 means task is enabled with tray enabled 2 => (true, true), // Otherwise, return both disabled (due to failure) - _ => (false, false) + _ => (false, false) }; - // Print init determination - CheckInitDetermination(returnCode); - } - - private static void CheckInitDetermination(int returnCode) - { - // If the return code is within range, then set as initialized - if (returnCode is > -2 and < 3) - { - // Set as initialized - _isInitialized = true; - } - // Otherwise, log the return code - else - { - string reason = returnCode switch - { - int.MaxValue => "ARGUMENT_INVALID", - int.MinValue => "UNHANDLED_ERROR", - short.MaxValue => "INTERNALINVOKE_ERROR", - short.MinValue => "APPLET_NOTFOUND", - _ => $"UNKNOWN_{returnCode}" - }; - Logger.LogWriteLine($"Error while getting task status from applet with reason: {reason}", LogType.Error, true); - } - } - - internal static void ToggleTrayEnabled(bool isEnabled) - { - _cachedIsOnTrayEnabled = isEnabled; - InvokeToggleCommand(); + return _cachedIsEnabled; } - - internal static void ToggleEnabled(bool isEnabled) + catch (Exception ex) { - _cachedIsEnabled = isEnabled; - InvokeToggleCommand(); + Logger.LogWriteLine($"An error occurred while trying to check TaskSchedulerUtil.IsEnabled\r\n{ex}", + LogType.Error, + true); + SentryHelper.ExceptionHandler(ex); + return false; } + } - private static void InvokeToggleCommand() - { - // Build the argument and mode to set - StringBuilder argumentBuilder = new StringBuilder(); - argumentBuilder.Append(_cachedIsEnabled ? "Enable" : "Disable"); - - // Append argument whether to toggle the tray or not - if (_cachedIsOnTrayEnabled) - argumentBuilder.Append("ToTray"); - - // Append task name and stub path - AppendTaskNameAndPathArgument(argumentBuilder); - - // Store argument builder as string - string argumentString = argumentBuilder.ToString(); - - // Invoke applet - int returnCode = GetInvokeCommandReturnCode(argumentString); + internal static void ToggleTrayEnabled(bool isEnabled) + { + _cachedIsOnTrayEnabled = isEnabled; + ToggleCore(); + } - // Print init determination - CheckInitDetermination(returnCode); - } + internal static void ToggleEnabled(bool isEnabled) + { + _cachedIsEnabled = isEnabled; + ToggleCore(); + } - private static void AppendTaskNameAndPathArgument(StringBuilder argumentBuilder) + private static void ToggleCore() + { + try { - // Get current stub or main executable path - string currentExecPath = VelopackLocatorExtension.FindCollapseStubPath(); - - // Build argument to the task name - argumentBuilder.Append(" \""); - argumentBuilder.Append(CollapseStartupTaskName); - argumentBuilder.Append('"'); - - // Build argument to the executable path - argumentBuilder.Append(" \""); - argumentBuilder.Append(currentExecPath); - argumentBuilder.Append('"'); + TaskSchedulerUtil.ToggleTask(_cachedIsEnabled, _cachedIsOnTrayEnabled, CollapseStartupTaskName, StubLocation); } - - internal static void RecreateIconShortcuts() + catch (Exception ex) { - // Get icons paths - (string iconLocationStartMenu, string iconLocationDesktop) - = GetIconLocationPaths( - out _, - out string? appDescription, - out string? executablePath, - out string? workingDirPath); - - // Create shell link instance and save the shortcut under Desktop and User's Start menu - CreateShortcut(iconLocationStartMenu, appDescription, executablePath, workingDirPath); - CreateShortcut(iconLocationDesktop, appDescription, executablePath, workingDirPath); + Logger.LogWriteLine($"An error occurred while trying to toggle Task Scheduler Task using TaskSchedulerUtil.ToggleTask\r\n{ex}", + LogType.Error, + true); + SentryHelper.ExceptionHandler(ex); } + } - private static void CreateShortcut( - string iconLocation, - string? appDescription, - string? executablePath, - string? workingDirPath) - { - // Try create icon location directory - string iconLocationDir = Path.GetDirectoryName(iconLocation) ?? ""; - - // Try create directory - Directory.CreateDirectory(iconLocationDir); - - // Create ShellLink instance - ShellLink shellLink = new(); + internal static void RecreateIconShortcuts() + { + // Get icons paths + (string iconLocationStartMenu, string iconLocationDesktop) + = GetIconLocationPaths( + out _, + out string? appDescription, + out string? executablePath, + out string? workingDirPath); + + // Create shell link instance and save the shortcut under Desktop and User's Start menu + CreateShortcut(iconLocationStartMenu, appDescription, executablePath, workingDirPath); + CreateShortcut(iconLocationDesktop, appDescription, executablePath, workingDirPath); + } - // If existing icon exist, try open it - try - { - if (File.Exists(iconLocation)) - shellLink.Open(iconLocation); - } - catch (Exception ex) - { - string msg = $"An error occurred while opening existing icon file at: {iconLocation}"; - SentryHelper.ExceptionHandler(new Exception(msg, ex)); - Logger.LogWriteLine(msg + $"\r\n{ex}", LogType.Error, true); - } - - // Set params on the shortcut instance - shellLink.IconIndex = 0; - shellLink.IconPath = executablePath ?? ""; - shellLink.DisplayMode = LinkDisplayMode.edmNormal; - shellLink.WorkingDirectory = workingDirPath ?? ""; - shellLink.Target = executablePath ?? ""; - shellLink.Description = appDescription ?? ""; + private static void CreateShortcut( + string iconLocation, + string? appDescription, + string? executablePath, + string? workingDirPath) + { + // Try create icon location directory + string iconLocationDir = Path.GetDirectoryName(iconLocation) ?? ""; - // Save the icons - shellLink.Save(iconLocation); - } + // Try create directory + Directory.CreateDirectory(iconLocationDir); + + // Create ShellLink instance + ShellLink shellLink = new(); - internal static (string IconStartMenu, string IconDesktop) GetIconLocationPaths( - out string? appProductName, - out string? appDescription, - out string? executablePath, - out string? workingDirPath) + // If existing icon exist, try open it + try { - // Get current executable path as its target. - executablePath = LauncherConfig.AppExecutablePath; - workingDirPath = Path.GetDirectoryName(executablePath); - - // Get exe's description - FileVersionInfo currentExecVersionInfo = FileVersionInfo.GetVersionInfo(executablePath); - appDescription = currentExecVersionInfo.FileDescription ?? ""; - - // Get paths - appProductName = currentExecVersionInfo.ProductName; - string shortcutFilename = appProductName + ".lnk"; - string startMenuLocation = Environment.GetFolderPath(Environment.SpecialFolder.CommonStartMenu); - string desktopLocation = Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory); - string iconLocationStartMenu = Path.Combine( - startMenuLocation, - "Programs", - currentExecVersionInfo.CompanyName ?? "", - shortcutFilename); - string iconLocationDesktop = Path.Combine( - desktopLocation, - shortcutFilename); - - return (iconLocationStartMenu, iconLocationDesktop); + if (File.Exists(iconLocation)) + shellLink.Open(iconLocation); } - - private static int GetInvokeCommandReturnCode(string argument) + catch (Exception ex) { - const string retValMark = "RETURNVAL_"; - - // Get the applet path and check if the file exist - string appletPath = Path.Combine(LauncherConfig.AppExecutableDir, "Lib", "win-x64", "Hi3Helper.TaskScheduler.exe"); - if (!File.Exists(appletPath)) - { - Logger.LogWriteLine($"Task Scheduler Applet does not exist in this path: {appletPath}", LogType.Error, true); - return short.MinValue; - } - - // Try to make process instance for the applet - using Process process = new Process(); - process.StartInfo = new ProcessStartInfo - { - FileName = appletPath, - Arguments = argument, - UseShellExecute = false, - RedirectStandardOutput = true, - CreateNoWindow = true - }; - -#if DEBUG - Logger.LogWriteLine("[TaskSchedulerHelper] Running TaskSchedulerHelper with command:\r\n" + appletPath + " " + argument, LogType.Debug, true); -#endif - - int lastErrCode = short.MaxValue; - try - { - // Start the applet and wait until it exit. - process.Start(); - while (process.StandardOutput.ReadLine() is {} consoleStdOut) - { - Logger.LogWriteLine("[TaskScheduler] " + consoleStdOut, LogType.Debug, true); - - // Parse if it has RETURNVAL_ - if (!consoleStdOut.StartsWith(retValMark)) - { - continue; - } - - ReadOnlySpan span = consoleStdOut.AsSpan(retValMark.Length); - if (int.TryParse(span, null, out int resultReturnCode)) - { - lastErrCode = resultReturnCode; - } - } - process.WaitForExit(); - } - catch (Exception ex) - { - // If error happened, then return. - SentryHelper.ExceptionHandler(ex, SentryHelper.ExceptionType.UnhandledOther); - Logger.LogWriteLine($"An error has occurred while invoking Task Scheduler applet!\r\n{ex}", LogType.Error, true); - return short.MaxValue; - } - - // Get return code - return lastErrCode; + string msg = $"An error occurred while opening existing icon file at: {iconLocation}"; + SentryHelper.ExceptionHandler(new Exception(msg, ex)); + Logger.LogWriteLine(msg + $"\r\n{ex}", LogType.Error, true); } + + // Set params on the shortcut instance + shellLink.IconIndex = 0; + shellLink.IconPath = executablePath ?? ""; + shellLink.DisplayMode = LinkDisplayMode.edmNormal; + shellLink.WorkingDirectory = workingDirPath ?? ""; + shellLink.Target = executablePath ?? ""; + shellLink.Description = appDescription ?? ""; + + // Save the icons + shellLink.Save(iconLocation); + } + + internal static (string IconStartMenu, string IconDesktop) GetIconLocationPaths( + out string? appProductName, + out string? appDescription, + out string? executablePath, + out string? workingDirPath) + { + // Get current executable path as its target. + executablePath = LauncherConfig.AppExecutablePath; + workingDirPath = Path.GetDirectoryName(executablePath); + + // Get exe's description + FileVersionInfo currentExecVersionInfo = FileVersionInfo.GetVersionInfo(executablePath); + appDescription = currentExecVersionInfo.FileDescription ?? ""; + + // Get paths + appProductName = currentExecVersionInfo.ProductName; + string shortcutFilename = appProductName + ".lnk"; + string startMenuLocation = Environment.GetFolderPath(Environment.SpecialFolder.CommonStartMenu); + string desktopLocation = Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory); + string iconLocationStartMenu = Path.Combine( + startMenuLocation, + "Programs", + currentExecVersionInfo.CompanyName ?? "", + shortcutFilename); + string iconLocationDesktop = Path.Combine( + desktopLocation, + shortcutFilename); + + return (iconLocationStartMenu, iconLocationDesktop); } } diff --git a/CollapseLauncher/Classes/Helper/WindowUtility.cs b/CollapseLauncher/Classes/Helper/WindowUtility.cs index 4a09ee5824..72c72197c6 100644 --- a/CollapseLauncher/Classes/Helper/WindowUtility.cs +++ b/CollapseLauncher/Classes/Helper/WindowUtility.cs @@ -14,6 +14,7 @@ using Hi3Helper.Win32.WinRT.ToastCOM; using Hi3Helper.Win32.WinRT.ToastCOM.Notification; using Microsoft.Extensions.Logging; +using Microsoft.Graphics.Canvas; using Microsoft.Graphics.Display; using Microsoft.UI; using Microsoft.UI.Composition.SystemBackdrops; @@ -25,8 +26,11 @@ using System; using System.Collections.Generic; using System.IO; +using System.Runtime; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; using Windows.Graphics; using Windows.UI; using WinRT.Interop; @@ -152,7 +156,7 @@ internal static bool CurrentWindowIsVisible get { WS_STYLE windowStyle = PInvoke.GetWindowLong(CurrentWindowPtr, GWL_INDEX.GWL_STYLE); - bool isHide = windowStyle.HasFlag(WS_STYLE.WS_MINIMIZE); + bool isHide = windowStyle.HasFlag(WS_STYLE.WS_MINIMIZE) || !windowStyle.HasFlag(WS_STYLE.WS_VISIBLE); return !isHide; } } @@ -598,12 +602,14 @@ private static nint MainWndProc(nint hwnd, uint msg, nuint wParam, nint lParam) { ImageBackgroundManager.Shared.SetWindowMinimizeEvent(); InnerLauncherConfig.m_homePage?.StopCarouselSlideshow(); + ToggleDeferVisibility(Visibility.Collapsed, FlushSharedCanvasDevice); break; } case SC_RESTORE: { ImageBackgroundManager.Shared.SetWindowRestoreEvent(); InnerLauncherConfig.m_homePage?.StartCarouselSlideshow(); + ToggleDeferVisibility(Visibility.Visible); break; } } @@ -614,11 +620,15 @@ private static nint MainWndProc(nint hwnd, uint msg, nuint wParam, nint lParam) { if (wParam == 0) { + ImageBackgroundManager.Shared.SetWindowMinimizeEvent(); InnerLauncherConfig.m_homePage?.StopCarouselSlideshow(); + ToggleDeferVisibility(Visibility.Collapsed, FlushSharedCanvasDevice); } else { + ImageBackgroundManager.Shared.SetWindowRestoreEvent(); InnerLauncherConfig.m_homePage?.StartCarouselSlideshow(); + ToggleDeferVisibility(Visibility.Visible); } break; } @@ -709,8 +719,84 @@ private static nint MainWndProc(nint hwnd, uint msg, nuint wParam, nint lParam) } return PInvoke.CallWindowProc(_oldMainWndProcPtr, hwnd, msg, wParam, lParam); + + static void FlushSharedCanvasDevice() + { + CanvasDevice sharedDevice = CanvasDevice.GetSharedDevice(); + sharedDevice.Trim(); + } + + static void ToggleDeferVisibility(Visibility visibility, Action? runActionBeforeGC = null) + { + if (CurrentWindow.IsObjectDisposed() || + CurrentWindow is not { } currentWindow) + { + return; + } + + ref SystemBackdrop? lastBackdrop = + ref CollectionsMarshal.GetValueRefOrAddDefault(_windowBackdrops, currentWindow.GetHashCode(), out _); + + currentWindow.SystemBackdrop = visibility == Visibility.Collapsed ? null : lastBackdrop; + currentWindow.Content.Visibility = visibility; + + CancellationTokenSource newCts = new(); + CancellationTokenSource? oldCts = Interlocked.Exchange(ref _gcJobMinimizedCts, newCts); + oldCts?.Cancel(); + oldCts?.Dispose(); + + if (visibility == Visibility.Collapsed) + { + // Run GC collection task in the background for 300 seconds approx. + // This however shouldn't bother any functionality of the launcher as the task will be cancelled + // immediately as the token is renewed and cancelled. + StartAggressiveGCCollectTask(10, 30, runActionBeforeGC, newCts.Token); + } + } + + static async void StartAggressiveGCCollectTask(double delayIntervalSec, int attempt, Action? runActionBeforeGC = null, CancellationToken token = default) + { + try + { + int attemptT = attempt; + while (--attempt >= 0) + { + if (token.IsCancellationRequested) + { + return; + } + + await Task.Delay(TimeSpan.FromSeconds(delayIntervalSec), token); + runActionBeforeGC?.Invoke(); + + GC.Collect(GC.MaxGeneration, + GCCollectionMode.Forced, + blocking: true, + compacting: true); + + GC.WaitForPendingFinalizers(); + } + + Logger.LogWriteLine($"[StartAggressiveGCCollectTask] Background GC Collection has been finished executing in: {attemptT * delayIntervalSec} seconds", + LogType.Info, + true); + } + catch (OperationCanceledException) + { + Logger.LogWriteLine("[StartAggressiveGCCollectTask] Background GC Collection Task was cancelled.", + LogType.Warning, + true); + } + catch (Exception ex) + { + Logger.LogWriteLine($"[StartAggressiveGCCollectTask] {ex}", LogType.Error, true); + } + } } + private static readonly Dictionary _windowBackdrops = []; + private static CancellationTokenSource? _gcJobMinimizedCts; + #endregion #region Titlebar Methods diff --git a/CollapseLauncher/Classes/InstallManagement/Base/GameInstallPackage.cs b/CollapseLauncher/Classes/InstallManagement/Base/GameInstallPackage.cs index 1e2447e69d..f016f73bb2 100644 --- a/CollapseLauncher/Classes/InstallManagement/Base/GameInstallPackage.cs +++ b/CollapseLauncher/Classes/InstallManagement/Base/GameInstallPackage.cs @@ -3,11 +3,11 @@ using Hi3Helper; using Hi3Helper.Data; using Hi3Helper.EncTool; -using Hi3Helper.Http.Legacy; using Hi3Helper.Plugin.Core.Management; using Hi3Helper.Preset; using Hi3Helper.SentryHelper; using System; +using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; @@ -20,21 +20,23 @@ namespace CollapseLauncher.InstallManager internal class GameInstallPackage : IAssetIndexSummary { #region Properties - public string URL { get; private set; } - public string DecompressedURL { get; set; } - public string Name { get; } - public string PathOutput { get; } - public GameInstallPackageType PackageType { get; init; } - public long Size { get; set; } - public long SizeRequired { get; } - public long SizeDownloaded { get; set; } - public GameVersion Version { get; set; } - public byte[] Hash { get; } - public string HashString { get => HexTool.BytesToHexUnsafe(Hash); } - public string LanguageID { get; init; } - public string RunCommand { get; } - public string PluginId { get; } - public bool IsUseLegacyDownloader { get; set; } + public string URL { get; private set; } + public string DecompressedURL { get; set; } + public string Name { get; init; } + public string PathOutput { get; init; } + public GameInstallPackageType PackageType { get; init; } + public long Size { get; set; } + public long SizeRequired { get; init; } + public long SizeDownloaded { get; set; } + public GameVersion Version { get; set; } + public byte[] Hash { get; init; } + public string HashString { get => field ??= HexTool.BytesToHexUnsafe(Hash); } + public string LanguageID { get; init; } + public string RunCommand { get; init; } + public string PluginId { get; init; } + public bool IsUseLegacyDownloader { get; set; } + public List ChunkList { get; set; } = []; + public object SourceObject { get; set; } #endregion public GameInstallPackage(HypChannelSdkData packageProperty, @@ -45,6 +47,8 @@ public GameInstallPackage(HypChannelSdkData packageProperty, ArgumentNullException.ThrowIfNull(packageProperty.SdkPackageDetail); ArgumentException.ThrowIfNullOrEmpty(pathOutput); + SourceObject = packageProperty; + PluginId = "sdk"; RunCommand = packageProperty.SdkPackageDetail.PackageRunCommand; Version = packageProperty.Version; @@ -77,6 +81,8 @@ public GameInstallPackage(HypPluginPackageInfo packageProperty, ArgumentNullException.ThrowIfNull(packageProperty.PluginPackage); ArgumentException.ThrowIfNullOrEmpty(pathOutput); + SourceObject = packageProperty; + PluginId = packageProperty.PluginId; RunCommand = packageProperty.PluginPackage.PackageRunCommand; Version = packageProperty.Version; @@ -106,6 +112,8 @@ public GameInstallPackage(HypPackageData packageProperty, string uncompressedUrl = null, GameVersion version = default) { + SourceObject = packageProperty; + if (packageProperty == null || pathOutput == null) throw new NullReferenceException(); if (packageProperty.FilePath != null) @@ -132,177 +140,123 @@ public GameInstallPackage(HypPackageData packageProperty, } } - public bool IsReadStreamExist(int count) - { - if (PathOutput == null) return false; - // Check if the single file exist or not - FileInfo fileInfo = new FileInfo(PathOutput); - if (fileInfo.Exists) - return true; + private GameInstallPackage() { } - // Check for the chunk files - return Enumerable.Range(0, count).All(chunkID => + public GameInstallPackage Clone() + { + return SourceObject switch { - // Get the hash number - long id = Http.GetHashNumber(count, chunkID); - // Append the hash number to the path - string pathLegacy = $"{PathOutput}.{id}"; - string path = PathOutput + $".{chunkID + 1:000}"; - // Get the file info - FileInfo fileInfoLegacy = new FileInfo(pathLegacy); - FileInfo fileInfoLocal = new FileInfo(path); - // Check if the file exist - return fileInfoLegacy.Exists || fileInfoLocal.Exists; - }); + HypChannelSdkData asSdkPackage => new GameInstallPackage(asSdkPackage, Path.GetDirectoryName(PathOutput), uncompressedUrl: DecompressedURL), + HypPluginPackageInfo asPluginPackage => new GameInstallPackage(asPluginPackage, Path.GetDirectoryName(PathOutput), uncompressedUrl: DecompressedURL), + HypPackageData asPackage => new GameInstallPackage(asPackage, Path.GetDirectoryName(PathOutput), uncompressedUrl: DecompressedURL), + _ => new GameInstallPackage + { + URL = URL, + DecompressedURL = DecompressedURL, + Name = Name, + PathOutput = Path.GetDirectoryName(PathOutput), + PackageType = PackageType, + Size = Size, + SizeRequired = SizeRequired, + SizeDownloaded = SizeDownloaded, + Version = Version, + Hash = Hash, + LanguageID = LanguageID, + RunCommand = RunCommand, + PluginId = PluginId, + IsUseLegacyDownloader = IsUseLegacyDownloader, + ChunkList = ChunkList, + SourceObject = SourceObject + } + }; } - public Stream GetReadStream(int count) + public bool IsReadStreamExist() { - // Get the file info of the single file - FileInfo fileInfo = new FileInfo(PathOutput!).ResolveSymlink().StripAlternateDataStream(); - // Check if the file exist and the length is equal to the size - if (fileInfo.Exists && fileInfo.Length == Size) + return ChunkList.Count == 0 + ? File.Exists(PathOutput) + : ChunkList.All(x => File.Exists(x.PathOutput)); + } + + public Stream GetReadStream() + { + if (ChunkList.Count == 0) { + FileInfo fileInfo = new FileInfo(PathOutput!) + .ResolveSymlink() + .StripAlternateDataStream() + .EnsureNoReadOnly(); // Return the stream for read return fileInfo.Open(new FileStreamOptions { - Access = FileAccess.Read, + Access = FileAccess.Read, BufferSize = 4 << 10, - Mode = FileMode.Open, - Options = FileOptions.None, - Share = FileShare.Read + Mode = FileMode.Open, + Options = FileOptions.None, + Share = FileShare.Read }); } - // If the single file doesn't exist, then try getting chunk stream - return GetCombinedStreamFromPackageAsset(count); - } - - public long GetStreamLength(int count) - { - // Get the file info of the single file - FileInfo fileInfo = new FileInfo(PathOutput!); - // Check if the file exist and the length is equal to the size - if (fileInfo.Exists && fileInfo.Length == Size) + var streams = new FileStream[ChunkList.Count]; + for (int i = 0; i < ChunkList.Count; i++) { - // Return the stream for read - return fileInfo.Length; - } + GameInstallPackage chunk = ChunkList[i]; + FileInfo fileInfo = new FileInfo(chunk.PathOutput) + .ResolveSymlink() + .StripAlternateDataStream() + .EnsureNoReadOnly(); - // If the single file doesn't exist, then try getting chunk stream - return GetCombinedLengthFromPackageAsset(count); - } - - private CombinedStream GetCombinedStreamFromPackageAsset(int count) - { - // Set the array - FileStream[] streamList = new FileStream[count]; - // Enumerate the ID - for (int i = 0; i < streamList.Length; i++) - { - // Get the hash ID - long id = Http.GetHashNumber(count, i); - // Append hash ID to the path - string path = PathOutput + $".{i + 1:000}"; - string pathLegacy = $"{PathOutput}.{id}"; - // Get the file info and check if the file exist - FileInfo fileInfo = new FileInfo(path); - FileInfo fileInfoLegacy = new FileInfo(pathLegacy); - if (!fileInfo.Exists && !fileInfoLegacy.Exists) + if (!fileInfo.Exists) { - // If not found, then throw - throw new FileNotFoundException($"File chunk doesn't exist in this path! -> {path}"); + throw new FileNotFoundException($"File: {chunk.PathOutput} is missing and cannot be merged!"); } - // Allocate to the array and open the stream - FileStreamOptions opt = new FileStreamOptions + streams[i] = fileInfo.Open(new FileStreamOptions { Access = FileAccess.Read, BufferSize = 4 << 10, Mode = FileMode.Open, Options = FileOptions.None, Share = FileShare.Read - }; - if (fileInfo.Exists) - streamList[i] = fileInfo.Open(opt); - else if (fileInfoLegacy.Exists) - streamList[i] = fileInfoLegacy.Open(opt); + }); } - // Assign the array and initiate it as a combined stream - return new CombinedStream(streamList); + return new CombinedStream(streams); } - private long GetCombinedLengthFromPackageAsset(int count) + public long GetStreamLength() { - // Initialize length - long length = 0; - // Enumerate the ID - for (int i = 0; i < count; i++) + if (ChunkList.Count != 0) { - // Get the hash ID - long id = Http.GetHashNumber(count, i); - // Append hash ID to the path - string path = PathOutput + $".{i + 1:000}"; - string pathLegacy = $"{PathOutput}.{id}"; - // Get the file info and check if the file exist - FileInfo fileInfo = new FileInfo(path); - FileInfo fileInfoLegacy = new FileInfo(pathLegacy); - switch (fileInfo.Exists) + return ChunkList.Sum(static x => { - case false when !fileInfoLegacy.Exists: - continue; - // Add length to the existing one - case true: - length += fileInfo.Length; - break; - default: - { - if (fileInfoLegacy.Exists) - length += fileInfoLegacy.Length; - break; - } - } - - // Then go back to the loop routine - // ReSharper disable once RedundantJumpStatement - continue; + FileInfo fileInfo = new(x.PathOutput); + return fileInfo.Exists ? fileInfo.Length : 0; + }); } - // Return the length - return length; + FileInfo fileInfo = new(PathOutput); + return fileInfo.Exists ? fileInfo.Length : 0; } - public void DeleteFile(int count) + public void DeleteFile() { string lastFile = PathOutput; try { - FileInfo fileInfo = new FileInfo(PathOutput!); - if (fileInfo.Exists && fileInfo.Length == Size) + if (ChunkList.Count == 0) { - fileInfo.Delete(); + FileInfo fileInfo = new(PathOutput); + fileInfo.TryDeleteFile(true); + return; } - for (int i = 0; i < count; i++) + foreach (GameInstallPackage chunk in ChunkList) { - long id = Http.GetHashNumber(count, i); - string path = PathOutput + $".{i + 1:000}"; - string pathLegacy = $"{PathOutput}.{id}"; - bool isUseLegacy = File.Exists(pathLegacy); - - lastFile = isUseLegacy ? pathLegacy : path; - fileInfo = new FileInfo(path); - FileInfo fileInfoLegacy = new FileInfo(pathLegacy); - if (fileInfo.Exists) - { - fileInfo.Delete(); - } - - if (fileInfoLegacy.Exists) - { - fileInfoLegacy.Delete(); - } + lastFile = chunk.PathOutput; + FileInfo fileInfo = new(chunk.PathOutput); + fileInfo.TryDeleteFile(true); + return; } } catch (Exception ex) @@ -313,7 +267,7 @@ public void DeleteFile(int count) } public string PrintSummary() => $"File [T: {PackageType}]: {URL}\t{ConverterTool.SummarizeSizeSimple(Size)} ({Size} bytes)"; - public long GetAssetSize() => Size; + public long GetAssetSize() => ChunkList.Count > 0 ? ChunkList.Sum(x => x.Size) : Size; public string GetRemoteURL() => URL; public void SetRemoteURL(string url) => URL = url; } diff --git a/CollapseLauncher/Classes/InstallManagement/Base/InstallManagerBase.cs b/CollapseLauncher/Classes/InstallManagement/Base/InstallManagerBase.cs index 094de6a0e1..671af8af42 100644 --- a/CollapseLauncher/Classes/InstallManagement/Base/InstallManagerBase.cs +++ b/CollapseLauncher/Classes/InstallManagement/Base/InstallManagerBase.cs @@ -14,7 +14,6 @@ using Hi3Helper; using Hi3Helper.Data; using Hi3Helper.EncTool.Parser.AssetIndex; -using Hi3Helper.Win32.ManagedTools; using Hi3Helper.Http; using Hi3Helper.Http.Legacy; using Hi3Helper.LocaleSourceGen; @@ -22,12 +21,14 @@ using Hi3Helper.SentryHelper; using Hi3Helper.Shared.ClassStruct; using Hi3Helper.Shared.Region; +using Hi3Helper.Win32.ManagedTools; using Microsoft.UI.Text; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; using Microsoft.Win32; -using SharpHDiffPatch.Core; -using SharpHDiffPatch.Core.Event; +using SharpHPatchZ; +using SharpHPatchZ.Header; +using SharpHPatchZ.Header.Metadata; using System; using System.Collections.Generic; using System.Diagnostics; @@ -158,7 +159,6 @@ public InstallManagerBase( $"{Path.GetFileNameWithoutExtension(gameVersionManager.GamePreset.GameExecutableName)}_Data\\Persistent"; _gameStreamingAssetsFolderBasePath = $"{Path.GetFileNameWithoutExtension(gameVersionManager.GamePreset.GameExecutableName)}_Data\\StreamingAssets"; - UpdateCompletenessStatus(CompletenessStatus.Idle); } protected void ResetToken() @@ -315,15 +315,13 @@ protected virtual async ValueTask StartDeltaPatch(IRepairAssetIndex repair UpdateStatus(); // Start the patching process - HDiffPatch.LogVerbosity = Verbosity.Verbose; - EventListener.PatchEvent += DeltaPatchCheckProgress; - EventListener.LoggerEvent += DeltaPatchCheckLogEvent; - await Task.Run(() => - { - HDiffPatch patch = new HDiffPatch(); - patch.Initialize(patchProperty.PatchPath); - patch.Patch(ingredientPath, previousPath, true, Token!.Token, false, true); - }).ConfigureAwait(false); + using HDiffInfo hdiffInfo = await HPatch.CreateInstanceAsync(patchProperty.PatchPath, Token!.Token); + Exception? resultException = await HPatch.PatchAsync(hdiffInfo, patchProperty.PatchPath, ingredientPath, + previousPath, PatchOptions.BigBuffer, DeltaPatchProgress, + token: Token.Token); + + if (resultException != null) + throw resultException; // Remove ingredient folder Directory.Delete(ingredientPath, true); @@ -346,11 +344,6 @@ await Task.Run(() => LogWriteLine($"Error has occurred while performing delta-patch!\r\n{ex}", LogType.Error, true); throw; } - finally - { - EventListener.PatchEvent -= DeltaPatchCheckProgress; - EventListener.LoggerEvent -= DeltaPatchCheckLogEvent; - } } protected virtual async ValueTask GetAndDownloadDeltaPatchPreReq( @@ -685,7 +678,7 @@ private async ValueTask RunPackageVerificationRoutine(GameInstallPackage as ProgressPerFileSizeCurrent = 0; byte[] hashLocal; - await using (Stream fs = asset.GetReadStream(DownloadThreadCount)!) + await using (Stream fs = asset.GetReadStream()) { // Reset the per file size ProgressPerFileSizeTotal = fs.Length; @@ -749,6 +742,9 @@ protected virtual async Task StartPackageInstallationInner(List IsPreloadCompleted(CancellationToken token) await GetPackagesRemoteSize(AssetIndex, token); long totalPackageSize = AssetIndex.Sum(x => x.Size); - // Get the sum of the total size of the single or segmented packages - return AssetIndex.Sum(asset => asset.IsReadStreamExist(DownloadThreadCount) ? - // If yes, then return the size of the single stream - asset.GetStreamLength(DownloadThreadCount) : - // If neither of both exist, then return 0 - 0) == totalPackageSize; // Then compare if the total package size is equal - - // Note: - // x.GetReadStream() will check if the single package/zip exist. - // So checking the fully downloaded single package is unnecessary. + // Get the sum of the total size of the single or segmented packages. + // Then compare if the total package size is equal. + return AssetIndex.Sum(asset => asset.GetStreamLength()) == totalPackageSize; } public async ValueTask MoveGameLocation() @@ -1348,38 +1337,44 @@ private string GetBasePersistentDirectory(string basePath, string input) private async Task FileHdiffPatcherInner(string patchPath, string sourceBasePath, string destPath, CancellationToken token) { - HDiffPatch patcher = new HDiffPatch(); - patcher.Initialize(patchPath); - token.ThrowIfCancellationRequested(); + FileInfo patchFileInfo = new(patchPath); + FileInfo sourceFileInfo = new(sourceBasePath); + FileInfo targetFileInfo = new(destPath); + + using HDiffInfo hdiffInfo = await HPatch.CreateInstanceAsync(patchFileInfo.FullName, token); + long newFileSize = GetHDiffNewSize(hdiffInfo); - Task task = Task.Run(() => + try { - try + Exception? resultException = await HPatch.PatchAsync(hdiffInfo, + patchFileInfo.FullName, + sourceFileInfo.FullName, + targetFileInfo.FullName, + PatchOptions.BigBuffer, + EventListener_PatchEvent, + token); + if (resultException != null) + throw resultException; + + targetFileInfo.TryMoveTo(sourceFileInfo); + } + catch (Exception ex) when (!token.IsCancellationRequested) + { + if (ex is not InvalidDataException or InvalidOperationException) { - patcher.Patch(sourceBasePath, destPath, true, token, false, true); - File.Move(destPath, sourceBasePath, true); + throw; } - catch (InvalidDataException ex) when (!token.IsCancellationRequested) - { - // ignored - // Get the base and new target file size - long newFileSize = HDiffPatch.GetHDiffNewSize(patchPath); - FileInfo fileInfo = new FileInfo(sourceBasePath); - long refFileSize = fileInfo.Exists ? fileInfo.Length : 0; - // Check if the throw happened for different file, then rethrow - if (newFileSize != refFileSize) - throw; + FileInfo fileInfo = new(sourceBasePath); + long refFileSize = fileInfo.Exists ? fileInfo.Length : 0; - // Otherwise, log the error - SentryHelper.ExceptionHandler(ex, SentryHelper.ExceptionType.UnhandledOther); - LogWriteLine($"New: {newFileSize} == Ref: {refFileSize}. File is already new. Skipping! {sourceBasePath}", LogType.Warning, true); - } - }, token); - await task; + // Check if the throw happened for different file, then rethrow + if (newFileSize != refFileSize) + throw; - if (task.Exception != null) - throw task.Exception; + // Otherwise, log the error + LogWriteLine($"New: {newFileSize} == Ref: {refFileSize}. File is already new. Skipping! {sourceBasePath}", LogType.Warning, true); + } } protected virtual async Task> GetHDiffMapEntryList(string gameDir) @@ -1442,7 +1437,7 @@ protected virtual async Task> GetHDiffMapEntryList(string ga protected virtual async Task ApplyHDiffMap() { - string gameDir = GamePath; + string gameDir = GamePath; List hDiffMapEntries = await GetHDiffMapEntryList(gameDir); if (hDiffMapEntries.Count == 0) @@ -1457,169 +1452,158 @@ protected virtual async Task ApplyHDiffMap() ProgressAllCountTotal = 1; ProgressAllCountFound = hDiffMapEntries.Count; - HDiffPatch.LogVerbosity = Verbosity.Verbose; - EventListener.LoggerEvent += EventListener_PatchLogEvent; - EventListener.PatchEvent += EventListener_PatchEvent; + Task parallelTask = Parallel.ForEachAsync(hDiffMapEntries, new ParallelOptions + { + MaxDegreeOfParallelism = ThreadCount, + CancellationToken = Token!.Token + }, + PatchWorker); - try + await parallelTask; + + return; + + async ValueTask PatchWorker(HDiffMapEntry entry, CancellationToken workerToken) { - Task parallelTask = Parallel.ForEachAsync(hDiffMapEntries, new ParallelOptions - { - MaxDegreeOfParallelism = ThreadCount, - CancellationToken = Token!.Token - }, - async (entry, ctx) => - { - Status.ActivityStatus = - $"{Locale.Current.Lang?._Misc?.Patching}: {string.Format(Locale.Current.Lang?._Misc?.PerFromTo ?? "", ProgressAllCountTotal, - ProgressAllCountFound)}"; - Status.ActivityStatusInternet = false; + Status.ActivityStatus = $"{Locale.Current.Lang?._Misc?.Patching}: {string.Format(Locale.Current.Lang?._Misc?.PerFromTo ?? "", ProgressAllCountTotal, ProgressAllCountFound)}"; + Status.ActivityStatusInternet = false; - bool isSuccess = false; - - FileInfo sourcePath = new FileInfo(GetBasePersistentDirectory(gameDir, entry.SourceFileName)) - .StripAlternateDataStream().EnsureNoReadOnly(out bool isSourceExist); - string sourcePathDir = sourcePath.DirectoryName ?? ""; - FileInfo patchPath = new FileInfo(Path.Combine(gameDir, entry.PatchFileName ?? "")) - .StripAlternateDataStream().EnsureNoReadOnly(out bool isPatchExist); - string targetPathBasedOnSource = Path.Combine(sourcePathDir, Path.GetFileName(entry.TargetFileName ?? "")); - FileInfo targetPath = new FileInfo(targetPathBasedOnSource) - .EnsureCreationOfDirectory() - .StripAlternateDataStream() - .EnsureNoReadOnly(); - FileInfo targetPathTemp = new FileInfo(targetPath + "_tmp") - .StripAlternateDataStream().EnsureNoReadOnly(); + bool isSuccess = false; - try + FileInfo sourcePath = new FileInfo(GetBasePersistentDirectory(gameDir, entry.SourceFileName)) + .StripAlternateDataStream() + .EnsureNoReadOnly(out bool isSourceExist); + string sourcePathDir = sourcePath.DirectoryName ?? ""; + FileInfo patchPath = new FileInfo(Path.Combine(gameDir, entry.PatchFileName ?? "")) + .StripAlternateDataStream() + .EnsureNoReadOnly(out bool isPatchExist); + string targetPathBasedOnSource = Path.Combine(sourcePathDir, Path.GetFileName(entry.TargetFileName ?? "")); + FileInfo targetPath = new FileInfo(targetPathBasedOnSource) + .EnsureCreationOfDirectory() + .StripAlternateDataStream() + .EnsureNoReadOnly(); + FileInfo targetPathTemp = new FileInfo(targetPath + "_tmp") + .StripAlternateDataStream().EnsureNoReadOnly(); + + try + { + if (string.IsNullOrEmpty(entry.SourceFileName) || !isPatchExist || !isSourceExist) { - if (string.IsNullOrEmpty(entry.SourceFileName)) - { - ForceUpdateProgress(entry); - return; - } + ForceUpdateProgress(entry); + return; + } - if (!isPatchExist || !isSourceExist) - { - ForceUpdateProgress(entry); - return; - } + if (isSourceExist && sourcePath.Length != + entry.SourceFileSize) + { + ForceUpdateProgress(entry); + LogWriteLine($"[InstallManagerBase::ApplyHDiffMap] Source file size mismatch: {sourcePath.FullName} ({sourcePath.Length} != {entry.SourceFileSize})", + LogType.Warning, true); + return; + } - if (isSourceExist && sourcePath.Length != entry.SourceFileSize) + byte[] sourceLocalHash = + entry.SourceMD5Hash?.Length switch { - ForceUpdateProgress(entry); - LogWriteLine($"[InstallManagerBase::ApplyHDiffMap] Source file size mismatch: {sourcePath.FullName} ({sourcePath.Length} != {entry.SourceFileSize})", LogType.Warning, true); - return; - } - - byte[] sourceLocalHash = entry.SourceMD5Hash?.Length switch - { - > 8 and 16 => await GetCryptoHashAsync(sourcePath, null, false, true, Token.Token), - > 4 => await GetHashAsync(sourcePath, false, true, Token.Token), - _ => await GetHashAsync(sourcePath, false, true, Token.Token) - }; + > 8 and 16 => await GetCryptoHashAsync(sourcePath, null, false, true, workerToken), + > 4 => await GetHashAsync(sourcePath, false, true, workerToken), + _ => await GetHashAsync(sourcePath, false, true, workerToken) + }; - if (!sourceLocalHash.AsSpan().SequenceEqual(entry.SourceMD5Hash)) - { - ForceUpdateProgress(entry); - LogWriteLine("[InstallManagerBase::ApplyHDiffMap] Source file or patch has mismatch hash!\r\n" - + $"Source file: {sourcePath.FullName}\r\nLocal Hash: {HexTool.BytesToHexUnsafe(sourceLocalHash)}\r\nRemote Hash: {HexTool.BytesToHexUnsafe(entry.SourceMD5Hash)}", - LogType.Warning, - true); - return; - } + if (!sourceLocalHash.AsSpan().SequenceEqual(entry.SourceMD5Hash)) + { + ForceUpdateProgress(entry); + LogWriteLine("[InstallManagerBase::ApplyHDiffMap] Source file or patch has mismatch hash!\r\n" + + $"Source file: {sourcePath.FullName}\r\nLocal Hash: {HexTool.BytesToHexUnsafe(sourceLocalHash)}\r\nRemote Hash: {HexTool.BytesToHexUnsafe(entry.SourceMD5Hash)}", + LogType.Warning, + true); + return; + } - LogWriteLine($"Patching file {entry.SourceFileName} to {entry.TargetFileName}...", LogType.Default, true); - UpdateProgressBase(); - UpdateStatus(); + LogWriteLine($"Patching file {entry.SourceFileName} to {entry.TargetFileName}...", LogType.Default, true); + UpdateProgressBase(); + UpdateStatus(); - await Task.Factory.StartNew(state => - { - CancellationToken thisInnerCtx = (CancellationToken)(state ?? CancellationToken.None); - try - { - thisInnerCtx.ThrowIfCancellationRequested(); - HDiffPatch patcher = new HDiffPatch(); - patcher.Initialize(patchPath.FullName); - patcher.Patch(sourcePath.FullName, targetPathTemp.FullName, true, thisInnerCtx, false, true); - isSuccess = true; - } - catch (InvalidDataException ex) when (!thisInnerCtx.IsCancellationRequested) - { - // ignored - // Get the base and new target file size - long newFileSize = HDiffPatch.GetHDiffNewSize(patchPath.FullName); - long refFileSize = targetPath.Exists ? targetPath.Length : 0; - - // Check if the throw happened for different file, then rethrow - if (newFileSize != refFileSize) - throw; - - // Otherwise, log the error - SentryHelper.ExceptionHandler(ex, SentryHelper.ExceptionType.UnhandledOther); - LogWriteLine($"New: {newFileSize} == Ref: {refFileSize}. File is already new. Skipping! {targetPath.FullName}", LogType.Warning, true); - } - }, - ctx, - ctx, - TaskCreationOptions.DenyChildAttach, - TaskScheduler.Default); - } - catch (OperationCanceledException) + try { - await Token.CancelAsync(); - LogWriteLine("Cancelling patching process!...", LogType.Warning, true); - throw; + using HDiffInfo hdiffInfo = await HPatch.CreateInstanceAsync(patchPath.FullName, workerToken); + Exception? resultException = await HPatch.PatchAsync( + hdiffInfo, + patchPath.FullName, + sourcePath.FullName, + targetPathTemp.FullName, + PatchOptions.BigBuffer, + EventListener_PatchEvent, + workerToken); + + if (resultException != null) + throw resultException; + + isSuccess = true; } - catch (Exception ex) + catch (Exception) when (!workerToken.IsCancellationRequested) { - await SentryHelper.ExceptionHandler_ForLoopAsync(ex); - LogWriteLine( - $"Error while patching file: {entry.SourceFileName ?? string.Empty} to: {entry.TargetFileName ?? string.Empty}. Skipping!\r\n{ex}", - LogType.Warning, - true); + // ignored + // Get the base and new target file size + long newFileSize = GetHDiffNewSize(patchPath.FullName); + long refFileSize = targetPath.Exists ? targetPath.Length : 0; - ForceUpdateProgress(entry); + // Check if the throw happened for different file, then rethrow + if (newFileSize != refFileSize) + throw; + + // Otherwise, log the error + LogWriteLine($"New: {newFileSize} == Ref: {refFileSize}. File is already new. Skipping! {targetPath.FullName}", LogType.Warning, true); } - finally - { - Interlocked.Increment(ref ProgressAllCountTotal); - if (!string.IsNullOrEmpty(entry.PatchFileName)) - { - _ = patchPath.TryDeleteFile(); - } + } + catch (OperationCanceledException) + { + LogWriteLine("Cancelling patching process!...", LogType.Warning, true); + await Token.CancelAsync(); + throw; + } + catch (Exception ex) + { + await SentryHelper.ExceptionHandler_ForLoopAsync(ex); + LogWriteLine($"Error while patching file: {entry.SourceFileName} to: {entry.TargetFileName ?? string.Empty}. Skipping!\r\n{ex}", + LogType.Warning, + true); - if (isSuccess && entry.CanDeleteSource) - { - sourcePath.Refresh(); - _ = sourcePath.TryDeleteFile(); - } + ForceUpdateProgress(entry); + } + finally + { + Interlocked.Increment(ref ProgressAllCountTotal); + if (!string.IsNullOrEmpty(entry.PatchFileName)) + { + _ = patchPath.TryDeleteFile(); + } - targetPathTemp.Refresh(); - if (targetPathTemp.Exists) - { - _ = targetPathTemp.TryMoveTo(targetPath); - } + if (isSuccess && entry.CanDeleteSource) + { + sourcePath.Refresh(); + _ = sourcePath.TryDeleteFile(); } - }); - await parallelTask; - } - finally - { - EventListener.LoggerEvent -= EventListener_PatchLogEvent; - EventListener.PatchEvent -= EventListener_PatchEvent; + targetPathTemp.Refresh(); + if (targetPathTemp.Exists) + { + _ = targetPathTemp.TryMoveTo(targetPath); + } + } } - return; - void ForceUpdateProgress(HDiffMapEntry entry) { lock (Progress) { Progress.ProgressAllSizeCurrent += entry.TargetFileSize; - Progress.ProgressAllPercentage = ConverterTool.ToPercentage(Progress.ProgressAllSizeTotal, Progress.ProgressAllSizeCurrent); + Progress.ProgressAllPercentage = + ConverterTool.ToPercentage(Progress.ProgressAllSizeTotal, Progress.ProgressAllSizeCurrent); Progress.ProgressAllSpeed = CalculateSpeed(entry.TargetFileSize); - Progress.ProgressAllTimeLeft = ConverterTool.ToTimeSpanRemain(Progress.ProgressAllSizeTotal, Progress.ProgressAllSizeCurrent, Progress.ProgressAllSpeed); + Progress.ProgressAllTimeLeft = + ConverterTool.ToTimeSpanRemain(Progress.ProgressAllSizeTotal, Progress.ProgressAllSizeCurrent, + Progress.ProgressAllSpeed); } UpdateProgress(); @@ -1641,10 +1625,6 @@ public virtual async Task ApplyHdiffListPatch() ProgressAllCountTotal = 1; ProgressAllCountFound = hdiffEntry.Count; - HDiffPatch.LogVerbosity = Verbosity.Verbose; - EventListener.LoggerEvent += EventListener_PatchLogEvent; - EventListener.PatchEvent += EventListener_PatchEvent; - Task parallelTask = Parallel.ForEachAsync(hdiffEntry, new ParallelOptions { CancellationToken = Token!.Token, @@ -1727,17 +1707,12 @@ public virtual async Task ApplyHdiffListPatch() await SentryHelper.ExceptionHandlerAsync(innerExceptionsFirst, SentryHelper.ExceptionType.UnhandledOther); throw innerExceptionsFirst; } - finally - { - EventListener.LoggerEvent -= EventListener_PatchLogEvent; - EventListener.PatchEvent -= EventListener_PatchEvent; - } } - private void EventListener_PatchEvent(object? sender, PatchEvent e) + private void EventListener_PatchEvent(long totalWritten, long totalSize, int written) { - Interlocked.Add(ref ProgressAllSizeCurrent, e.Read); - double speed = CalculateSpeed(e.Read); + Interlocked.Add(ref ProgressAllSizeCurrent, written); + double speed = CalculateSpeed(written); if (!CheckIfNeedRefreshStopwatch()) { @@ -1754,32 +1729,6 @@ private void EventListener_PatchEvent(object? sender, PatchEvent e) UpdateProgress(); } - private void EventListener_PatchLogEvent(object? sender, LoggerEvent e) - { - if (HDiffPatch.LogVerbosity == Verbosity.Quiet - || (HDiffPatch.LogVerbosity == Verbosity.Debug - && !(e.LogLevel == Verbosity.Debug || - e.LogLevel == Verbosity.Verbose || - e.LogLevel == Verbosity.Info)) - || (HDiffPatch.LogVerbosity == Verbosity.Verbose - && !(e.LogLevel == Verbosity.Verbose || - e.LogLevel == Verbosity.Info)) - || (HDiffPatch.LogVerbosity == Verbosity.Info - && e.LogLevel != Verbosity.Info)) - { - return; - } - - LogType type = e.LogLevel switch - { - Verbosity.Verbose => LogType.Debug, - Verbosity.Debug => LogType.Debug, - _ => LogType.Default - }; - - LogWriteLine(e.Message, type, true); - } - public virtual List TryGetHDiffList() { List _out = []; @@ -1816,7 +1765,7 @@ public virtual List TryGetHDiffList() try { - prop.fileSize = HDiffPatch.GetHDiffNewSize(filePath); + prop.fileSize = GetHDiffNewSize(filePath); LogWriteLine($"hdiff entry: {prop.remoteName}", LogType.Default, true); _out.Add(prop); @@ -1840,6 +1789,36 @@ public virtual List TryGetHDiffList() return _out; } + protected static long GetHDiffNewSize(string filePath) + { + HDiffInfo hdiffInfo = HPatch.CreateInstance(filePath); + + try + { + return GetHDiffNewSize(hdiffInfo); + } + finally + { + hdiffInfo.Dispose(); + } + } + + protected static unsafe long GetHDiffNewSize(HDiffInfo hdiffInfo) + { + if (!hdiffInfo.TryGetPatchMetadata(out PatchMetadata patchMetadata)) + { + throw new InvalidOperationException("File is not a supported HDIFF file"); + } + + if (hdiffInfo.TryGetDirectoryPatchMetadata(out DirectoryPatchMetadata dirPatchMetadata)) + { + return dirPatchMetadata.OutputPathCountSizeInfoP->Size + + dirPatchMetadata.SameFilePathCountSizeInfoP->Size; + } + + return patchMetadata.DiffNewSize; + } + protected virtual string GetLanguageLocaleCodeByID(int id) { return id switch @@ -3000,9 +2979,9 @@ private async ValueTask RunPackageDownloadRoutine(Http httpClient, // If the file exist or package size is unmatched, // then start downloading - long legacyExistingPackageFileSize = package.GetStreamLength(DownloadThreadCount); + long legacyExistingPackageFileSize = package.GetStreamLength(); long existingPackageFileSize = package.SizeDownloaded > legacyExistingPackageFileSize ? package.SizeDownloaded : legacyExistingPackageFileSize; - bool isExistingPackageFileExist = package.IsReadStreamExist(DownloadThreadCount); + bool isExistingPackageFileExist = package.IsReadStreamExist(); if (!isExistingPackageFileExist || existingPackageFileSize != package.Size) @@ -3233,7 +3212,7 @@ public void UpdateCompletenessStatus(CompletenessStatus status) Status.IsCompleted = false; Status.IsCanceled = false; #if !DISABLEDISCORD - InnerLauncherConfig.AppDiscordPresence.SetActivity(ActivityType.Update); + InnerLauncherConfig.AppDiscordPresence.SetActivity(DiscordActivityType.Update); #endif break; case CompletenessStatus.Completed: @@ -3244,7 +3223,7 @@ public void UpdateCompletenessStatus(CompletenessStatus status) Status.IsProgressAllIndetermined = false; Status.IsProgressPerFileIndetermined = false; #if !DISABLEDISCORD - InnerLauncherConfig.AppDiscordPresence.SetActivity(ActivityType.Idle); + InnerLauncherConfig.AppDiscordPresence.SetActivity(DiscordActivityType.Idle); #endif // HACK: Fix the progress not achieving 100% while completed lock (Progress) @@ -3261,7 +3240,7 @@ public void UpdateCompletenessStatus(CompletenessStatus status) Status.IsProgressAllIndetermined = false; Status.IsProgressPerFileIndetermined = false; #if !DISABLEDISCORD - InnerLauncherConfig.AppDiscordPresence.SetActivity(ActivityType.Idle); + InnerLauncherConfig.AppDiscordPresence.SetActivity(DiscordActivityType.Idle); #endif break; case CompletenessStatus.Idle: @@ -3272,7 +3251,7 @@ public void UpdateCompletenessStatus(CompletenessStatus status) Status.IsProgressAllIndetermined = false; Status.IsProgressPerFileIndetermined = false; #if !DISABLEDISCORD - InnerLauncherConfig.AppDiscordPresence.SetActivity(ActivityType.Idle); + InnerLauncherConfig.AppDiscordPresence.SetActivity(DiscordActivityType.Idle); #endif break; } @@ -3305,58 +3284,85 @@ protected virtual GameInstallFileInfo GetGameInstallFileInfo() #endregion - #region Event Methods + #region Private Methods - protected void UpdateProgressBase() + private static List MergeChunkedPackage(List packages) { - base.UpdateProgress(); - } + List dedupList = []; - protected void DeltaPatchCheckProgress(object? sender, PatchEvent e) - { - if (!CheckIfNeedRefreshStopwatch()) + // Clone the list first. + foreach (GameInstallPackage package in packages) { - return; + string filePath = package.PathOutput; + string fileExtension = Path.GetExtension(filePath); + + // Add the first chunk + if (fileExtension.StartsWith(".001")) + { + dedupList.Add(package.Clone()); + continue; + } + + // Ignore other chunks + if (fileExtension.IsChunkedFilePath()) + { + continue; + } + + // Add other non-chunk file + dedupList.Add(package.Clone()); } - lock (Progress) + // Start adding up the chunk files. + foreach (GameInstallPackage dedupPackage in dedupList) { - Progress.ProgressAllPercentage = e.ProgressPercentage; - Progress.ProgressAllTimeLeft = e.TimeLeft; - Progress.ProgressAllSpeed = e.Speed; - Progress.ProgressAllSizeTotal = e.TotalSizeToBePatched; - Progress.ProgressAllSizeCurrent = e.CurrentSizePatched; + string filePath = dedupPackage.PathOutput; + string filePathNoChunkExt = Path.Combine(Path.GetDirectoryName(filePath) ?? "", Path.GetFileNameWithoutExtension(filePath)); + string fileExtension = Path.GetExtension(filePath); + + if (!fileExtension.StartsWith(".001")) + { + continue; + } + + // Select the chunked file by order only + foreach (GameInstallPackage package in packages + .Where(x => x.PathOutput.StartsWith(filePathNoChunkExt)) + .OrderBy(x => x.PathOutput)) + { + dedupPackage.ChunkList.Add(package.Clone()); + } } - Status.IsProgressAllIndetermined = false; - UpdateProgressBase(); - UpdateStatus(); + return dedupList; + } + + #endregion + + #region Event Methods + + protected void UpdateProgressBase() + { + base.UpdateProgress(); } - protected void DeltaPatchCheckLogEvent(object? sender, LoggerEvent e) + private void DeltaPatchProgress(long totalWritten, long totalSize, int currentlyWritten) { - if (HDiffPatch.LogVerbosity == Verbosity.Quiet - || (HDiffPatch.LogVerbosity == Verbosity.Debug - && !(e.LogLevel == Verbosity.Debug || - e.LogLevel == Verbosity.Verbose || - e.LogLevel == Verbosity.Info)) - || (HDiffPatch.LogVerbosity == Verbosity.Verbose - && !(e.LogLevel == Verbosity.Verbose || - e.LogLevel == Verbosity.Info)) - || (HDiffPatch.LogVerbosity == Verbosity.Info - && e.LogLevel != Verbosity.Info)) + double speed = CalculateSpeed(currentlyWritten); + if (!CheckIfNeedRefreshStopwatch()) { return; } - LogType type = e.LogLevel switch - { - Verbosity.Verbose => LogType.Debug, - Verbosity.Debug => LogType.Debug, - _ => LogType.Default - }; + Progress.ProgressAllPercentage = Math.Round(totalWritten / (double)totalSize * 100, 2); + Progress.ProgressAllTimeLeft = TimeSpan.FromSeconds((totalSize - totalWritten) / speed.UnNanOrInfinity()); + Progress.ProgressAllSpeed = speed; + Progress.ProgressAllSizeTotal = totalSize; + Progress.ProgressAllSizeCurrent = totalWritten; - LogWriteLine(e.Message, type, true); + Status.IsProgressAllIndetermined = false; + UpdateProgressBase(); + UpdateStatus(); } protected void DeltaPatchCheckProgress(object? sender, TotalPerFileProgress e) diff --git a/CollapseLauncher/Classes/Interfaces/Class/ProgressBase.cs b/CollapseLauncher/Classes/Interfaces/Class/ProgressBase.cs index f344db39fc..88f3d842ae 100644 --- a/CollapseLauncher/Classes/Interfaces/Class/ProgressBase.cs +++ b/CollapseLauncher/Classes/Interfaces/Class/ProgressBase.cs @@ -467,6 +467,13 @@ protected virtual void UpdateProgressCopyStream(long currentPosition, int read, protected double CalculateSpeed(long receivedBytes) => CalculateSpeed(receivedBytes, ref _scLastSpeed, ref _scLastReceivedBytes, ref _scLastTick); + protected void ResetSpeedCalculator() + { + Interlocked.Exchange(ref _scLastReceivedBytes, 0); + Interlocked.Exchange(ref _scLastTick, Environment.TickCount64); + _scLastSpeed = 0; + } + protected static double CalculateSpeed(long receivedBytes, ref double lastSpeedToUse, ref long lastReceivedBytesToUse, ref long lastTickToUse) { long currentTick = Environment.TickCount64 - lastTickToUse + 1; @@ -1443,12 +1450,12 @@ protected virtual long GetSingleOrSegmentedUncompressedSize(GameInstallPackage a protected virtual Stream GetSingleOrSegmentedDownloadStream(GameInstallPackage asset) { - return asset.GetReadStream(DownloadThreadCount); + return asset.GetReadStream(); } protected virtual void DeleteSingleOrSegmentedDownloadStream(GameInstallPackage asset) { - asset.DeleteFile(DownloadThreadCount); + asset.DeleteFile(); } diff --git a/CollapseLauncher/Classes/Interfaces/IBackgroundActivity.cs b/CollapseLauncher/Classes/Interfaces/IBackgroundActivity.cs index 3eeda615be..ba378bf235 100644 --- a/CollapseLauncher/Classes/Interfaces/IBackgroundActivity.cs +++ b/CollapseLauncher/Classes/Interfaces/IBackgroundActivity.cs @@ -11,6 +11,8 @@ internal interface IBackgroundActivity event EventHandler StatusChanged; event EventHandler FlushingTrigger; + TotalPerFileProgress Progress { get; } + TotalPerFileStatus Status { get; } bool IsRunning { get; } UIElement ParentUI { get; } void CancelRoutine(); diff --git a/CollapseLauncher/Classes/Interfaces/IGameSettingsUniversal.cs b/CollapseLauncher/Classes/Interfaces/IGameSettingsUniversal.cs index 46281787a5..0d5be1c1b9 100644 --- a/CollapseLauncher/Classes/Interfaces/IGameSettingsUniversal.cs +++ b/CollapseLauncher/Classes/Interfaces/IGameSettingsUniversal.cs @@ -22,7 +22,12 @@ public interface IGameSettingsExportable RegistryKey? RegistryRoot { get; } RegistryKey? RefreshRegistryRoot(); - Task ImportSettings(string? gameBasePath = null); - Task ExportSettings(bool isCompressed = true, string? parentPathToImport = null, string[]? relativePathToImport = null); + Task ImportSettings(string? gameBasePath = null, string? path = null); + + Task ExportSettings(bool isCompressed = true, string? parentPathToImport = null, + string[]? relativePathToImport = null, string? path = null); + + Task PushToDatabase(); + Task GetFromDatabase(); } } diff --git a/CollapseLauncher/Classes/Plugins/PluginGameInstallWrapper.cs b/CollapseLauncher/Classes/Plugins/PluginGameInstallWrapper.cs index 4d59a69b65..30e89db4b3 100644 --- a/CollapseLauncher/Classes/Plugins/PluginGameInstallWrapper.cs +++ b/CollapseLauncher/Classes/Plugins/PluginGameInstallWrapper.cs @@ -73,6 +73,7 @@ public override string GamePath private InstallProgressState _currentInstallState = InstallProgressState.Idle; private string _lastActivityStatus = string.Empty; private float _lastLoggedPercentage = -1f; + private bool _resetSpeedBaseline = true; private PerFileProgressCallbackNative? _perFileProgressDelegate; private GCHandle _perFileProgressGcHandle; @@ -558,7 +559,18 @@ private void UpdateProgressCallback(in InstallProgress delegateProgress) long downloadedBytes = delegateProgress.DownloadedBytes; long downloadedBytesTotal = delegateProgress.TotalBytesToDownload; - long readDownload = delegateProgress.DownloadedBytes - _updateProgressProperty.LastDownloaded; + long readDownload = 0; + if (_resetSpeedBaseline) + { + _resetSpeedBaseline = false; + _updateProgressProperty.LastDownloaded = downloadedBytes; + } + else if (downloadedBytes >= _updateProgressProperty.LastDownloaded) + { + readDownload = downloadedBytes - _updateProgressProperty.LastDownloaded; + _updateProgressProperty.LastDownloaded = downloadedBytes; + } + double currentSpeed = CalculateSpeed(readDownload); Progress.ProgressAllSizeCurrent = downloadedBytes; @@ -605,8 +617,6 @@ or InstallProgressState.Verify : 0; } - _updateProgressProperty.LastDownloaded = downloadedBytes; - PublishProgressUi(updateProgressBar: true); } } @@ -623,7 +633,26 @@ private void UpdateStatusCallback(InstallProgressState delegateState) { using (_updateStatusLock.EnterScope()) { - _currentInstallState = delegateState; + if (_currentInstallState != delegateState) + { + _currentInstallState = delegateState; + _resetSpeedBaseline = true; + ResetSpeedCalculator(); + Progress.ProgressAllSpeed = 0; + } + + if (delegateState == InstallProgressState.Completed) + { + Progress.ProgressAllTimeLeft = TimeSpan.Zero; + if (Progress.ProgressPerFileSizeTotal > 0) + { + Progress.ProgressPerFilePercentage = Math.Min(100d, + ConverterTool.ToPercentage( + Progress.ProgressPerFileSizeTotal, + Progress.ProgressPerFileSizeCurrent)); + } + } + ApplyActivityStatusFromProperty(); PublishProgressUi(updateProgressBar: true); } @@ -705,7 +734,13 @@ public async ValueTask UninstallGame() } } - public void Flush() => FlushingTrigger?.Invoke(this, EventArgs.Empty); + public void Flush() + { + UpdateCompletenessStatus(CompletenessStatus.Idle); + AssetIndex.Clear(); + + FlushingTrigger?.Invoke(this, EventArgs.Empty); + } public async ValueTask IsPreloadCompleted(CancellationToken token = default) { @@ -829,7 +864,7 @@ public void UpdateCompletenessStatus(CompletenessStatus status) Status.IsCompleted = false; Status.IsCanceled = false; #if !DISABLEDISCORD - InnerLauncherConfig.AppDiscordPresence.SetActivity(ActivityType.Update); + InnerLauncherConfig.AppDiscordPresence.SetActivity(DiscordActivityType.Update); #endif break; case CompletenessStatus.Completed: @@ -840,7 +875,7 @@ public void UpdateCompletenessStatus(CompletenessStatus status) Status.IsProgressAllIndetermined = false; Status.IsProgressPerFileIndetermined = false; #if !DISABLEDISCORD - InnerLauncherConfig.AppDiscordPresence.SetActivity(ActivityType.Idle); + InnerLauncherConfig.AppDiscordPresence.SetActivity(DiscordActivityType.Idle); #endif lock (Progress) { @@ -856,7 +891,7 @@ public void UpdateCompletenessStatus(CompletenessStatus status) Status.IsProgressAllIndetermined = false; Status.IsProgressPerFileIndetermined = false; #if !DISABLEDISCORD - InnerLauncherConfig.AppDiscordPresence.SetActivity(ActivityType.Idle); + InnerLauncherConfig.AppDiscordPresence.SetActivity(DiscordActivityType.Idle); #endif break; case CompletenessStatus.Idle: @@ -867,7 +902,7 @@ public void UpdateCompletenessStatus(CompletenessStatus status) Status.IsProgressAllIndetermined = false; Status.IsProgressPerFileIndetermined = false; #if !DISABLEDISCORD - InnerLauncherConfig.AppDiscordPresence.SetActivity(ActivityType.Idle); + InnerLauncherConfig.AppDiscordPresence.SetActivity(DiscordActivityType.Idle); #endif break; } diff --git a/CollapseLauncher/Classes/Plugins/PluginPresetConfigWrapper.cs b/CollapseLauncher/Classes/Plugins/PluginPresetConfigWrapper.cs index bd0578c382..6c8a77d52a 100644 --- a/CollapseLauncher/Classes/Plugins/PluginPresetConfigWrapper.cs +++ b/CollapseLauncher/Classes/Plugins/PluginPresetConfigWrapper.cs @@ -25,6 +25,7 @@ public partial class PluginPresetConfigWrapper : PresetConfig, IDisposable { public DiscordPresenceExtension.DiscordPresenceContext DiscordPresenceContext { get; } public GameManagerExtension.RunGameFromGameManagerContext RunGameContext { get; } + public GameSettingsExtension.GameSettingsContext GameSettingsContext { get; } public readonly PluginInfo PluginInfo; public readonly IPlugin Plugin; private readonly IPluginPresetConfig _config; @@ -50,6 +51,7 @@ private unsafe PluginPresetConfigWrapper(PluginInfo pluginInfo, IPluginPresetCon }; DiscordPresenceContext = new DiscordPresenceExtension.DiscordPresenceContext(pluginInfo.Handle, config); + GameSettingsContext = new GameSettingsExtension.GameSettingsContext(pluginInfo.Handle, config); } public unsafe GameManagerExtension.RunGameFromGameManagerContext UseToggledGameLaunchContext() diff --git a/CollapseLauncher/Classes/Properties/InnerLauncherConfig.cs b/CollapseLauncher/Classes/Properties/InnerLauncherConfig.cs index 56e2579c52..bb2b6ac383 100644 --- a/CollapseLauncher/Classes/Properties/InnerLauncherConfig.cs +++ b/CollapseLauncher/Classes/Properties/InnerLauncherConfig.cs @@ -49,18 +49,9 @@ public enum AppMode public static bool IsSkippingUpdateCheck = false; public static AppThemeMode CurrentAppTheme; #if !DISABLEDISCORD - public static DiscordPresenceManager AppDiscordPresence + public static DiscordRpcManager AppDiscordPresence { - get - { - if (field != null) return field; - - bool isEnableDiscord = GetAppConfigValue("EnableDiscordRPC"); - field = new DiscordPresenceManager(isEnableDiscord); - AppDiscordPresence.SetActivity(ActivityType.Idle); - - return field; - } + get => field ??= new DiscordRpcManager(); } #endif public static bool IsAppThemeLight => @@ -73,7 +64,7 @@ public static DiscordPresenceManager AppDiscordPresence public static void SaveLocalNotificationData() { - NotificationPush localNotificationData = new NotificationPush + NotificationPush localNotificationData = new() { AppPushIgnoreMsgIds = NotificationData?.AppPushIgnoreMsgIds, RegionPushIgnoreMsgIds = NotificationData?.RegionPushIgnoreMsgIds diff --git a/CollapseLauncher/Classes/Properties/WindowSizeProp/WindowSizeProp.cs b/CollapseLauncher/Classes/Properties/WindowSizeProp/WindowSizeProp.cs index 3851f3d258..2071ff6009 100644 --- a/CollapseLauncher/Classes/Properties/WindowSizeProp/WindowSizeProp.cs +++ b/CollapseLauncher/Classes/Properties/WindowSizeProp/WindowSizeProp.cs @@ -33,12 +33,12 @@ Dictionary WindowSizeProfiles PostEventPanelScaleFactor = 1.35f, SidePanel1Width = new GridLength(340, GridUnitType.Pixel), EventPostCarouselBounds = new Size(340, 158), - PostPanelBounds = new Size(340, 84), + PostPanelBounds = new Size(340, 100), PostPanelBottomMargin = new Thickness(0, 0, 0, 20), PostPanelPaimonHeight = 110, PostPanelPaimonMargin = new Thickness(0, -48, -56, 0), PostPanelPaimonInnerMargin = new Thickness(0, 0, 0, 0), - PostPanelPaimonTextMargin = new Thickness(0, 0, 0, 18), + PostPanelPaimonTextMargin = new Thickness(0, 0, 0, 24), PostPanelPaimonTextSize = 11, BannerIconHeight = 40, BannerIconHeightHYP = 40, @@ -59,12 +59,12 @@ Dictionary WindowSizeProfiles PostEventPanelScaleFactor = 1.25f, SidePanel1Width = new GridLength(280, GridUnitType.Pixel), EventPostCarouselBounds = new Size(280, 130), - PostPanelBounds = new Size(280, 82), + PostPanelBounds = new Size(280, 80), PostPanelBottomMargin = new Thickness(0, 0, 0, 12), PostPanelPaimonHeight = 110, PostPanelPaimonMargin = new Thickness(0, -48, -56, 0), PostPanelPaimonInnerMargin = new Thickness(0, 0, 0, 0), - PostPanelPaimonTextMargin = new Thickness(0, 0, 0, 18), + PostPanelPaimonTextMargin = new Thickness(0, 0, 0, 20), PostPanelPaimonTextSize = 11, BannerIconHeight = 32, BannerIconHeightHYP = 32, diff --git a/CollapseLauncher/Classes/RegionManagement/RegionManagement.cs b/CollapseLauncher/Classes/RegionManagement/RegionManagement.cs index 5d61f80c5e..592ab1d5e9 100644 --- a/CollapseLauncher/Classes/RegionManagement/RegionManagement.cs +++ b/CollapseLauncher/Classes/RegionManagement/RegionManagement.cs @@ -168,14 +168,6 @@ Task BeforeLoadRoutine(CancellationToken token) LogWriteLine($"Initializing game: {regionToChangeName}...", LogType.Scheme, true); ClearMainPageState(); - - bool isCustom = LauncherConfig.GetAppConfigValue($"LastIsCustomImageEnabled-{preset.GameName}-{preset.ZoneName}").ToBool(); - if (!isCustom) - { - DispatcherQueue.TryEnqueue(() => - ImageBackgroundManager.Shared.PreloadBackground(preset, BackgroundPresenterGrid)); - } - _ = ShowAsyncLoadingTimedOutPill(token); } catch (Exception ex) @@ -395,8 +387,8 @@ private async Task LoadRegionRootButton() LogWriteLine($"Region changed to {gameRegion.ZoneFullname}", LogType.Scheme, true); #if !DISABLEDISCORD - if (AppDiscordPresence.IsRpcEnabled) - AppDiscordPresence.SetupPresence(gameRegion); + if (AppDiscordPresence.IsEnabled) + AppDiscordPresence.SetPresence(gameRegion); #endif } diff --git a/CollapseLauncher/Classes/RepairManagement/HonkaiV2/HonkaiRepairV2.AsbExt.Audio.cs b/CollapseLauncher/Classes/RepairManagement/HonkaiV2/HonkaiRepairV2.AsbExt.Audio.cs index de6205b8af..b7066e6948 100644 --- a/CollapseLauncher/Classes/RepairManagement/HonkaiV2/HonkaiRepairV2.AsbExt.Audio.cs +++ b/CollapseLauncher/Classes/RepairManagement/HonkaiV2/HonkaiRepairV2.AsbExt.Audio.cs @@ -1,4 +1,5 @@ using CollapseLauncher.Helper; +using CollapseLauncher.Helper.DaftarPustaka; using CollapseLauncher.Helper.Metadata; using CollapseLauncher.Interfaces; using Hi3Helper; @@ -6,7 +7,6 @@ using Hi3Helper.EncTool; using Hi3Helper.EncTool.Parser.AssetMetadata; using Hi3Helper.EncTool.Parser.KianaDispatch; -using Hi3Helper.EncTool.Parser.Senadina; using Hi3Helper.Plugin.Core.Management; using Hi3Helper.Preset; using Hi3Helper.Shared.ClassStruct; @@ -34,19 +34,19 @@ internal static partial class AssetBundleExtension internal static async Task> GetAudioAssetListAsync( - this HttpClient assetBundleHttpClient, - PresetConfig presetConfig, - GameVersion gameVersion, - KianaDispatch gameServerInfo, - SenadinaFileIdentifier? audioFileIdentifier, - ProgressBase progressibleInstance, - AudioPCKType[]? ignoredAudioIds = null, - CancellationToken token = default) + this HttpClient assetBundleHttpClient, + PresetConfig presetConfig, + GameVersion gameVersion, + KianaDispatch gameServerInfo, + ServeV3FileContext? context, + ProgressBase progressibleInstance, + AudioPCKType[]? ignoredAudioIds = null, + CancellationToken token = default) where T : IAssetIndexSummary { - HashSet ignoredAudioHashset = new(ignoredAudioIds ?? []); + HashSet ignoredAudioHashset = [.. ignoredAudioIds ?? []]; - ArgumentNullException.ThrowIfNull(audioFileIdentifier); + ArgumentNullException.ThrowIfNull(context); int parallelThread = progressibleInstance.ThreadForIONormalized; // Update Progress @@ -65,7 +65,7 @@ internal static async Task> .ManifestAudio .ManifestAudioRevision}"); - await using Stream manifestStream = audioFileIdentifier.fileStream ?? throw new NullReferenceException("Senadina Audio Identifier Stream cannot be null!"); + await using Stream manifestStream = context.Stream ?? throw new NullReferenceException("Senadina Audio Identifier Stream cannot be null!"); KianaAudioManifest manifestData = new(manifestStream, gameVersion.VersionArrayManifest); diff --git a/CollapseLauncher/Classes/RepairManagement/HonkaiV2/HonkaiRepairV2.AsbExt.Block.cs b/CollapseLauncher/Classes/RepairManagement/HonkaiV2/HonkaiRepairV2.AsbExt.Block.cs index b5d9b1adb2..85c8b66f88 100644 --- a/CollapseLauncher/Classes/RepairManagement/HonkaiV2/HonkaiRepairV2.AsbExt.Block.cs +++ b/CollapseLauncher/Classes/RepairManagement/HonkaiV2/HonkaiRepairV2.AsbExt.Block.cs @@ -58,8 +58,8 @@ internal static async Task> progressibleInstance.Status.IsIncludePerFileIndicator = false; progressibleInstance.UpdateStatus(); - await using Stream xmfMetaCurrentFileStream = senadinaResults.XmfMeta?.fileStream ?? throw new NullReferenceException("Senadina BlockMeta Identifier Stream cannot be null!"); - await using Stream xmfPatchCurrentFileStream = senadinaResults.XmfPatch?.fileStream ?? throw new NullReferenceException("Senadina BlockPatch Identifier Stream cannot be null!"); + await using Stream xmfMetaCurrentFileStream = senadinaResults.XmfMeta?.Stream ?? throw new NullReferenceException("Senadina BlockMeta Identifier Stream cannot be null!"); + await using Stream xmfPatchCurrentFileStream = senadinaResults.XmfPatch?.Stream ?? throw new NullReferenceException("Senadina BlockPatch Identifier Stream cannot be null!"); XMFParser xmfMetaParser = new(string.Empty, xmfMetaCurrentFileStream, true); BlockPatchManifest xmfPatchParser = new(xmfPatchCurrentFileStream); @@ -101,8 +101,7 @@ internal static async Task> foreach (XMFBlock xmfBlock in xmfMetaParser.BlockEntry) { - ref BlockPatchInfo patchInfoRef = - ref CollectionsMarshal.GetValueRefOrNullRef(patchInfos, xmfBlock.BlockName); + ref BlockPatchInfo patchInfoRef = ref CollectionsMarshal.GetValueRefOrNullRef(patchInfos, xmfBlock.BlockName); string asbBaseUrl = progressibleInstance.GetRandomAsbBaseUrl(gameServerInfo); string assetUrl = diff --git a/CollapseLauncher/Classes/RepairManagement/HonkaiV2/HonkaiRepairV2.Check.Generic.cs b/CollapseLauncher/Classes/RepairManagement/HonkaiV2/HonkaiRepairV2.Check.Generic.cs index 1dbb343594..ae849696e9 100644 --- a/CollapseLauncher/Classes/RepairManagement/HonkaiV2/HonkaiRepairV2.Check.Generic.cs +++ b/CollapseLauncher/Classes/RepairManagement/HonkaiV2/HonkaiRepairV2.Check.Generic.cs @@ -1,11 +1,11 @@ using CollapseLauncher.Helper; +using CollapseLauncher.Helper.DaftarPustaka; using CollapseLauncher.Helper.StreamUtility; using CollapseLauncher.RepairManagement; using Hi3Helper; using Hi3Helper.Data; using Hi3Helper.EncTool; using Hi3Helper.EncTool.Hashes; -using Hi3Helper.EncTool.Parser.Senadina; using Hi3Helper.Shared.ClassStruct; using System; using System.IO; @@ -158,71 +158,85 @@ private async ValueTask TryIsAssetRemoteSizeEquals( hmacKey ??= (asset.AssociatedObject as CacheAssetInfo)?.HmacSha1Salt; HashOperationStatus resultStatus = HashOperationStatus.InvalidOperation; - switch (hashSize) + if (asset.AssociatedObject is ServeV3FileContext daftarPustakaContext) { - case 8: + NonCryptographicHashAlgorithm hasher = daftarPustakaContext.DataHashType switch { - MhyMurmurHash264B hasher = new((ulong)assetFileInfo.Length); - HashUtility hashUtil = HashUtility.ThreadSafe; - - (resultStatus, _) = - await hashUtil - .TryGetHashFromStreamAsync(hasher, - assetFileStream, - hashBufferSpan, - ImplReadBytesAction, - bufferSize, - token); - break; - } - case MD5.HashSizeInBytes when asset.AssociatedObject is SenadinaFileIdentifier: - { - XxHash128 hasher = new(); - HashUtility hashUtil = HashUtility.ThreadSafe; - - (resultStatus, _) = - await hashUtil - .TryGetHashFromStreamAsync(hasher, - assetFileStream, - hashBufferSpan, - ImplReadBytesAction, - bufferSize, - token); - break; - } - case MD5.HashSizeInBytes: + nameof(XxHash3) => new XxHash3(), + nameof(XxHash32) => new XxHash32(), + nameof(XxHash64) => new XxHash64(), + nameof(XxHash128) => new XxHash128(), + nameof(Crc32) => new Crc32(), + nameof(Crc64) => new Crc64(), + + _ => throw new NotSupportedException() + }; + + (resultStatus, _) = await (daftarPustakaContext.DataHashType switch { - using HashAlgorithm hasher = hmacKey != null ? new HMACMD5(hmacKey) : MD5.Create(); - CryptoHashUtility hashUtil = CryptoHashUtility.ThreadSafe; - - (resultStatus, _) = - await hashUtil - .TryGetHashFromStreamAsync(hasher, - assetFileStream, - hashBufferSpan, - ImplReadBytesAction, - hmacKey, - bufferSize, - false, - token); - break; - } - case SHA1.HashSizeInBytes: + nameof(XxHash3) => HashUtility.ThreadSafe.TryGetHashFromStreamAsync(hasher, assetFileStream, hashBufferSpan, ImplReadBytesAction, bufferSize, token), + nameof(XxHash32) => HashUtility.ThreadSafe.TryGetHashFromStreamAsync(hasher, assetFileStream, hashBufferSpan, ImplReadBytesAction, bufferSize, token), + nameof(XxHash64) => HashUtility.ThreadSafe.TryGetHashFromStreamAsync(hasher, assetFileStream, hashBufferSpan, ImplReadBytesAction, bufferSize, token), + nameof(XxHash128) => HashUtility.ThreadSafe.TryGetHashFromStreamAsync(hasher, assetFileStream, hashBufferSpan, ImplReadBytesAction, bufferSize, token), + nameof(Crc32) => HashUtility.ThreadSafe.TryGetHashFromStreamAsync(hasher, assetFileStream, hashBufferSpan, ImplReadBytesAction, bufferSize, token), + nameof(Crc64) => HashUtility.ThreadSafe.TryGetHashFromStreamAsync(hasher, assetFileStream, hashBufferSpan, ImplReadBytesAction, bufferSize, token), + + _ => throw new NotSupportedException() + }); + } + else + { + switch (hashSize) { - using HashAlgorithm hasher = hmacKey != null ? new HMACSHA1(hmacKey) : SHA1.Create(); - CryptoHashUtility hashUtil = CryptoHashUtility.ThreadSafe; - - (resultStatus, _) = - await hashUtil - .TryGetHashFromStreamAsync(hasher, - assetFileStream, - hashBufferSpan, - ImplReadBytesAction, - hmacKey, - bufferSize, - false, - token); - break; + case 8: + { + MhyMurmurHash264B hasher = new((ulong)assetFileInfo.Length); + HashUtility hashUtil = HashUtility.ThreadSafe; + + (resultStatus, _) = + await hashUtil + .TryGetHashFromStreamAsync(hasher, + assetFileStream, + hashBufferSpan, + ImplReadBytesAction, + bufferSize, + token); + break; + } + case MD5.HashSizeInBytes: + { + using HashAlgorithm hasher = hmacKey != null ? new HMACMD5(hmacKey) : MD5.Create(); + CryptoHashUtility hashUtil = CryptoHashUtility.ThreadSafe; + + (resultStatus, _) = + await hashUtil + .TryGetHashFromStreamAsync(hasher, + assetFileStream, + hashBufferSpan, + ImplReadBytesAction, + hmacKey, + bufferSize, + false, + token); + break; + } + case SHA1.HashSizeInBytes: + { + using HashAlgorithm hasher = hmacKey != null ? new HMACSHA1(hmacKey) : SHA1.Create(); + CryptoHashUtility hashUtil = CryptoHashUtility.ThreadSafe; + + (resultStatus, _) = + await hashUtil + .TryGetHashFromStreamAsync(hasher, + assetFileStream, + hashBufferSpan, + ImplReadBytesAction, + hmacKey, + bufferSize, + false, + token); + break; + } } } diff --git a/CollapseLauncher/Classes/RepairManagement/HonkaiV2/HonkaiRepairV2.Fetch.cs b/CollapseLauncher/Classes/RepairManagement/HonkaiV2/HonkaiRepairV2.Fetch.cs index b25d5f7a34..fd8899e15b 100644 --- a/CollapseLauncher/Classes/RepairManagement/HonkaiV2/HonkaiRepairV2.Fetch.cs +++ b/CollapseLauncher/Classes/RepairManagement/HonkaiV2/HonkaiRepairV2.Fetch.cs @@ -1,4 +1,5 @@ using CollapseLauncher.Extension; +using CollapseLauncher.Helper.DaftarPustaka; using CollapseLauncher.Helper.Metadata; using CollapseLauncher.Helper.StreamUtility; using CollapseLauncher.RepairManagement; @@ -7,7 +8,6 @@ using Hi3Helper.EncTool.Parser.AssetMetadata; using Hi3Helper.EncTool.Parser.CacheParser; using Hi3Helper.EncTool.Parser.KianaDispatch; -using Hi3Helper.EncTool.Parser.Senadina; using Hi3Helper.Shared.ClassStruct; using Hi3Helper.Sophon; using Microsoft.Win32; @@ -35,10 +35,10 @@ internal partial class HonkaiRepairV2 { internal class SenadinaFileResult { - public SenadinaFileIdentifier? Audio { get; init; } - public SenadinaFileIdentifier? XmfMeta { get; init; } - public SenadinaFileIdentifier? XmfInfoCurrent { get; init; } - public SenadinaFileIdentifier? XmfPatch { get; init; } + public ServeV3FileContext? Audio { get; init; } + public ServeV3FileContext? XmfMeta { get; init; } + public ServeV3FileContext? XmfInfoCurrent { get; init; } + public ServeV3FileContext? XmfPatch { get; init; } } #region Fetch by Sophon @@ -303,7 +303,7 @@ private void FinalizeVideoAssetsPath(List assetList) private async Task FinalizeAudioAssetsPath(List originAssetList, List assetList, - SenadinaFileIdentifier? audioManifestIdentifier, + ServeV3FileContext? context, CancellationToken token) { // Edit: 2025-05-01 @@ -342,19 +342,18 @@ await EnsureAudioDefaultManifestExisted(HttpClientGeneric, audioDefaultManifestA .EnsureNoReadOnly() .StripAlternateDataStream(); - if (audioManifestIdentifier != null) + if (context != null) { - if (audioManifestIdentifier.lastOriginHash?.Length != 0) + if (context.OriginFileHash.Length != 0) { FilePropertiesRemote manifestAsset = new() { - AssociatedObject = audioManifestIdentifier, - CRC = HexTool.BytesToHexUnsafe(audioManifestIdentifier.lastOriginHash), + AssociatedObject = context, + CRC = HexTool.BytesToHexUnsafe(context.OriginFileHash), FT = FileType.Generic, - RN = audioManifestIdentifier.GetOriginalFileUrl(), + RN = context.GetOriginalFileUrl(), N = Path.Combine(AssetBundleExtension.RelativePathAudio, "manifest.m"), - S = (await HttpClientAssetBundle.GetURLStatusCode(audioManifestIdentifier.GetOriginalFileUrl(), - token)).FileSize + S = (await HttpClientAssetBundle.GetURLStatusCode(context.GetOriginalFileUrl(), token)).FileSize }; assetList.Add(manifestAsset); } @@ -367,9 +366,7 @@ await EnsureAudioDefaultManifestExisted(HttpClientGeneric, audioDefaultManifestA .StripAlternateDataStream(); - CDNCacheResult originManifestResponse = - await audioManifestIdentifier - .GetOriginalFileHttpResponse(HttpClientAssetBundle, token: token); + CDNCacheResult originManifestResponse = await context.GetOriginalFileHttpResponse(HttpClientAssetBundle, token: token); if (originManifestResponse.IsSuccessStatusCode) { await using Stream originManifestStreamRemote = originManifestResponse.Stream; @@ -494,26 +491,26 @@ await AddBlockAndMetaToAssetList("BlockMeta.xmf", return; - async Task AddBlockAndMetaToAssetList(string targetFilename, - SenadinaFileIdentifier? identifier, - CancellationToken innerToken) + async Task AddBlockAndMetaToAssetList(string targetFilename, + ServeV3FileContext? identifier, + CancellationToken innerToken) { if (identifier == null) { return; } - if (identifier.lastOriginHash?.Length != 0) + if (identifier.OriginFileHash.Length != 0) { FilePropertiesRemote manifestAsset = new() { AssociatedObject = identifier, - CRC = HexTool.BytesToHexUnsafe(identifier.lastOriginHash), + CRC = HexTool.BytesToHexUnsafe(identifier.OriginFileHash), FT = FileType.Generic, RN = identifier.GetOriginalFileUrl(), N = Path.Combine(AssetBundleExtension.RelativePathBlock, targetFilename), - S = (await HttpClientAssetBundle.GetURLStatusCode(identifier.GetOriginalFileUrl(), - innerToken)).FileSize + + S = (await HttpClientAssetBundle.GetURLStatusCode(identifier.GetOriginalFileUrl(), innerToken)).FileSize }; targetAssetList.Add(manifestAsset); } @@ -525,9 +522,7 @@ async Task AddBlockAndMetaToAssetList(string targetFilename, .EnsureNoReadOnly() .StripAlternateDataStream(); - CDNCacheResult originResponse = - await identifier - .GetOriginalFileHttpResponse(HttpClientAssetBundle, token: innerToken); + CDNCacheResult originResponse = await identifier.GetOriginalFileHttpResponse(HttpClientAssetBundle, token: innerToken); if (originResponse.IsSuccessStatusCode) { await using Stream originStreamRemote = originResponse.Stream; diff --git a/CollapseLauncher/Classes/RepairManagement/HonkaiV2/HonkaiRepairV2.SenadinaExt.cs b/CollapseLauncher/Classes/RepairManagement/HonkaiV2/HonkaiRepairV2.SenadinaExt.cs index e92c5635b9..601d0a30a4 100644 --- a/CollapseLauncher/Classes/RepairManagement/HonkaiV2/HonkaiRepairV2.SenadinaExt.cs +++ b/CollapseLauncher/Classes/RepairManagement/HonkaiV2/HonkaiRepairV2.SenadinaExt.cs @@ -1,18 +1,16 @@ using CollapseLauncher.Extension; using CollapseLauncher.Helper; +using CollapseLauncher.Helper.DaftarPustaka; using CollapseLauncher.Interfaces; using Hi3Helper; using Hi3Helper.Data; using Hi3Helper.EncTool; -using Hi3Helper.EncTool.Parser.AssetIndex; -using Hi3Helper.EncTool.Parser.Senadina; using Hi3Helper.Plugin.Core.Management; using Hi3Helper.Preset; using System; -using System.Buffers.Binary; using System.Collections.Generic; using System.IO; -using System.IO.Compression; +using System.IO.Hashing; using System.Net.Http; using System.Threading; using System.Threading.Tasks; @@ -53,20 +51,20 @@ internal static async Task } // Get the Senadina File Identifier Dictionary and its file references - var senadinaManifest + Dictionary? senadinaManifest = await client.GetSenadinaManifestAsync(mainUrl, secondaryUrl, false, token); - SenadinaFileIdentifier? senadinaAudioManifestIdentity = null; - SenadinaFileIdentifier? senadinaXmfMetaIdentity = null; - SenadinaFileIdentifier? senadinaXmfInfoCurrentIdentity = null; - SenadinaFileIdentifier? senadinaXmfPatchIdentity = null; + ServeV3FileContext? senadinaAudioManifestIdentity = null; + ServeV3FileContext? senadinaXmfMetaIdentity = null; + ServeV3FileContext? senadinaXmfInfoCurrentIdentity = null; + ServeV3FileContext? senadinaXmfPatchIdentity = null; Task senadinaAudioManifestTask = client.GetSenadinaIdentifierKind(senadinaManifest, - SenadinaKind.chiptunesCurrent, + "current/manifest.m", gameVersion, mainUrl, secondaryUrl, @@ -76,7 +74,7 @@ var senadinaManifest Task senadinaXmfMetaTask = client.GetSenadinaIdentifierKind(senadinaManifest, - SenadinaKind.platformBase, + "BlocksMeta.xmf", gameVersion, mainUrl, secondaryUrl, @@ -86,7 +84,7 @@ var senadinaManifest Task senadinaXmfInfoCurrentTask = client.GetSenadinaIdentifierKind(senadinaManifest, - SenadinaKind.bricksCurrent, + "current/Blocks.xmf", gameVersion, mainUrl, secondaryUrl, @@ -96,7 +94,7 @@ var senadinaManifest Task senadinaXmfPatchTask = client.GetSenadinaIdentifierKind(senadinaManifest, - SenadinaKind.wandCurrent, + "PatchConfig.xmf", gameVersion, mainUrl, secondaryUrl, @@ -128,67 +126,48 @@ await Task.WhenAll(senadinaAudioManifestTask, }; } - private static async Task ThrowIfFileIsNotSenadina(this Stream stream, CancellationToken token) - { - ArgumentNullException.ThrowIfNull(stream); - - Memory header = new byte[AssetBundleReference.CollapseHeaderBytes.Length]; - _ = await stream.ReadAsync(header, token); - if (!header.Span.SequenceEqual(AssetBundleReference.CollapseHeaderBytes)) - throw new InvalidDataException($"Daftar pustaka file is corrupted! Expecting header: 0x{BinaryPrimitives.ReadInt64LittleEndian(AssetBundleReference.CollapseHeaderBytes):x8} but got: 0x{BinaryPrimitives.ReadInt64LittleEndian(header.Span):x8} instead!"); - } - - private static async Task + private static async Task GetSenadinaIdentifierKind( - this HttpClient client, - Dictionary? dict, - SenadinaKind kind, - GameVersion gameVersion, - string? mainUrl, - string? secondaryUrl, - bool skipThrow, - CancellationToken token) + this HttpClient client, + Dictionary? dict, + string indexFile, + GameVersion gameVersion, + string? mainUrl, + string? secondaryUrl, + bool skipThrow, + CancellationToken token) { mainUrl ??= secondaryUrl; ArgumentNullException.ThrowIfNull(dict); try { - string origFileRelativePath = $"{gameVersion.Major}_{gameVersion.Minor}_{kind.ToString().ToLower()}"; - string hashedRelativePath = SenadinaFileIdentifier.GetHashedString(origFileRelativePath); - - string fileUrl = mainUrl.CombineURLFromString(hashedRelativePath); - if (!dict.TryGetValue(origFileRelativePath, out SenadinaFileIdentifier? identifier)) + if (!dict.TryGetValue(indexFile, out ServeV3FileContext? identifier)) { - Logger.LogWriteLine($"Key reference to the pustaka file: {hashedRelativePath} is not found for game version: {gameVersion}. Please contact us on our Discord Server to report this issue.", LogType.Error, true); + Logger.LogWriteLine($"Key reference to the pustaka file: {indexFile} is not found for game version: {gameVersion}. Please contact us on our Discord Server to report this issue.", LogType.Error, true); if (skipThrow) return null; - throw new - FileNotFoundException("Assets reference for repair is not found. " + - "Please contact us in GitHub issues or Discord to let us know about this issue."); + throw new FileNotFoundException("Assets reference for repair is not found. Please contact us in GitHub issues or Discord to let us know about this issue."); } - CDNCacheResult result = await client.TryGetCachedStreamFrom(fileUrl, token: token); - Stream networkStream = result.Stream; + string fileDictKey = ServeV3FileContext.GetHashStringAuto(indexFile, identifier); + string fileUrl = mainUrl.CombineURLFromString(fileDictKey); - await ThrowIfFileIsNotSenadina(networkStream, token); - identifier.fileStream = SenadinaFileIdentifier.CreateKangBakso(networkStream, identifier.lastIdentifier!, origFileRelativePath, (int)identifier.fileTime); - identifier.relativePath = origFileRelativePath; + CDNCacheResult result = await client.TryGetCachedStreamFrom(fileUrl, token: token); + identifier.Stream = DaftarPustakaUtil.Reader.CreateCryptoStream(result.Stream, identifier); return identifier; } catch (Exception ex) { - Logger.LogWriteLine($"[Senadina::Identifier] Failed while fetching Senadina's identifier kind: {kind} from URL: {mainUrl}\r\n{ex}", LogType.Error, true); - if (skipThrow) - { - throw; - } - Logger.LogWriteLine($"[Senadina::Identifier] Trying to get Senadina's identifier kind: {kind} from secondary URL: {secondaryUrl}", LogType.Warning, true); - return await GetSenadinaIdentifierKind(client, dict, kind, gameVersion, null, secondaryUrl, true, token); + Logger.LogWriteLine($"[Senadina::Identifier] Failed while fetching Senadina's identifier kind: {indexFile} from URL: {mainUrl}\r\n{ex}", LogType.Error, true); + if (!skipThrow) throw; + + Logger.LogWriteLine($"[Senadina::Identifier] Trying to get Senadina's identifier kind: {indexFile} from secondary URL: {secondaryUrl}", LogType.Warning, true); + return await client.GetSenadinaIdentifierKind(dict, indexFile, gameVersion, null, secondaryUrl, true, token); } } - private static async Task?> + private static async Task?> GetSenadinaManifestAsync(this HttpClient client, string? mainUrl, string? secondaryUrl, @@ -198,18 +177,17 @@ private static async Task?> mainUrl ??= secondaryUrl; try { - string identifierUrl = mainUrl.CombineURLFromString("daftar-pustaka"); - await using Stream fileIdentifierStream = (await client.TryGetCachedStreamFrom(identifierUrl, token: token)).Stream; - await using Stream fileIdentifierStreamDecoder = new BrotliStream(fileIdentifierStream, CompressionMode.Decompress, true); + string identifierUrl = mainUrl.CombineURLFromString("index.v3"); + await using Stream remoteStream = (await client.TryGetCachedStreamFrom(identifierUrl, token: token)).Stream; + await using Stream remoteStreamDecrypt = DaftarPustakaUtil.Reader.CreateCryptoStream(remoteStream); - await ThrowIfFileIsNotSenadina(fileIdentifierStream, token); #if DEBUG - using StreamReader rd = new StreamReader(fileIdentifierStreamDecoder); - string response = await rd.ReadToEndAsync(token); + using StreamReader rd = new(remoteStreamDecrypt); + string response = await rd.ReadToEndAsync(token); Logger.LogWriteLine($"[HonkaiRepair::GetSenadinaIdentifierDictionary() Dictionary Response:\r\n{response}", LogType.Debug, true); - return response.Deserialize(SenadinaJsonContext.Default.DictionaryStringSenadinaFileIdentifier); + return response.Deserialize(ServeV3FileContextContext.Default.DictionaryStringServeV3FileContext); #else - return await fileIdentifierStreamDecoder.DeserializeAsync(SenadinaJsonContext.Default.DictionaryStringSenadinaFileIdentifier, token: token); + return await remoteStreamDecrypt.DeserializeAsync(ServeV3FileContextContext.Default.DictionaryStringServeV3FileContext, token: token); #endif } catch (Exception ex) diff --git a/CollapseLauncher/CollapseLauncher.csproj b/CollapseLauncher/CollapseLauncher.csproj index f66aa6163e..e3d488246b 100644 --- a/CollapseLauncher/CollapseLauncher.csproj +++ b/CollapseLauncher/CollapseLauncher.csproj @@ -16,7 +16,7 @@ $(Company). neon-nyan, Cry0, bagusnl, shatyuka, gablm. Copyright 2022-2026 $(Company) - 1.84.6 + 1.84.7 preview x64 @@ -274,15 +274,15 @@ - + - - + + all - runtime; build; native; contentfiles; analyzers; buildtransitive + build; analyzers - + @@ -300,29 +300,31 @@ - - + + - + - + - - + + + + @@ -477,10 +479,6 @@ --> - - - - @@ -494,7 +492,6 @@ - diff --git a/CollapseLauncher/Program.cs b/CollapseLauncher/Program.cs index 9ab411f59e..5215f28577 100644 --- a/CollapseLauncher/Program.cs +++ b/CollapseLauncher/Program.cs @@ -1,6 +1,7 @@ using CollapseLauncher.Extension; using CollapseLauncher.Helper; using CollapseLauncher.Helper.Database; +using CollapseLauncher.Helper.Image; using CollapseLauncher.Helper.InternalPInvoke; using CollapseLauncher.Helper.Update; using Hi3Helper; @@ -8,6 +9,7 @@ using Hi3Helper.EncTool; using Hi3Helper.EncTool.Hashes; using Hi3Helper.Http.Legacy; +using Hi3Helper.MagicScaler.LibheifCL; using Hi3Helper.SentryHelper; using Hi3Helper.Shared.ClassStruct; using Hi3Helper.Win32.ManagedTools; @@ -18,13 +20,15 @@ using Microsoft.UI.Xaml.Settings; using Microsoft.Win32; using PhotoSauce.MagicScaler; -using PhotoSauce.NativeCodecs.Libheif; using PhotoSauce.NativeCodecs.Libjxl; using PhotoSauce.NativeCodecs.Libwebp; using System; +using System.Buffers; +using System.Collections.Generic; using System.Diagnostics; using System.Globalization; using System.IO; +using System.Linq; using System.Reflection; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; @@ -181,7 +185,6 @@ private static void InitializeExperimentalWinUIFeatures() // https://github.com/sundaramramaswamy/microsoft-ui-xaml/blob/069fbc9683b3b07df5549961e00251439a6916cd/specs/XamlOptionalChanges/XamlOptionalChanges-Spec.md#xamlchangeid-enum EnableXamlOpts(XamlChangeId.DefaultStyleOptimizations, XamlChangeId.DeferContextFlyoutInit, - XamlChangeId.IconNoGridOptimization, XamlChangeId.OptimizeApplyStyles); return; @@ -319,10 +322,17 @@ private static void InitCriticalModules() * Module: Libzstd */ +#if !NET11_0_OR_GREATER // Basically, the Libzstd's DLL will be checked if they exist on Non-AOT build. // But due to AOT build uses Static Library in favor of Shared ones (that comes // with .dll files), the check will be ignored. ZstdNet.DllUtils.IsIgnoreMissingLibrary = true; +#endif + + /* --------------------------------------------------------------------------------------------- + * Module: MagicScaler External Codecs for Image Decoding + */ + InitMagicScalerExternalCodecs(); /* --------------------------------------------------------------------------------------------- * Module: Velopack @@ -368,9 +378,9 @@ private static async void InitOtherSdkAsync() await InitDatabaseHandler(); /* --------------------------------------------------------------------------------------------- - * Module: MagicScaler External Codecs for Image Decoding + * Module: Waifu2X (Start device test in the background and cache it) */ - InitMagicScalerExternalCodecs(); + ImageLoaderHelper.EnsureWaifu2X(); } catch (Exception ex) { @@ -382,12 +392,7 @@ private static void InitMagicScalerExternalCodecs() { try { - CodecManager.Configure(codecs => - { - codecs.UseLibwebp(); - codecs.UseLibheif(); - codecs.UseLibjxl(); - }); + CodecManager.Configure(RegisterExternalCodec); } catch (Exception ex) { @@ -395,6 +400,29 @@ private static void InitMagicScalerExternalCodecs() LogType.Error, true); } + + return; + + static void RegisterExternalCodec(CodecCollection codecs) + { + // Remove built-in WIC decoder from the list before adding another ones. + SearchValues searchValues = SearchValues.Create([ + "Microsoft HEIF", + "Microsoft Webp", + "Microsoft JPEG-XL", + "Microsoft JXL" + ], StringComparison.OrdinalIgnoreCase); + + List toDeleteList = [.. codecs.Where(x => x.Name.ContainsAny(searchValues))]; + foreach (IImageCodecInfo toDelete in toDeleteList) + { + codecs.Remove(toDelete); + } + + codecs.UseLibwebp(); + codecs.UseLibheifCL(); + codecs.UseLibjxl(); + } } private static void InitSentrySdk() diff --git a/CollapseLauncher/Properties/launchSettings.json b/CollapseLauncher/Properties/launchSettings.json index eb3b60bd14..1afb9fd779 100644 --- a/CollapseLauncher/Properties/launchSettings.json +++ b/CollapseLauncher/Properties/launchSettings.json @@ -2,8 +2,8 @@ "profiles": { "Collapse Launcher": { "commandName": "Project", - "nativeDebugging": false, - "hotReloadEnabled": true + "hotReloadEnabled": true, + "debugEngines": "managed-dotnet" }, "OOBE Setup": { "commandName": "Project", diff --git a/CollapseLauncher/XAMLs/MainApp/MainPage.Navigation.cs b/CollapseLauncher/XAMLs/MainApp/MainPage.Navigation.cs index fb89139045..3cfcba9d98 100644 --- a/CollapseLauncher/XAMLs/MainApp/MainPage.Navigation.cs +++ b/CollapseLauncher/XAMLs/MainApp/MainPage.Navigation.cs @@ -203,11 +203,13 @@ void Impl() GameNameType.StarRail => typeof(StarRailGameSettingsPage), GameNameType.Genshin => typeof(GenshinGameSettingsPage), GameNameType.Zenless => typeof(ZenlessGameSettingsPage), + GameNameType.Plugin when presetConfig is PluginPresetConfigWrapper + { GameSettingsContext.HasPage: true } => typeof(PluginGameSettingsPage), _ => null }; NavigationViewItemsContext.GameSettingsPage.Item.Tag = gspPageType; - NavigationViewItemsContext.GameSettingsPage.Item.Visibility = isPluginGame ? Visibility.Collapsed : Visibility.Visible; + NavigationViewItemsContext.GameSettingsPage.Item.Visibility = gspPageType == null ? Visibility.Collapsed : Visibility.Visible; NavigationViewItemsContext.FileCleanupPage.Item.Visibility = isPluginGame ? Visibility.Collapsed : Visibility.Visible; } } @@ -378,10 +380,17 @@ typeOfPageObj is Type currentPageType && { case Type pageType: { - if (pageType.Name.EndsWith("GameSettingsPage") && - !IsGameInstalled()) + if (pageType.Name.EndsWith("GameSettingsPage")) { - pageType = typeof(NotInstalledPage); + if (!IsLoadRegionComplete) + { + return false; + } + + if (!IsGameInstalled()) + { + pageType = typeof(NotInstalledPage); + } } LauncherFrame.Navigate(pageType, null, transitionInfo ?? new DrillInNavigationTransitionInfo()); diff --git a/CollapseLauncher/XAMLs/MainApp/MainPage.Notification.cs b/CollapseLauncher/XAMLs/MainApp/MainPage.Notification.cs index 14692283af..aa0b65d693 100644 --- a/CollapseLauncher/XAMLs/MainApp/MainPage.Notification.cs +++ b/CollapseLauncher/XAMLs/MainApp/MainPage.Notification.cs @@ -1,4 +1,5 @@ using CollapseLauncher.Extension; +using CollapseLauncher.GameManagement.ImageBackground; using CollapseLauncher.Helper; using CollapseLauncher.Helper.StreamUtility; using CollapseLauncher.Helper.Update; diff --git a/CollapseLauncher/XAMLs/MainApp/MainPage.xaml b/CollapseLauncher/XAMLs/MainApp/MainPage.xaml index 12d2dcafd4..fe14e7d822 100644 --- a/CollapseLauncher/XAMLs/MainApp/MainPage.xaml +++ b/CollapseLauncher/XAMLs/MainApp/MainPage.xaml @@ -27,14 +27,14 @@ + + BackgroundSource="{x:Bind PlaceholderBackgroundImage, Converter={StaticResource ImageDecoderValueConverter}}"> @@ -444,7 +444,7 @@ Margin="-2,-2,0,-2" VerticalAlignment="Stretch" Opacity="0.8" - Source="{ThemeResource AppLogo}"> + Source="ms-appx:///Assets/CollapseLauncherLogo.png"> diff --git a/CollapseLauncher/XAMLs/MainApp/MainPage.xaml.cs b/CollapseLauncher/XAMLs/MainApp/MainPage.xaml.cs index 979c2f79d8..15e7f7258c 100644 --- a/CollapseLauncher/XAMLs/MainApp/MainPage.xaml.cs +++ b/CollapseLauncher/XAMLs/MainApp/MainPage.xaml.cs @@ -72,7 +72,6 @@ private void OnPropertyChanged([CallerMemberName] string? propertyName = null) private int _currentGameRegion = -1; internal Uri PlaceholderBackgroundImage => new(ImageBackgroundManager.GetPlaceholderBackgroundImageFrom(CurrentGameProperty?.GamePreset)); - internal string PlaceholderDecodedCacheDir => AppGameImgFolder; internal ImageBackgroundManager CurrentBackgroundManager => ImageBackgroundManager.Shared; #endregion @@ -213,7 +212,7 @@ private async Task InitializeStartup() bool isEnableDiscord = GetAppConfigValue("EnableDiscordRPC"); if (isEnableDiscord) { - InnerLauncherConfig.AppDiscordPresence.SetupPresence(presetConfig); + InnerLauncherConfig.AppDiscordPresence.SetPresence(presetConfig); } } @@ -841,8 +840,8 @@ private async void ChangeToActivatedRegion() if (await LoadRegionFromCurrentConfigV2(preset, gameName, gameRegion)) { #if !DISABLEDISCORD - if (InnerLauncherConfig.AppDiscordPresence.IsRpcEnabled && !sameRegion) - InnerLauncherConfig.AppDiscordPresence.SetupPresence(preset); + if (InnerLauncherConfig.AppDiscordPresence.IsEnabled && !sameRegion) + InnerLauncherConfig.AppDiscordPresence.SetPresence(preset); #endif InvokeLoadingRegionPopup(false); LauncherFrame.BackStack.Clear(); diff --git a/CollapseLauncher/XAMLs/MainApp/Pages/CachesPage.xaml.cs b/CollapseLauncher/XAMLs/MainApp/Pages/CachesPage.xaml.cs index 3561607a95..b892c0ff7a 100644 --- a/CollapseLauncher/XAMLs/MainApp/Pages/CachesPage.xaml.cs +++ b/CollapseLauncher/XAMLs/MainApp/Pages/CachesPage.xaml.cs @@ -270,7 +270,7 @@ or GameInstallStateEnum.InstalledHavePlugin else { #if !DISABLEDISCORD - AppDiscordPresence.SetActivity(ActivityType.Cache); + AppDiscordPresence.SetActivity(DiscordActivityType.Cache); #endif } } diff --git a/CollapseLauncher/XAMLs/MainApp/Pages/Dialogs/SimpleDialogs.cs b/CollapseLauncher/XAMLs/MainApp/Pages/Dialogs/SimpleDialogs.cs index cf5038c333..13fa84dba2 100644 --- a/CollapseLauncher/XAMLs/MainApp/Pages/Dialogs/SimpleDialogs.cs +++ b/CollapseLauncher/XAMLs/MainApp/Pages/Dialogs/SimpleDialogs.cs @@ -2,6 +2,7 @@ using CollapseLauncher.GameManagement.ImageBackground; using CollapseLauncher.Helper; using CollapseLauncher.Helper.Animation; +using CollapseLauncher.Helper.InternalPInvoke; using CollapseLauncher.Helper.Loading; using CollapseLauncher.Helper.Metadata; using CollapseLauncher.InstallManager.Base; @@ -1523,7 +1524,7 @@ await SpawnDialog(Locale.Current.Lang?._Dialogs?.Media_VideoFFmpegCodecPrepareTi goto StartOver; } - foundFfmpegDir = ImageBackgroundManager.FindFFmpegInstallFolder(ffmpegDir, ffmpegLibNames); + foundFfmpegDir = FFmpegPInvoke.FindFFmpegInstallFolder(ffmpegDir, ffmpegLibNames); if (string.IsNullOrEmpty(foundFfmpegDir)) { await SpawnDialog(Locale.Current.Lang?._Dialogs?.Media_VideoFFmpegCodecPrepareLocateFailedTitle, diff --git a/CollapseLauncher/XAMLs/MainApp/Pages/GameSettingsPages/GameSettingsPageBase.cs b/CollapseLauncher/XAMLs/MainApp/Pages/GameSettingsPages/GameSettingsPageBase.cs index 548c2d905e..13d45482e3 100644 --- a/CollapseLauncher/XAMLs/MainApp/Pages/GameSettingsPages/GameSettingsPageBase.cs +++ b/CollapseLauncher/XAMLs/MainApp/Pages/GameSettingsPages/GameSettingsPageBase.cs @@ -1,10 +1,12 @@ -using CollapseLauncher.Extension; +using CollapseLauncher.Helper.Database; +using CollapseLauncher.Extension; using CollapseLauncher.Helper; using CollapseLauncher.Interfaces; using CollapseLauncher.RegistryUtils; using CollapseLauncher.Statics; using Hi3Helper; using Hi3Helper.SentryHelper; +using Microsoft.UI.Text; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Data; @@ -94,7 +96,9 @@ protected GameSettingsPageBase(IGameSettings? settings, RegistryKey? gameRegistr VerticalAlignment = VerticalAlignment.Center, Style = UIElementExtensions.GetApplicationResource + \ No newline at end of file diff --git a/CollapseLauncher/XAMLs/Theme/Button/TransparentDropDownButton.xaml b/CollapseLauncher/XAMLs/Theme/Button/TransparentDropDownButton.xaml new file mode 100644 index 0000000000..644baad54d --- /dev/null +++ b/CollapseLauncher/XAMLs/Theme/Button/TransparentDropDownButton.xaml @@ -0,0 +1,73 @@ + + + \ No newline at end of file diff --git a/CollapseLauncher/XAMLs/Theme/CustomControls/ExtensionDetectUtility.cs b/CollapseLauncher/XAMLs/Theme/CustomControls/ExtensionDetectUtility.cs index 9927954591..b7cbd03ce8 100644 --- a/CollapseLauncher/XAMLs/Theme/CustomControls/ExtensionDetectUtility.cs +++ b/CollapseLauncher/XAMLs/Theme/CustomControls/ExtensionDetectUtility.cs @@ -37,12 +37,12 @@ public static async ValueTask GuessImageFormatFromStream } } - private static ImageExternalCodecType IsHeaderInternalCodecImage(scoped Span buffer) + public static ImageExternalCodecType IsHeaderInternalCodecImage(scoped Span buffer) { // Checks for JPEG ReadOnlySpan jpegSig1 = [0xFF, 0xD8, 0xFF]; - ReadOnlySpan jpegSigJfif = "JFIF"u8; - ReadOnlySpan jpegSigExif = "Exif"u8; + var jpegSigJfif = "JFIF"u8; + var jpegSigExif = "Exif"u8; bool isJpeg = buffer.StartsWith(jpegSig1) && (buffer[6..].StartsWith(jpegSigJfif) || buffer[6..].StartsWith(jpegSigExif)); if (isJpeg) @@ -60,7 +60,7 @@ private static ImageExternalCodecType IsHeaderInternalCodecImage(scoped Span bmpSig = "BM"u8; + var bmpSig = "BM"u8; bool isBmp = buffer.StartsWith(bmpSig); if (isBmp) @@ -69,8 +69,8 @@ private static ImageExternalCodecType IsHeaderInternalCodecImage(scoped Span gifSig1 = "GIF87a"u8; - ReadOnlySpan gifSig2 = "GIF89a"u8; + var gifSig1 = "GIF87a"u8; + var gifSig2 = "GIF89a"u8; bool isGif = buffer.StartsWith(gifSig1) || buffer.StartsWith(gifSig2); if (isGif) @@ -79,8 +79,8 @@ private static ImageExternalCodecType IsHeaderInternalCodecImage(scoped Span tiffSig1 = "II*\0"u8; // Little-endian - ReadOnlySpan tiffSig2 = "MM\0*"u8; // Big-endian + var tiffSig1 = "II*\0"u8; // Little-endian + var tiffSig2 = "MM\0*"u8; // Big-endian bool isTiff = buffer.StartsWith(tiffSig1) || buffer.StartsWith(tiffSig2); if (isTiff) @@ -98,14 +98,18 @@ private static ImageExternalCodecType IsHeaderInternalCodecImage(scoped Span svgSig = "= 0; + var svgSig = "= 0; return isSvg ? ImageExternalCodecType.Svg : ImageExternalCodecType.NotSupported; } - private static ImageExternalCodecType IsHeaderExternalCodecImage(scoped Span buffer) + public static ImageExternalCodecType IsHeaderExternalCodecImage(scoped Span buffer) { // Checks for VP8 if (buffer.StartsWith("RIFF"u8) && diff --git a/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Const.cs b/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Const.cs new file mode 100644 index 0000000000..e3aa891613 --- /dev/null +++ b/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Const.cs @@ -0,0 +1,80 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace CollapseLauncher.XAMLs.Theme.CustomControls; + +public partial class LayeredBackgroundImage +{ + /// + /// All the sources in this list are common image formats supported by Windows Imaging Component (WIC). + /// Some formats might require additional codecs to be installed. + ///

+ /// The extensions list are taken from:
+ /// https://developer.mozilla.org/en-US/docs/Web/Media/Guides/Formats/Image_types#jpeg_joint_photographic_experts_group_image + ///
+ public static readonly HashSet SupportedImageBitmapExtensions = + [ + with(StringComparer.OrdinalIgnoreCase), + ".jpg", ".jpeg", ".jpe", ".jif", ".jfif", // "image/jpeg" + ".apng", ".png", // "image/apng" and "image/png" + ".bmp", // "image/bmp" + ".gif", // "image/gif" + ".ico", // "image/x-icon" + ".tif", ".tiff" // "image/tiff" + ]; + public static readonly HashSet.AlternateLookup> SupportedImageBitmapExtensionsLookup = + SupportedImageBitmapExtensions.GetAlternateLookup>(); + + public static readonly HashSet SupportedImageBitmapExternalCodecExtensions = + [ + with(StringComparer.OrdinalIgnoreCase), + ".jxr", // "image/jxr" (Requires additional codec) + ".avif", // "image/avif" (Requires additional codec) + ".heic", // "image/heic" (Requires additional codec) + ".heif", // "image/heif" (Requires additional codec) + ".webp" // "image/webp" (Requires additional codec) + ]; + public static readonly HashSet.AlternateLookup> SupportedImageBitmapExternalCodecExtensionsLookup = + SupportedImageBitmapExternalCodecExtensions.GetAlternateLookup>(); + + public static readonly HashSet SupportedImageVectorExtensions = + [ + with(StringComparer.OrdinalIgnoreCase), + ".svg" // "image/svg" + ]; + public static readonly HashSet.AlternateLookup> SupportedImageVectorExtensionsLookup = + SupportedImageVectorExtensions.GetAlternateLookup>(); + + public static readonly HashSet SupportedVideoExtensions = + [ + with(StringComparer.OrdinalIgnoreCase), + ".3gp", ".3gp2", // "video/3gp" + ".asf", ".wmv", // "video/wmv" + ".avi", // "video/avi" + ".flv", ".f4v", // "video/flv" + ".mp4", ".m4v", // "video/mp4" + ".mov", ".movie", ".qt", // "video/quicktime" + ".webm", // "video/webm" + ".mpg", ".mpeg", ".ts", ".tsv", ".ps", ".m2ts", ".mts", ".vob", // "video/mpeg" + ".ogv", // "video/ogg" + ".mkv", ".mks" // "video/matroska" + ]; + public static readonly HashSet.AlternateLookup> SupportedVideoExtensionsLookup = + SupportedVideoExtensions.GetAlternateLookup>(); + + public static readonly string SupportedImageFormatsFilter = + string.Join(";", SupportedImageBitmapExtensions.Select(x => $"*{x}")) + ";" + + string.Join(";", SupportedImageBitmapExternalCodecExtensions.Select(x => $"*{x}")) + ";" + + string.Join(";", SupportedImageVectorExtensions.Select(x => $"*{x}")); + + public static readonly string SupportedVideoFormatsFilter = + string.Join(";", SupportedVideoExtensions.Select(x => $"*{x}")); + + public static readonly Dictionary SupportedAllBackgroundFormatsFilter = + new() { + { "All supported formats", SupportedImageFormatsFilter + ';' + SupportedVideoFormatsFilter }, + { "Image formats", SupportedImageFormatsFilter }, + { "Video formats", SupportedVideoFormatsFilter } + }; +} \ No newline at end of file diff --git a/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Controls.cs b/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Controls.cs new file mode 100644 index 0000000000..363e37c7f5 --- /dev/null +++ b/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Controls.cs @@ -0,0 +1,155 @@ +using Microsoft.Graphics.Canvas; +using System; +using System.Threading.Tasks; +using Windows.Foundation; +using Windows.Graphics.DirectX; +using Windows.Graphics.Imaging; +using Windows.Media.Playback; + +#nullable enable +namespace CollapseLauncher.XAMLs.Theme.CustomControls; + +public partial class LayeredBackgroundImage +{ + public void ToggleMute(bool isMute) + { + if (!TryGetBackgroundSourceContext(out _, out MediaPlayer? player)) + return; + + if (isMute) + { + player.IsMuted = true; + return; + } + + player.IsMuted = false; + } + + public void SetVolume(double volume) + { + if (!TryGetBackgroundSourceContext(out SourceContext? sourceContext, out _)) + return; + + sourceContext.SetVolume(volume); + } + + public void Play() + { + if (!TryGetBackgroundSourceContext(out SourceContext? sourceContext, out MediaPlayer? player)) + return; + + player.Play(); + MediaPlayer_RaiseStateUpdate(player); + sourceContext.SetVolume(AudioVolume); + } + + public void Pause() + { + if (!TryGetBackgroundSourceContext(out SourceContext? sourceContext, out MediaPlayer? player)) + return; + + // Ignore if the player is already in paused, closed or stopped state. + if (player.CurrentState + is MediaPlayerState.Paused + or MediaPlayerState.Closed + or MediaPlayerState.Stopped) + return; + + // Use SourceContext's SetVolume onCompleted event to perform fade-out then pause action. + sourceContext.SetVolume(0d, () => + { + player.Pause(); + MediaPlayer_RaiseStateUpdate(player); + }); + } + + public void RestoreViewAllocation() + { + LoadAllPresenters(); + } + + public void ReleaseViewAllocation() + { + UnloadAllPresenters(); + } + + public async Task CopyCurrentFrame(bool includeBackground, bool includeForeground) + { + CanvasRenderTarget? backgroundCanvas = null; + CanvasRenderTarget? foregroundCanvas = null; + + try + { + if (includeForeground && + TryGetPresenterSourceContextFromParent(_foregroundGrid, out SourceContext? foregroundSourceContext)) + foregroundCanvas = await foregroundSourceContext.CopyCurrentFrame(); + + if (includeBackground && + TryGetPresenterSourceContextFromParent(_backgroundGrid, out SourceContext? backgroundSourceContext)) + backgroundCanvas = await backgroundSourceContext.CopyCurrentFrame(); + + if (backgroundCanvas == null && foregroundCanvas == null) + return null; + + BitmapSize backgroundSize = backgroundCanvas?.SizeInPixels ?? default; + BitmapSize foregroundSize = foregroundCanvas?.SizeInPixels ?? default; + float maxWidth = Math.Max(backgroundSize.Width, foregroundSize.Width); + float maxHeight = Math.Max(backgroundSize.Height, foregroundSize.Height); + + DirectXPixelFormat pixelFormat = (DirectXPixelFormat)Math.Max((int)(backgroundCanvas?.Format ?? 0), + (int)(foregroundCanvas?.Format ?? 0)); + + CanvasDevice canvasDevice = CanvasDevice.GetSharedDevice(); + CanvasRenderTarget canvasTarget = new(canvasDevice, maxWidth, maxHeight, 96, pixelFormat, CanvasAlphaMode.Premultiplied); + + using CanvasDrawingSession cds = canvasTarget.CreateDrawingSession(); + DrawUniformFill(cds, backgroundCanvas, maxWidth, maxHeight); + DrawUniformFill(cds, foregroundCanvas, maxWidth, maxHeight); + + return canvasTarget; + } + finally + { + backgroundCanvas?.Dispose(); + foregroundCanvas?.Dispose(); + } + + static void DrawUniformFill( + CanvasDrawingSession ds, + CanvasBitmap? source, + float destWidth, + float destHeight) + { + if (source == null) + return; + + float srcWidth = source.SizeInPixels.Width; + float srcHeight = source.SizeInPixels.Height; + + float scale = Math.Max(destWidth / srcWidth, destHeight / srcHeight); + + float drawWidth = srcWidth * scale; + float drawHeight = srcHeight * scale; + + float offsetX = (destWidth - drawWidth) * 0.5f; + float offsetY = (destHeight - drawHeight) * 0.5f; + + Rect destRect = new(offsetX, offsetY, drawWidth, drawHeight); + + ds.DrawImage(source, + destRect, + new Rect(0, 0, srcWidth, srcHeight), + 1f, + CanvasImageInterpolation.HighQualityCubic, + CanvasComposite.SourceOver); + } + } + + private void MediaPlayer_RaiseStateUpdate(MediaPlayer player) + { + DispatcherQueue.TryEnqueue(() => + { + SetValue(MediaStateProperty, player.CurrentState); + }); + } +} diff --git a/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Event.ElevateGrid.cs b/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Event.ElevateGrid.cs new file mode 100644 index 0000000000..9f256007d6 --- /dev/null +++ b/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Event.ElevateGrid.cs @@ -0,0 +1,134 @@ +using Microsoft.UI.Composition; +using Microsoft.UI.Xaml; +using System; +using System.Numerics; + +#nullable enable +namespace CollapseLauncher.XAMLs.Theme.CustomControls; + +public partial class LayeredBackgroundImage +{ + private void ElevateGrid_ToggleEnable(bool isEnable) + { + ElevateGrid_CalculateCenterPoint(); + ElevateGrid_StartElevationAnimation(isEnable); + } + + private void ElevateGrid_OnSizeChanged(object sender, SizeChangedEventArgs e) + { + bool isCurrentlyEnabled = IsBackgroundElevated; + if (!isCurrentlyEnabled) + { + return; + } + + ElevateGrid_CalculateCenterPoint(); + ElevateGrid_StartElevationAnimation(true); + } + + private void ElevateGrid_CalculateCenterPoint() + { + _elevateGridVisual.CenterPoint = new Vector3((float)_elevateGrid.RenderSize.Width / 2, + (float)_elevateGrid.RenderSize.Height / 2, + 0); + } + + private void ElevateGrid_StartElevationAnimation(bool enable) + { + double elevationOffset = enable + ? BackgroundElevationPixels + : 0d; + + StartElementElevateOffsetAnimation(_elevateGridCompositor, + _elevateGridVisual, + _elevateGrid, + elevationOffset, + elevationOffset, + 0d, + 0d, + 500d, + _elevateEasingFunction); + } + + private static void StartElementElevateOffsetAnimation( + Compositor compositor, + Visual visual, + UIElement element, + double offsetX, + double offsetY, + double centerPointX, + double centerPointY, + double durationMs, + CompositionEasingFunction? easingFunction = null, + bool useAnimatedOffset = true) + { + // Move opposite to center point + float tx = (float)(-centerPointX * offsetX); + float ty = (float)(-centerPointY * offsetY); + + // Scale with x2 as it counts with each side of axis (Left, Right) (Top, Bottom) + Vector2 size = element.ActualSize; + double sizeToX = size.X + Math.Abs(offsetX) * 2; + double sizeToY = size.Y + Math.Abs(offsetY) * 2; + + double addScaleX = sizeToX / size.X; + double addScaleY = sizeToY / size.Y; + + // Gets the stronger axis + float factorScale = (float)Math.Max(addScaleX, addScaleY); + + StartElementElevateAnimation(compositor, + visual, + Vector3.Zero with { X = tx, Y = ty }, + Vector3.One with { X = factorScale, Y = factorScale }, + durationMs, + easingFunction, + useAnimatedOffset); + } + + private static void StartElementElevateAnimation( + Compositor compositor, + Visual visual, + Vector3 offset, + Vector3 scale, + double duration, + CompositionEasingFunction? easingFunction = null, + bool useAnimatedOffset = true) + { + const string targetTranslation = "Offset"; + const string targetScale = "Scale"; + + if (compositor == null!) + { + return; + } + + // Scale + Vector3KeyFrameAnimation scaleAnim = CreateVector3Keyframe(scale, targetScale); + visual.StartAnimation(targetScale, scaleAnim); + + if (useAnimatedOffset) + { + // Move + Vector3KeyFrameAnimation offsetAnim = CreateVector3Keyframe(offset, targetTranslation); + visual.StartAnimation(targetTranslation, offsetAnim); + return; + } + + // Directly set visual offset + visual.Offset = offset; + return; + + Vector3KeyFrameAnimation CreateVector3Keyframe( + Vector3 valueTo, + string targetProperty) + { + Vector3KeyFrameAnimation keyframe = compositor.CreateVector3KeyFrameAnimation(); + keyframe.InsertKeyFrame(1, valueTo, easingFunction); + keyframe.Target = targetProperty; + keyframe.Duration = TimeSpan.FromMilliseconds(duration); + + return keyframe; + } + } +} diff --git a/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Event.ParallaxGrid.cs b/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Event.ParallaxGrid.cs new file mode 100644 index 0000000000..e76161ea8c --- /dev/null +++ b/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Event.ParallaxGrid.cs @@ -0,0 +1,157 @@ +using CollapseLauncher.Helper; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Input; +using System; +using System.Numerics; +using Windows.Foundation; + +#nullable enable +namespace CollapseLauncher.XAMLs.Theme.CustomControls; + +public partial class LayeredBackgroundImage +{ + #region Fields + + private FrameworkElement? _lastParallaxHoverSource; + + #endregion + + private void ParallaxGrid_ToggleEnable(bool isEnable) + { + if (isEnable) + { + ParallaxGrid_RegisterEffect(); + return; + } + + ParallaxGrid_UnregisterEffect(); + } + + private void ParallaxGrid_RegisterEffect() + { + ParallaxGrid_RegisterEvents(); + ParallaxGrid_OffsetReset(); + } + + private void ParallaxGrid_UnregisterEffect() + { + ParallaxGrid_UnregisterEvents(); + ParallaxGrid_OffsetReset(); + } + + private void ParallaxGrid_RegisterEvents() + { + FrameworkElement? eventSource = ParallaxGrid_UnregisterEvents(); + if (eventSource == null) + { + return; + } + + eventSource.SizeChanged += ParallaxGrid_OnSizeChanged; + eventSource.PointerMoved += ParallaxGrid_OnPointerMoved; + eventSource.PointerEntered += ParallaxGrid_OnPointerEntered; + eventSource.PointerExited += ParallaxGrid_OnPointerExited; + _lastParallaxHoverSource = eventSource; + } + + private FrameworkElement? ParallaxGrid_UnregisterEvents() + { + if (_lastParallaxHoverSource != null) + { + _lastParallaxHoverSource.SizeChanged -= ParallaxGrid_OnSizeChanged; + _lastParallaxHoverSource.PointerMoved -= ParallaxGrid_OnPointerMoved; + _lastParallaxHoverSource.PointerEntered -= ParallaxGrid_OnPointerEntered; + _lastParallaxHoverSource.PointerExited -= ParallaxGrid_OnPointerExited; + } + + FrameworkElement? eventSource = ParallaxHoverSource ?? _parallaxGrid; + if (eventSource == null!) + { + return null; + } + + return eventSource; + } + + private void ParallaxGrid_CalculateCenterPoint() + { + if (!IsLoaded) + { + return; + } + + _parallaxGridVisual.CenterPoint = new Vector3((float)_parallaxGrid.RenderSize.Width / 2, + (float)_parallaxGrid.RenderSize.Height / 2, + 0); + } + + private void ParallaxGrid_OnSizeChanged(object sender, SizeChangedEventArgs e) + { + ParallaxGrid_CalculateCenterPoint(); + } + + private void ParallaxGrid_OnPointerExited(object sender, PointerRoutedEventArgs e) + { + if (ParallaxResetOnUnfocused) + { + ParallaxGrid_OffsetReset(); + } + } + + private void ParallaxGrid_OnPointerEntered(object sender, PointerRoutedEventArgs e) + { + ParallaxGrid_CalculateCenterPoint(); // Explicitly calculate center point once the pointer enters. + ParallaxGrid_OnPointerMovedCore(sender, e, true); + } + + private void ParallaxGrid_OnPointerMoved(object sender, PointerRoutedEventArgs e) + { + ParallaxGrid_OnPointerMovedCore(sender, e); + } + + private void ParallaxGrid_OffsetReset() + { + StartElementElevateAnimation(_parallaxGridCompositor, + _parallaxGridVisual, + Vector3.Zero, + Vector3.One, + 250d, + easingFunction: _parallaxEasingFunction); + } + + private void ParallaxGrid_OnPointerMovedCore( + object sender, + PointerRoutedEventArgs args, + bool isUseResetAnimation = false) + { + FrameworkElement element = (FrameworkElement)sender; + + // Move + Point pos = args.GetCurrentPoint(element).Position; + double w = element.ActualWidth; + double h = element.ActualHeight; + + if (w <= 0 || h <= 0) + return; + + // Normalize mouse position to range [-1, +1] + double nx = pos.X / w * 2d - 1d; + double ny = pos.Y / h * 2d - 1d; + + double offsetX = ParallaxHorizontalShift; + double offsetY = ParallaxVerticalShift; + + double deltaMs = Math.Max(1000d / WindowUtility.CurrentWindowMonitorRefreshRate, 8d); + + StartElementElevateOffsetAnimation(_parallaxGridCompositor, + _parallaxGridVisual, + _parallaxGrid, + offsetX, + offsetY, + nx, + ny, + isUseResetAnimation ? 250d : deltaMs, + easingFunction: _parallaxEasingFunction, + useAnimatedOffset: isUseResetAnimation); + } +} diff --git a/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Event.PlayerCallbacks.cs b/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Event.PlayerCallbacks.cs new file mode 100644 index 0000000000..f654b0d399 --- /dev/null +++ b/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Event.PlayerCallbacks.cs @@ -0,0 +1,47 @@ +using System; +using Windows.Media.Playback; + +#nullable enable +namespace CollapseLauncher.XAMLs.Theme.CustomControls; + +public partial class LayeredBackgroundImage +{ + private void MediaPlayer_OnCurrentStateChanged(MediaPlayer player, object? args) + { + MediaPlayerState value = player.CurrentState; + DispatcherQueue.TryEnqueue(() => + { + // Avoid being triggered multiple times. + if ((MediaPlayerState)GetValue(MediaStateProperty) == value) + return; + + SetValue(MediaStateProperty, value); + }); + } + + private void MediaPlayer_OnPositionChanged(MediaPlaybackSession session, object? args) + { + try + { + TimeSpan value = session.Position; + if (DispatcherQueue == null) + return; + + DispatcherQueue.TryEnqueue(() => + { + try + { + SetValue(MediaDurationPositionProperty, value); + } + catch + { + // ignored + } + }); + } + catch + { + // ignored + } + } +} diff --git a/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Event.PropertyChanged.cs b/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Event.PropertyChanged.cs new file mode 100644 index 0000000000..b7c66742b7 --- /dev/null +++ b/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Event.PropertyChanged.cs @@ -0,0 +1,143 @@ +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using System; +using Windows.Media.Playback; + +#nullable enable +namespace CollapseLauncher.XAMLs.Theme.CustomControls; + +public partial class LayeredBackgroundImage +{ + private void SubscribeGridEvents() + { + _elevateGrid.SizeChanged += ElevateGrid_OnSizeChanged; + + // Toggle parallax event explicitly for parallax animation + ParallaxGrid_ToggleEnable(IsParallaxEnabled); + } + + private void UnsubscribeGridEvents() + { + _elevateGrid.SizeChanged -= ElevateGrid_OnSizeChanged; + + // Unsubscribe event explicitly for parallax animation by toggling it off. + ParallaxGrid_ToggleEnable(false); + } + + #region Audio, Media Position and Volume + + private static void PropertyIsAudioEnabled_OnChange(DependencyObject obj, DependencyPropertyChangedEventArgs args) + { + LayeredBackgroundImage instance = (LayeredBackgroundImage)obj; + if (!instance.IsLoaded) + { + return; + } + + bool previousState = (bool?)args.OldValue ?? false; + if (!previousState) + { + instance.ToggleMute(!(bool)args.NewValue); + if (instance.TryGetBackgroundSourceContext(out SourceContext? sourceContext, + out _)) + { + sourceContext.SetVolume(instance.AudioVolume); + } + + return; + } + + instance.ToggleMute(!(bool)args.NewValue); + } + + private static void PropertyAudioVolume_OnChange(DependencyObject obj, DependencyPropertyChangedEventArgs args) + { + LayeredBackgroundImage instance = (LayeredBackgroundImage)obj; + if (!instance.IsLoaded) + { + return; + } + + instance.SetVolume((double)args.NewValue); + } + + private static void PropertyMediaDurationPosition_OnChange(DependencyObject obj, DependencyPropertyChangedEventArgs args) + { + TimeSpan pos = (TimeSpan)args.NewValue; + if (pos.Ticks >= 0) + { + return; + } + + LayeredBackgroundImage instance = (LayeredBackgroundImage)obj; + if (!instance.TryGetBackgroundSourceContext(out _, out MediaPlayer? player)) + { + return; + } + + player.Position = -pos; + } + + #endregion + + #region Parallax and Elevation + + private static void PropertyIsBackgroundElevated_OnChange(DependencyObject obj, DependencyPropertyChangedEventArgs args) + { + LayeredBackgroundImage instance = (LayeredBackgroundImage)obj; + instance.ElevateGrid_ToggleEnable((bool)args.NewValue); + } + + private static void PropertyIsParallelEnabled_OnChange(DependencyObject obj, DependencyPropertyChangedEventArgs args) + { + LayeredBackgroundImage instance = (LayeredBackgroundImage)obj; + if (!instance.IsLoaded) + { + return; + } + + instance.ParallaxGrid_ToggleEnable((bool)args.NewValue); + } + + private static void PropertyParallaxHoverSource_OnChange(DependencyObject obj, DependencyPropertyChangedEventArgs args) + { + LayeredBackgroundImage instance = (LayeredBackgroundImage)obj; + if (!instance.IsLoaded) + { + return; + } + + instance.ParallaxGrid_RegisterEffect(); // Register source events. + } + + #endregion + + #region Media Sources + + private static void PropertyForegroundSource_OnChange(DependencyObject obj, DependencyPropertyChangedEventArgs args) + { + LayeredBackgroundImage instance = (LayeredBackgroundImage)obj; + PropertyMediaSource_OnChange(instance._foregroundGrid, instance, args); + } + + private static void PropertyBackgroundSource_OnChange(DependencyObject obj, DependencyPropertyChangedEventArgs args) + { + LayeredBackgroundImage instance = (LayeredBackgroundImage)obj; + PropertyMediaSource_OnChange(instance._backgroundGrid, instance, args); + } + + private static async void PropertyMediaSource_OnChange( + Grid parent, + LayeredBackgroundImage instance, + DependencyPropertyChangedEventArgs args) + { + if (!instance.IsLoaded) + { + return; + } + + await instance.LoadPresenter(parent, args.NewValue); + } + + #endregion +} diff --git a/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Events.Control.cs b/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Events.Control.cs deleted file mode 100644 index 6867442c63..0000000000 --- a/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Events.Control.cs +++ /dev/null @@ -1,372 +0,0 @@ -using CollapseLauncher.Extension; -using Hi3Helper; -using Microsoft.UI.Dispatching; -using Microsoft.UI.Xaml; -using System; -using System.Diagnostics.CodeAnalysis; -using System.Runtime.CompilerServices; -using System.Runtime.InteropServices; -using System.Threading; -using Windows.Media.Playback; - -#nullable enable -namespace CollapseLauncher.XAMLs.Theme.CustomControls; - -public partial class LayeredBackgroundImage -{ - #region Fields - - private CancellationTokenSource? _videoPlayerPlayPauseCts; - private long _videoPlayerPosRefreshLastTick = Environment.TickCount64; - - private enum VideoState - { - Paused, - RequestedPlay, - Playing, - RequestedPause, - } - private VideoState _videoState; - - #endregion - - #region Play and Pause Control - - public void Play() - { - if (_lastBackgroundSourceType == MediaSourceType.Image) - { - return; - } - - if (Interlocked.CompareExchange(ref _videoState, VideoState.RequestedPlay, VideoState.Paused) != - VideoState.Paused) - return; - - CancellationTokenSource? lastCts = Interlocked.Exchange(ref _videoPlayerPlayPauseCts, new CancellationTokenSource()); - lastCts?.Cancel(); - lastCts?.Dispose(); - - InitializeAndPlayVideoView(volumeFadeDurationMs: 150d, - volumeFadeResolutionMs: 10d, - token: _videoPlayerPlayPauseCts.Token); - - Interlocked.Exchange(ref _videoState, VideoState.Playing); - } - - public void Pause() - { - if (_lastBackgroundSourceType == MediaSourceType.Image) - { - return; - } - - if (Interlocked.CompareExchange(ref _videoState, VideoState.RequestedPause, VideoState.Playing) != - VideoState.Playing) - return; - - if (!IsLoaded || - _videoPlayer == null! || - !_videoPlayer.CanPause) - { - Paused(); - return; - } - - CancellationTokenSource? lastCts = - Interlocked.Exchange(ref _videoPlayerPlayPauseCts, new CancellationTokenSource()); - lastCts?.Cancel(); - lastCts?.Dispose(); - - if (CanUseStaticBackground) - { - BackgroundSource_UseStatic(this); - } - - DisposeAndPauseVideoView(actionAfterPause: Paused, - volumeFadeDurationMs: 150d, - volumeFadeResolutionMs: 10d, - // disposeVideoPlayer: CanUseStaticBackground, - disposeRenderImageSource: false, // Do not dispose Image Source - token: _videoPlayerPlayPauseCts.Token); - return; - - void Paused() - { - Interlocked.Exchange(ref _videoState, VideoState.Paused); - } - } - - #endregion - - #region Fade In/Out Audio Control - - public void FadeInAudio(double volumeFadeDurationMs = 500d, - double volumeFadeResolutionMs = 10d, - CancellationToken coopToken = default) - { - if (_videoPlayer == null!) - { - return; - } - - StartVideoPlayerVolumeFade(Math.Max(0, _videoPlayer.Volume * 100d), - AudioVolume, - volumeFadeDurationMs, - volumeFadeResolutionMs, - coopToken: coopToken); - } - - public void FadeOutAudio(Action? actionAfterPause = null, - double volumeFadeDurationMs = 500d, - double volumeFadeResolutionMs = 10d, - CancellationToken coopToken = default) - { - if (_videoPlayer == null!) - { - return; - } - - StartVideoPlayerVolumeFade(Math.Min(AudioVolume, _videoPlayer.Volume * 100d), - 0, - volumeFadeDurationMs, - volumeFadeResolutionMs, - true, - onAfterAction: actionAfterPause, - coopToken: coopToken); - } - - private void StartVideoPlayerVolumeFade(double fromValue, - double toValue, - double durationMs, - double resolutionMs, - bool fadeOut = false, - Action? onAfterAction = null, - CancellationToken coopToken = default) - { - new Thread(() => - StartVideoPlayerVolumeFadeCore(fromValue, - toValue, - durationMs, - resolutionMs, - fadeOut, - onAfterAction, - coopToken)) - { - IsBackground = true - }.Start(); - } - - public double ActualVolume - { - get => (double)GetValue(ActualVolumeProperty); - set => SetValue(ActualVolumeProperty, value); - } - - public static readonly DependencyProperty ActualVolumeProperty = - DependencyProperty.Register(nameof(ActualVolume), - typeof(double), - typeof(LayeredBackgroundImage), - new PropertyMetadata(0d, SetAudioVolumeBridge)); - - private static void SetAudioVolumeBridge(DependencyObject d, DependencyPropertyChangedEventArgs e) - { - LayeredBackgroundImage instance = (LayeredBackgroundImage)d; - if (instance._videoPlayer == null!) - { - return; - } - - DispatcherQueueExtensions.TryEnqueue(() => instance._videoPlayer.Volume = (double)e.NewValue); - } - - private void StartVideoPlayerVolumeFadeCore(double fromValue, - double toValue, - double durationMs, - double resolutionMs, - bool fadeOut = false, - Action? onAfterAction = null, - CancellationToken coopToken = default) - { - try - { - CancellationTokenSource currentCts = CancellationTokenSource.CreateLinkedTokenSource(coopToken); - CancellationTokenSource? prevCts = Interlocked.Exchange(ref _videoPlayerFadeCts, currentCts); - prevCts?.Cancel(); - prevCts?.Dispose(); - - CancellationToken currentToken = currentCts.Token; - - fromValue /= 100d; - toValue /= 100d; - - double timeDelta = resolutionMs / durationMs; - double step = Math.Max(fromValue, toValue) * timeDelta; - step = fadeOut ? -step : step; - - Unsafe.SkipInit(out Timer timer); // HACK: Ignore Error CS0165 - timer = new Timer(Impl, null, TimeSpan.Zero, TimeSpan.FromMilliseconds(resolutionMs)); - - return; - - [SuppressMessage("ReSharper", "AccessToModifiedClosure")] - void Impl(object? state) - { - try - { - // ReSharper disable once AccessToModifiedClosure - if (timer == null! || - currentToken.IsCancellationRequested) - { - timer?.Dispose(); - onAfterAction?.Invoke(); - return; - } - - durationMs -= resolutionMs; - fromValue += step; - if (durationMs < 0) - { - if (_videoPlayer != null!) - { - TrySetVolume(toValue); - } - timer.Dispose(); - onAfterAction?.Invoke(); - return; - } - - if (_videoPlayer != null!) - { - TrySetVolume(fromValue); - } - } - catch (Exception e) - { - timer.Dispose(); - onAfterAction?.Invoke(); - Logger.LogWriteLine($"[LayeredBackgroundImage::StartVideoPlayerVolumeFadeCore::Impl] {e}", - LogType.Error, - true); - } - } - - void TrySetVolume(double value) - { - try - { - _videoPlayer.Volume = value; - } - catch (Exception e) - { - Logger.LogWriteLine($"[LayeredBackgroundImage::StartVideoPlayerVolumeFadeCore::TrySetVolume] {e}", - LogType.Error, - true); - } - } - } - catch (Exception e) - { - Logger.LogWriteLine($"[LayeredBackgroundImage::StartVideoPlayerVolumeFadeCore] {e}", - LogType.Error, - true); - } - } - - #endregion - - #region Duration Position Control - - [SkipLocalsInit] - private void MediaDurationPosition_OnChangedBridge(MediaPlaybackSession sender, object args) - { - long thisTick = Environment.TickCount64; - long lastTick = _videoPlayerPosRefreshLastTick; - - // Only updates a second at a time to avoid UI thread overhead due to extensive update. - if (thisTick - lastTick <= 1000 || - Interlocked.CompareExchange(ref _videoPlayerPosRefreshLastTick, - thisTick, - lastTick) != lastTick) - { - return; - } - - if (DispatcherQueue.IsObjectDisposed()) - { - return; - } - - DispatcherQueue.TryEnqueue(DispatcherQueuePriority.Low, () => SetValue(MediaDurationPositionProperty, sender.Position)); - } - - private static void MediaDurationPosition_OnChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) - { - try - { - LayeredBackgroundImage instance = (LayeredBackgroundImage)d; - if (e.NewValue is not TimeSpan value) - { - return; - } - - SaveMediaPosition(instance.BackgroundSource, value); - bool isParamForSeek = value < TimeSpan.Zero; // Mark for seeking to video player if negated - - // Skip if the value is not negated (not for seeking) - if (!isParamForSeek) return; - - value = value.Negate(); - if (value.TotalSeconds < 1) // Clamp to 1 second if clip is not zero but less than a second. - { - value = TimeSpan.Zero; - } - instance._videoPlayer?.Position = value; - } - catch (Exception ex) - { - Logger.LogWriteLine($"[LayeredBackgroundImage::MediaDurationPosition_OnChanged] {ex}", - LogType.Error, - true); - } - } - - [SkipLocalsInit] - private static bool SaveMediaPosition(object? source, TimeSpan timeSpan) - { - if (!TryGetSourceHashCode(source, out int hashCode)) - { - return false; - } - - if (timeSpan.Ticks < 0) - { - timeSpan = timeSpan.Negate(); - } - - SharedLastMediaPosition[hashCode] = timeSpan; - - TrimSharedLastMediaPositionIfNeeded(); - - return true; - } - - private static void TrimSharedLastMediaPositionIfNeeded() - { - if (SharedLastMediaPosition.Count <= MaxSharedLastMediaPositionEntries) - return; - - int excess = SharedLastMediaPosition.Count - MaxSharedLastMediaPositionEntries; - foreach (var entry in SharedLastMediaPosition) - { - if (!SharedLastMediaPosition.TryRemove(entry.Key, out _)) - continue; - - excess--; - if (excess <= 0) - break; - } - } - - #endregion -} diff --git a/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Events.FrameInitializer.cs b/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Events.FrameInitializer.cs deleted file mode 100644 index a8b1a5a523..0000000000 --- a/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Events.FrameInitializer.cs +++ /dev/null @@ -1,278 +0,0 @@ -using CollapseLauncher.Extension; -using Hi3Helper; -using Hi3Helper.Win32.WinRT.SwapChainPanelHelper; -using Microsoft.Graphics.Canvas; -using Microsoft.Graphics.Canvas.UI.Xaml; -using System; -using System.Runtime.InteropServices; -using System.Threading; -using Windows.Foundation; -using Windows.Graphics.DirectX.Direct3D11; -using Windows.Media.Playback; -using WinRT; - -// ReSharper disable CommentTypo - -#nullable enable -namespace CollapseLauncher.XAMLs.Theme.CustomControls; - -public partial class LayeredBackgroundImage -{ - #region Initializers - - private void InitializeVideoPlayer() - { - try - { - if (_videoPlayer != null!) - { - return; - } - - MediaPlayer player = new() - { - IsLoopingEnabled = true, - IsVideoFrameServerEnabled = true, - Volume = AudioVolume.GetClampedVolume(), - IsMuted = !IsAudioEnabled, - CommandManager = { IsEnabled = false } - }; - Interlocked.Exchange(ref _videoPlayer, player); - - player.MediaOpened += InitializeVideoFrameOnMediaOpened; - - if (!_useSafeFrameRenderer) - { - ((IWinRTObject)player).NativeObject.TryAs(IMediaPlayer5_IID, out _videoPlayerPtr); - } - } - catch (Exception ex) - { - Logger.LogWriteLine($"[LayeredBackgroundImage::InitializeVideoPlayer] {ex}", - LogType.Error, - true); - } - } - - private void InitializeRenderTargetSize(MediaPlaybackSession playbackSession) - { - try - { - double currentCanvasWidth = playbackSession.NaturalVideoWidth; - double currentCanvasHeight = playbackSession.NaturalVideoHeight; - - _canvasWidth = (int)currentCanvasWidth; - _canvasHeight = (int)currentCanvasHeight; - _canvasRenderSize = new Rect(0, 0, _canvasWidth, _canvasHeight); - - // 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) return; - - _canvasWidth = _videoFfmpegMediaSource.CurrentVideoStream.PixelWidth; - _canvasHeight = _videoFfmpegMediaSource.CurrentVideoStream.PixelHeight; - _canvasRenderSize = new Rect(0, 0, _canvasWidth, _canvasHeight); - } - catch (Exception ex) - { - Logger.LogWriteLine($"[LayeredBackgroundImage::DisposeVideoPlayer] {ex}", - LogType.Error, - true); - } - } - - private unsafe void InitializeRenderTarget() - { - try - { - Interlocked.Exchange(ref _isBlockVideoFrameDraw, 1); // Block frame drawing routine - DisposeRenderTarget(_canvasImageSource == null); // Always ensure the previous render target has been disposed - - _canvasDevice ??= CanvasDevice.GetSharedDevice(); - _canvasImageSource ??= new CanvasVirtualImageSource(_canvasDevice, - _canvasWidth, - _canvasHeight, - 96f); - - _canvasRenderTarget ??= new CanvasRenderTarget(_canvasDevice, - _canvasWidth, - _canvasHeight, - 96f); - - Interlocked.Exchange(ref _useSafeFrameRenderer, false); - if (_useSafeFrameRenderer) - { - return; - } - - _canvasRenderTargetNativePtr = ((IWinRTObject)_canvasRenderTarget).NativeObject.ThisPtr; - _canvasImageSourceNativePtr = ((IWinRTObject)_canvasImageSource).NativeObject.ThisPtr; - - ((IWinRTObject)_canvasRenderTarget).NativeObject.TryAs(typeof(IDirect3DSurface).GUID, out _canvasRenderTargetAsSurfacePtr); - - try - { - if (_functionTableBeginDraw == null! || - _functionTableDrawImage == null! || - _functionTableDispose == null!) - { - SwapChainPanelHelper.GetDirectNativeDelegateForDrawRoutine(_canvasImageSourceNativePtr, - _canvasRenderTargetNativePtr, - out _functionTableBeginDraw, - out _functionTableDrawImage, - out _functionTableDispose, - in _canvasRenderSize); - } - } - catch (Exception e) - { - Interlocked.Exchange(ref _useSafeFrameRenderer, true); // Fallback - - _functionTableBeginDraw = null; - _functionTableDrawImage = null; - _functionTableDispose = null; - Logger.LogWriteLine($"[LayeredBackgroundImage::InitializeRenderTarget] Failed to initialize fast-unsafe method for frame rendering. Fallback to safe renderer.\r\n{e}", - LogType.Error, - true); - } - } - catch (Exception e) - { - Logger.LogWriteLine($"[LayeredBackgroundImage::InitializeRenderTarget] FATAL: {e}", - LogType.Error, - true); - } - finally - { - if (_canvasImageSource != null) - { - SetRenderImageSource(_canvasImageSource.Source); - } - Interlocked.Exchange(ref _isBlockVideoFrameDraw, 0); // Unblock frame drawing routine - } - } - - #endregion - - #region Disposers - - private void DetachVideoPlayerEvents(MediaPlayer player) - { - player.MediaOpened -= InitializeVideoFrameOnMediaOpened; - player.VideoFrameAvailable -= NotifyVideoLoaded; - player.VideoFrameAvailable -= VideoPlayer_VideoFrameAvailableUnsafe; - player.VideoFrameAvailable -= VideoPlayer_VideoFrameAvailableSafe; - player.PlaybackSession.PositionChanged -= MediaDurationPosition_OnChangedBridge; - } - - private void DisposeVideoPlayer(bool disposeRenderImageSource = true) - { - try - { - MediaPlayer? player = _videoPlayer; - if (player is null || player.IsObjectDisposed()) - { - return; - } - - DetachVideoPlayerEvents(player); - player.Pause(); - - // Save last video player duration for later - if (player.CanSeek) - { - _ = SaveMediaPosition(BackgroundSource, player.Position); - } - - if (!_useSafeFrameRenderer) - { - NullifyMediaPlayerNativePointers(); - } - - Interlocked.Exchange(ref _videoFfmpegMediaSource, null)?.Dispose(); - Interlocked.Exchange(ref _videoPlayer, null)?.Dispose(); - - DisposeRenderTarget(disposeRenderImageSource); - } - catch (Exception ex) - { - Logger.LogWriteLine($"[LayeredBackgroundImage::DisposeVideoPlayer] {ex}", - LogType.Error, - true); - } - finally - { - Interlocked.Exchange(ref _isVideoInitialized, 0); - } - } - - private void DisposeRenderTarget(bool disposeRenderImageSource = true) - { - try - { - if (disposeRenderImageSource) - { - SetRenderImageSource(null); - Interlocked.Exchange(ref _canvasImageSource, null); - Interlocked.Exchange(ref _canvasRenderTarget, null)?.Dispose(); - } - - if (!_useSafeFrameRenderer) - { - NullifyRenderTargetNativePointers(); - } - } - catch (Exception e) - { - Logger.LogWriteLine($"[LayeredBackgroundImage::DisposeRenderTarget] FATAL: {e}", - LogType.Error, - true); - } - } - - #endregion - - #region On Device Lost Handler - - private void CanvasDevice_OnDeviceLost() - { - Logger.LogWriteLine("[LayeredBackgroundImage::CanvasDevice_OnDeviceLost] Render device has been lost! Re-initialize render device and textures...", - LogType.Warning, - true); - - // -- Nullify _canvasImageSource so CanvasDevice and other dependencies are reinitialized too - Interlocked.Exchange(ref _canvasImageSource, null!); - InitializeRenderTarget(); - - // Try to unlock video draw progress (if a throw happened inside frame drawing routine) - Interlocked.Exchange(ref _isVideoFrameDrawInProgress, 0); - } - - #endregion - - #region COM Native Pointer Nullifiers - - private void NullifyMediaPlayerNativePointers() - { - // -- Note to myself @neon-nyan: - // Release IMediaPlayer5 reference first, then dispose the whole MediaPlayer. - // This is necessary as we just cast the _videoPlayer object (as IWinRTObject, then took its direct pointer) into IMediaPlayer5. - // If not released, the reference on the IWinRTObject will not be zeroed, causing leak. - if (_videoPlayerPtr != nint.Zero) Marshal.Release(Interlocked.Exchange(ref _videoPlayerPtr, nint.Zero)); - } - - private void NullifyRenderTargetNativePointers() - { - // -- Note to myself @neon-nyan: - // Release IDirect3DSurface reference first, then dispose the whole CanvasRenderTarget. - // This is necessary as we just cast the _canvasRenderTargetNativePtr (which is obtained from IWinRTObject's direct pointer) into IDirect3DSurface. - // If not released, the reference on the IWinRTObject will not be zeroed, causing leak. - if (_canvasRenderTargetAsSurfacePtr != nint.Zero) Marshal.Release(Interlocked.Exchange(ref _canvasRenderTargetAsSurfacePtr, nint.Zero)); - - // -- Nullify IWinRTObject direct pointers. - Interlocked.Exchange(ref _canvasRenderTargetNativePtr, nint.Zero); - Interlocked.Exchange(ref _canvasImageSourceNativePtr, nint.Zero); - } - - #endregion -} diff --git a/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Events.FrameRenderer.cs b/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Events.FrameRenderer.cs deleted file mode 100644 index 38850acee0..0000000000 --- a/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Events.FrameRenderer.cs +++ /dev/null @@ -1,557 +0,0 @@ -using CollapseLauncher.Extension; -using FFmpegInteropX; -using Hi3Helper; -using Hi3Helper.Data; -using Hi3Helper.Win32.WinRT.SwapChainPanelHelper; -using Microsoft.Graphics.Canvas; -using Microsoft.Graphics.Canvas.UI.Xaml; -using Microsoft.UI.Dispatching; -using Microsoft.UI.Xaml; -using Microsoft.UI.Xaml.Controls; -using Microsoft.UI.Xaml.Media; -using System; -using System.Collections.Concurrent; -using System.Linq; -using System.Runtime.CompilerServices; -using System.Runtime.InteropServices; -using System.Threading; -using Windows.Foundation; -using Windows.Media.Playback; -// ReSharper disable CommentTypo -// ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract -// ReSharper disable AccessToModifiedClosure - -#nullable enable -namespace CollapseLauncher.XAMLs.Theme.CustomControls; - -public partial class LayeredBackgroundImage -{ - #region Properties - - // ReSharper disable once InconsistentNaming - private static ref readonly Guid IMediaPlayer5_IID - { - [MethodImpl(MethodImplOptions.AggressiveInlining)] - get - { - ReadOnlySpan span = [ - 253, 55, 229, 207, 106, 248, 70, 68, 191, 77, - 200, 231, 146, 183, 180, 179 - ]; - return ref Unsafe.As(ref MemoryMarshal.GetReference(span)); - } - } - - private bool _useSafeFrameRenderer; - - #endregion - - #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; - - #endregion - - #region Fields - private const int MaxSharedLastMediaPositionEntries = 32; - - private static readonly ConcurrentDictionary SharedLastMediaPosition = new(); - - private CanvasRenderTarget? _canvasRenderTarget; - private nint _canvasRenderTargetNativePtr; - private nint _canvasRenderTargetAsSurfacePtr; - - private int _isBlockVideoFrameDraw = 1; - private int _isVideoFrameDrawInProgress; - private int _isVideoInitialized; - - private CanvasDevice? _canvasDevice; - private CanvasVirtualImageSource? _canvasImageSource; - private nint _canvasImageSourceNativePtr = nint.Zero; - - private int _canvasWidth; - private int _canvasHeight; - private Rect _canvasRenderSize; - - private MediaPlayer? _videoPlayer; - private nint _videoPlayerPtr = nint.Zero; - private CancellationTokenSource? _videoPlayerFadeCts; - private FFmpegMediaSource? _videoFfmpegMediaSource; - private long _videoToSkipFrames; - - private int _isFirstInitSkipFrame = 1; - - #endregion - - #region Video Frame Drawing - - private unsafe void VideoPlayer_VideoFrameAvailableUnsafe(MediaPlayer sender, object args) - { - nint drawingSessionPpv = nint.Zero; - try - { - if (Interlocked.Decrement(ref _videoToSkipFrames) > 0) - { - return; - } - - if (_isBlockVideoFrameDraw == 1 || - _canvasImageSourceNativePtr == nint.Zero || - _canvasRenderTargetNativePtr == nint.Zero || - Interlocked.Exchange(ref _isVideoFrameDrawInProgress, 1) == 1) - { -#if DEBUG - Logger.LogWriteLine($@"Skipping frame at: {sender.Position:hh\:mm\:ss\.ffffff}"); -#endif - return; - } - - SwapChainPanelHelper.MediaPlayerCopyFrameUnsafe(_videoPlayerPtr, - _canvasRenderTargetAsSurfacePtr); - - drawingSessionPpv = SwapChainPanelHelper - .CanvasSessionDrawUnsafe(_canvasImageSourceNativePtr, - _canvasRenderTargetNativePtr, - _functionTableBeginDraw, - _functionTableDrawImage, - in _canvasRenderSize); - } - // Device lost error. If happened, reinitialize render target - catch (COMException comEx) when ((uint)comEx.HResult is 0x887A0005u or 0x802B0020u or 0x8899000Cu) - { - DispatcherQueue.TryEnqueue(CanvasDevice_OnDeviceLost); - } - catch (COMException comEx) when ((uint)comEx.HResult is 0x88980801u) - { - // Try to unlock if any error caused by DCOMPOSITION_ERROR_SURFACE_NOT_BEING_RENDERED - Interlocked.Exchange(ref _isVideoFrameDrawInProgress, 0); - } - catch (Exception ex) - { - Logger.LogWriteLine($"[LayeredBackgroundImage::VideoPlayer_VideoFrameAvailableUnsafe|OtherThread] {ex}", - LogType.Error, - true); - } - finally - { - if (drawingSessionPpv != nint.Zero) - { - DispatcherQueue?.TryEnqueue(DispatcherQueuePriority.High, DrawFrame); - } - void DrawFrame() - { - try - { - SwapChainPanelHelper.DrawingDisposeUnsafe(drawingSessionPpv, _functionTableDispose); - } - // Device lost error. If happened, reinitialize render target - catch (COMException comEx) when ((uint)comEx.HResult is 0x887A0005u or 0x802B0020u or 0x8899000Cu) - { - DispatcherQueue.TryEnqueue(CanvasDevice_OnDeviceLost); - } - catch (COMException comEx) when ((uint)comEx.HResult is 0x88980801u) - { - // Try to unlock if any error caused by DCOMPOSITION_ERROR_SURFACE_NOT_BEING_RENDERED - Interlocked.Exchange(ref _isVideoFrameDrawInProgress, 0); - } - catch (Exception ex) - { - Logger.LogWriteLine($"[LayeredBackgroundImage::VideoPlayer_VideoFrameAvailableUnsafe|UIThread] {ex}", - LogType.Error, - true); - } - finally - { - Interlocked.Exchange(ref _isVideoFrameDrawInProgress, 0); - } - } - } - } - - private void VideoPlayer_VideoFrameAvailableSafe(MediaPlayer sender, object args) - { - Unsafe.SkipInit(out CanvasDrawingSession? ds); - - try - { - if (Interlocked.Decrement(ref _videoToSkipFrames) > 0) - { - return; - } - - if (_isBlockVideoFrameDraw == 1 || - _canvasImageSource == null! || - _canvasRenderTarget == null! || - Interlocked.Exchange(ref _isVideoFrameDrawInProgress, 1) == 1) - { -#if DEBUG - Logger.LogWriteLine($@"Skipping frame at: {sender.Position:hh\:mm\:ss\.ffffff}"); -#endif - return; - } - - _videoPlayer?.CopyFrameToVideoSurface(_canvasRenderTarget); - ds = _canvasImageSource?.CreateDrawingSession(default, _canvasRenderSize); - ds?.DrawImage(_canvasRenderTarget, _canvasRenderSize); - } - // Device lost error. If happened, reinitialize render target - catch (COMException comEx) when ((uint)comEx.HResult is 0x887A0005u or 0x802B0020u or 0x8899000Cu) - { - DispatcherQueue.TryEnqueue(CanvasDevice_OnDeviceLost); - } - catch (COMException comEx) when ((uint)comEx.HResult is 0x88980801u) - { - // Try to unlock if any error caused by DCOMPOSITION_ERROR_SURFACE_NOT_BEING_RENDERED - Interlocked.Exchange(ref _isVideoFrameDrawInProgress, 0); - } - catch (Exception ex) - { - Logger.LogWriteLine($"[LayeredBackgroundImage::VideoPlayer_VideoFrameAvailableSafe|OtherThread] {ex}", - LogType.Error, - true); - } - finally - { - DispatcherQueue?.TryEnqueue(DispatcherQueuePriority.High, DrawFrame); - } - - return; - - void DrawFrame() - { - try - { - ds?.Dispose(); - } - // Device lost error. If happened, reinitialize render target - catch (COMException comEx) when ((uint)comEx.HResult is 0x887A0005u or 0x802B0020u or 0x8899000Cu) - { - CanvasDevice_OnDeviceLost(); - } - catch (COMException comEx) when ((uint)comEx.HResult is 0x88980801u) - { - // Try to unlock if any error caused by DCOMPOSITION_ERROR_SURFACE_NOT_BEING_RENDERED - Interlocked.Exchange(ref _isVideoFrameDrawInProgress, 0); - } - catch (Exception ex) - { - Logger.LogWriteLine($"[LayeredBackgroundImage::VideoPlayer_VideoFrameAvailableSafe|UIThread] {ex}", - LogType.Error, - true); - } - finally - { - Interlocked.Exchange(ref _isVideoFrameDrawInProgress, 0); - } - } - } - - #endregion - - #region Video Frame Setter - - private void SetRenderImageSource(ImageSource? renderSource) - { - try - { - if (_backgroundGrid == null!) - { - return; - } - - if (_backgroundGrid.DispatcherQueue == null!) - { - return; - } - - _backgroundGrid - .DispatcherQueue - .TryEnqueue(() => - { - try - { - Image? image = _backgroundGrid.Children - .OfType() - .LastOrDefault(x => x.Name == "VideoRenderFrame"); - if (image != null) - { - image.Source = renderSource; - } - } - catch - { - // ignored - } - }); - } - catch (Exception e) - { - Logger.LogWriteLine($"[LayeredBackgroundImage::SetRenderImageSource] {e}", - LogType.Error, - true); - } - } - - #endregion - - #region Video Player Events - - public CanvasRenderTarget? LockCanvasRenderTarget() - { - if (_canvasRenderTarget == null) - { - return null; - } - - Interlocked.Exchange(ref _isBlockVideoFrameDraw, 1); - return _canvasRenderTarget; - } - - public void UnlockCanvasRenderTarget() - { - Interlocked.Exchange(ref _isBlockVideoFrameDraw, 0); - } - - private static void IsAudioEnabled_OnChange(DependencyObject d, DependencyPropertyChangedEventArgs e) - { - try - { - LayeredBackgroundImage instance = (LayeredBackgroundImage)d; - if (instance._videoPlayer is not { } videoPlayer) - { - return; - } - - videoPlayer.IsMuted = !(bool)e.NewValue; - } - catch (Exception ex) - { - Logger.LogWriteLine($"[LayeredBackgroundImage::IsAudioEnabled_OnChange] {ex}", - LogType.Error, - true); - } - } - - private static void AudioVolume_OnChange(DependencyObject d, DependencyPropertyChangedEventArgs e) - { - try - { - LayeredBackgroundImage instance = (LayeredBackgroundImage)d; - if (instance._videoPlayer is not { } videoPlayer) - { - return; - } - - double volume = e.NewValue.TryGetDouble(); - videoPlayer.Volume = volume.GetClampedVolume(); - } - catch (Exception ex) - { - Logger.LogWriteLine($"[LayeredBackgroundImage::AudioVolume_OnChange] {ex}", - LogType.Error, - true); - } - } - - public void InitializeAndPlayVideoView(Action? actionOnPlay = null, - bool reinitializeImageSource = true, - double volumeFadeDurationMs = 1000d, - double volumeFadeResolutionMs = 10d, - CancellationToken token = default) - { - try - { - if (_videoPlayer != null!) - { - // Only initialize once. - if (Interlocked.Exchange(ref _isVideoInitialized, 1) == 0) - { - InitializeRenderTarget(); - - // Seek to last position if source was the same - if (_videoPlayer.CanSeek && - TryGetSourceHashCode(BackgroundSource, out int lastSourceHashCode) && - SharedLastMediaPosition.TryGetValue(lastSourceHashCode, out TimeSpan lastPosition)) - { - _videoPlayer.Position = lastPosition; - } - - _videoPlayer.PlaybackSession.PositionChanged += MediaDurationPosition_OnChangedBridge; - - // INTENTIONAL: Skipping a blank frame after initialization. - try - { - if (UseFfmpegDecoder) - { - // Use a half second for FFmpeg as it took slightly longer. - double ffmpegSessionFrameRate = _videoFfmpegMediaSource?.CurrentVideoStream?.FramesPerSecond ?? 0; - ffmpegSessionFrameRate *= .5d; - - // Avoid longer frame skipping on first init. Override it by just two - if (Interlocked.Exchange(ref _isFirstInitSkipFrame, 0) == 1 && - ffmpegSessionFrameRate > 1) - { - ffmpegSessionFrameRate = 1; - } - - Interlocked.Exchange(ref _videoToSkipFrames, (long)Math.Round(ffmpegSessionFrameRate)); - } - else - { - Interlocked.Exchange(ref _videoToSkipFrames, 1); - } - } - catch - { - // ignored - } - } - - // 2026/04/19: Now the VideoFrameAvailable handle is used to notify NotifyVideoLoaded -> NotifyImageLoaded. - _videoPlayer.VideoFrameAvailable += NotifyVideoLoaded; - - PlayVideoView(ActionVideoAfterPlay, - volumeFadeDurationMs, - volumeFadeResolutionMs, - token); - - void ActionVideoAfterPlay() - { - _videoPlayer.VideoFrameAvailable += !_useSafeFrameRenderer - ? VideoPlayer_VideoFrameAvailableUnsafe - : VideoPlayer_VideoFrameAvailableSafe; - } - } - else if (BackgroundSource != null) - { - // Try loading last media - BackgroundSource_UseNormal(this); - _lastBackgroundSource = BackgroundSource; - } - actionOnPlay?.Invoke(); - } - catch (Exception e) - { - Logger.LogWriteLine($"[LayeredBackgroundImage::InitializeAndPlayVideoView] FATAL: {e}", - LogType.Error, - true); - } - } - - public void DisposeAndPauseVideoView(Action? actionOnPause = null, - Action? actionAfterPause = null, - bool disposeVideoPlayer = true, - bool disposeRenderImageSource = true, - double volumeFadeDurationMs = 500d, - double volumeFadeResolutionMs = 10d, - CancellationToken token = default) - { - try - { - if (this.IsObjectDisposed()) - { - return; - } - - token.Register(() => - { - // Interlocked.Exchange(ref _isBlockVideoFrameDraw, 0); // Make sure to unblock if request is cancelled - }); - // Set events - DispatcherQueue?.TryEnqueue(() => SetValue(IsVideoPlayProperty, false)); - actionOnPause?.Invoke(); - - MediaPlayer? playerAtDisposeStart = _videoPlayer; - if (playerAtDisposeStart is null) - { - actionAfterPause?.Invoke(); - return; - } - - if (disposeVideoPlayer) - { - // Unsubscribe early to avoid wasted skipped frames. - DetachVideoPlayerEvents(playerAtDisposeStart); - } - - // Interlocked.Exchange(ref _isBlockVideoFrameDraw, 1); // Blocks early - // Guard: skip deferred dispose if the player was already swapped out by a - // concurrent source switch (the old player was disposed synchronously). - PauseVideoView(Impl, volumeFadeDurationMs, volumeFadeResolutionMs, token); - return; - - void Impl() - { - try - { - if (_videoPlayer != null! && - ReferenceEquals(_videoPlayer, playerAtDisposeStart) && - DispatcherQueue != null!) - { - DispatcherQueue.TryEnqueue(DispatcherQueuePriority.High, () => - { - if (disposeVideoPlayer && - ReferenceEquals(_videoPlayer, playerAtDisposeStart)) - DisposeVideoPlayer(disposeRenderImageSource); - }); - } - } - catch (Exception e) - { - Logger.LogWriteLine($"[LayeredBackgroundImage::DisposeAndPauseVideoView|UIThread] FATAL: {e}", - LogType.Error, - true); - } - finally - { - DispatcherQueue?.TryEnqueue(DispatcherQueuePriority.High, () => - { - actionAfterPause?.Invoke(); - }); - GC.Collect(); - GC.WaitForPendingFinalizers(); - } - } - } - catch (Exception e) - { - Logger.LogWriteLine($"[LayeredBackgroundImage::DisposeAndPauseVideoView|OtherThread] FATAL: {e}", - LogType.Error, - true); - } - } - - private void PlayVideoView(Action? actionAfterPause = null, - double volumeFadeDurationMs = 1000d, - double volumeFadeResolutionMs = 10d, - CancellationToken token = default) - { - _videoPlayer?.Volume = 0; - _videoPlayer?.Play(); - SetValue(IsVideoPlayProperty, true); - - actionAfterPause?.Invoke(); - - FadeInAudio(volumeFadeDurationMs, volumeFadeResolutionMs, token); - } - - private void PauseVideoView(Action? actionAfterPause = null, - double volumeFadeDurationMs = 1000d, - double volumeFadeResolutionMs = 10d, - CancellationToken token = default) - { - FadeOutAudio(ActionAfterPauseInject, volumeFadeDurationMs, volumeFadeResolutionMs, token); - return; - - void ActionAfterPauseInject() - { - _videoPlayer?.Pause(); - DispatcherQueueExtensions.TryEnqueue(() => SetValue(IsVideoPlayProperty, false)); - actionAfterPause?.Invoke(); - } - } - - #endregion -} diff --git a/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Events.Loaders.cs b/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Events.Loaders.cs deleted file mode 100644 index 9d67ba4245..0000000000 --- a/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Events.Loaders.cs +++ /dev/null @@ -1,711 +0,0 @@ -using CollapseLauncher.Extension; -using FFmpegInteropX; -using Hi3Helper; -using Hi3Helper.Data; -using Hi3Helper.Win32.WinRT.WindowsStream; -using Microsoft.UI; -using Microsoft.UI.Xaml; -using Microsoft.UI.Xaml.Controls; -using Microsoft.UI.Xaml.Data; -using Microsoft.UI.Xaml.Media.Animation; -using System; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using System.IO; -using System.Linq; -using System.Runtime.CompilerServices; -using System.Threading; -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 -#pragma warning disable CsWinRT1032 - -#nullable enable -namespace CollapseLauncher.XAMLs.Theme.CustomControls; - -public partial class LayeredBackgroundImage -{ - #region Enums - - private enum MediaSourceType - { - Unknown, - Image, - Video, - } - - /// - /// All the sources in this list are common image formats supported by Windows Imaging Component (WIC). - /// Some formats might require additional codecs to be installed. - ///

- /// The extensions list are taken from:
- /// https://developer.mozilla.org/en-US/docs/Web/Media/Guides/Formats/Image_types#jpeg_joint_photographic_experts_group_image - ///
- internal static readonly HashSet SupportedImageBitmapExtensions = new([ - ".jpg", ".jpeg", ".jpe", ".jif", ".jfif", // "image/jpeg" - ".apng", ".png", // "image/apng" and "image/png" - ".bmp", // "image/bmp" - ".gif", // "image/gif" - ".ico", // "image/x-icon" - ".tif", ".tiff" // "image/tiff" - ], StringComparer.OrdinalIgnoreCase); - internal static readonly HashSet.AlternateLookup> SupportedImageBitmapExtensionsLookup = - SupportedImageBitmapExtensions.GetAlternateLookup>(); - - internal static readonly HashSet SupportedImageBitmapExternalCodecExtensions = new([ - ".jxr", // "image/jxr" (Requires additional codec) - ".avif", // "image/avif" (Requires additional codec) - ".heic", // "image/heic" (Requires additional codec) - ".heif", // "image/heif" (Requires additional codec) - ".webp" // "image/webp" (Requires additional codec) - ], StringComparer.OrdinalIgnoreCase); - internal static readonly HashSet.AlternateLookup> SupportedImageBitmapExternalCodecExtensionsLookup = - SupportedImageBitmapExternalCodecExtensions.GetAlternateLookup>(); - - internal static readonly HashSet SupportedImageVectorExtensions = new([ - ".svg" // "image/svg" - ], StringComparer.OrdinalIgnoreCase); - internal static readonly HashSet.AlternateLookup> SupportedImageVectorExtensionsLookup = - SupportedImageVectorExtensions.GetAlternateLookup>(); - - internal static readonly HashSet SupportedVideoExtensions = new([ - ".3gp", ".3gp2", // "video/3gp" - ".asf", ".wmv", // "video/wmv" - ".avi", // "video/avi" - ".flv", ".f4v", // "video/flv" - ".mp4", ".m4v", // "video/mp4" - ".mov", ".movie", ".qt", // "video/quicktime" - ".webm", // "video/webm" - ".mpg", ".mpeg", ".ts", ".tsv", ".ps", ".m2ts", ".mts", ".vob", // "video/mpeg" - ".ogv", // "video/ogg" - ".mkv", ".mks" // "video/matroska" - ], StringComparer.OrdinalIgnoreCase); - internal static readonly HashSet.AlternateLookup> SupportedVideoExtensionsLookup = - SupportedVideoExtensions.GetAlternateLookup>(); - - #endregion - - #region Fields - - private bool _isLoaded = true; - - #endregion - - #region Loaders - - private static void PlaceholderSource_OnChange(DependencyObject d, DependencyPropertyChangedEventArgs e) - { - LayeredBackgroundImage element = (LayeredBackgroundImage)d; - if (!element.IsLoaded) - { - return; - } - - Grid grid = element._placeholderGrid; - if (!IsSourceKindEquals(element._lastPlaceholderSource, element.PlaceholderSource)) - { - element.LoadFromSourceAsyncDetached(PlaceholderSourceProperty, - nameof(PlaceholderStretch), - nameof(PlaceholderHorizontalAlignment), - nameof(PlaceholderVerticalAlignment), - grid, - false, - ref element._lastPlaceholderSourceType); - element._lastPlaceholderSource = element.PlaceholderSource; - } - } - - private static void BackgroundSource_OnChange(DependencyObject d, DependencyPropertyChangedEventArgs e) - { - LayeredBackgroundImage element = (LayeredBackgroundImage)d; - if (!element.IsLoaded) - { - return; - } - - // If the element can be used on initial load change (with autoplay off) or static source is changed while previous static background is used, - // then load static background source. - if (element is { CanUseStaticBackground: true, IsVideoPlay: true } or { CanUseStaticBackground: true, IsUseStaticBackgroundUsed: true }) - { - if (!IsSourceKindEquals(element._lastBackgroundStaticSource, element.BackgroundStaticSource)) - { - BackgroundSource_UseStatic(element); - element._lastBackgroundStaticSource = element.BackgroundStaticSource; - } - return; - } - - if (!IsSourceKindEquals(element._lastBackgroundSource, element.BackgroundSource)) - { - BackgroundSource_UseNormal(element); - element._lastBackgroundSource = element.BackgroundSource; - } - } - - private static void BackgroundSource_UseStatic(LayeredBackgroundImage element) - { - Grid grid = element._backgroundGrid; - - element.LoadFromSourceAsyncDetached(BackgroundStaticSourceProperty, - nameof(BackgroundStretch), - nameof(BackgroundHorizontalAlignment), - nameof(BackgroundVerticalAlignment), - grid, - false, - ref element._lastBackgroundStaticSourceType); - - element.IsUseStaticBackgroundUsed = true; - } - - private static void BackgroundSource_UseNormal(LayeredBackgroundImage element) - { - Grid grid = element._backgroundGrid; - element.LoadFromSourceAsyncDetached(BackgroundSourceProperty, - nameof(BackgroundStretch), - nameof(BackgroundHorizontalAlignment), - nameof(BackgroundVerticalAlignment), - grid, - true, - ref element._lastBackgroundSourceType); - - element.IsUseStaticBackgroundUsed = false; - } - - private static void ForegroundSource_OnChange(DependencyObject d, DependencyPropertyChangedEventArgs e) - { - LayeredBackgroundImage element = (LayeredBackgroundImage)d; - if (!element.IsLoaded) - { - return; - } - - Grid grid = element._foregroundGrid; - if (!IsSourceKindEquals(element._lastForegroundSource, element.ForegroundSource)) - { - element.LoadFromSourceAsyncDetached(ForegroundSourceProperty, - nameof(ForegroundStretch), - nameof(ForegroundHorizontalAlignment), - nameof(ForegroundVerticalAlignment), - grid, - false, - ref element._lastForegroundSourceType); - element._lastForegroundSource = element.ForegroundSource; - } - } - - private void LoadFromSourceAsyncDetached( - DependencyProperty sourceProperty, - string stretchProperty, - string horizontalAlignmentProperty, - string verticalAlignmentProperty, - Grid grid, - bool canReceiveVideo, - ref MediaSourceType lastMediaType, - bool isForceNotLoad = false) - { - try - { - object? source = GetValue(sourceProperty); - if (isForceNotLoad) - { - source = null; - } - - if (source is null) - { - goto ClearAndReturnUnknown; - } - - if (!TryGetMediaPathFromSource(source, out string? mediaPath)) - { - goto ClearAndReturnUnknown; - } - - if (GetMediaSourceTypeFromPath(mediaPath) is var mediaType && - mediaType == MediaSourceType.Unknown) - { - goto ClearAndReturnUnknown; - } - - if (lastMediaType == MediaSourceType.Video) - { - // Synchronously dispose the old player before loading the new source, - // cancelling any in-flight fade-out that would otherwise dispose it later. - CancellationTokenSource? fadeCts = Interlocked.Exchange(ref _videoPlayerFadeCts, null); - fadeCts?.Cancel(); - fadeCts?.Dispose(); - SetValue(IsVideoPlayProperty, false); - if (_videoPlayer != null!) - { - DisposeVideoPlayer(); - } - } - - _ = InnerLoadDetached().ConfigureAwait(false); - lastMediaType = mediaType; - return; - - async Task InnerLoadDetached() - { - try - { - if (!IsLoaded || grid.IsObjectDisposed()) - { - return; - } - - // ReSharper disable once ConvertIfStatementToSwitchStatement - if (mediaType == MediaSourceType.Image && - await LoadImageFromSourceAsync(source, - stretchProperty, - horizontalAlignmentProperty, - verticalAlignmentProperty, - this, - grid)) - { - return; - } - - if (mediaType == MediaSourceType.Video && - canReceiveVideo && - await LoadVideoFromSourceAsync(source, this)) - { - } - } - catch (Exception e) - { - Console.WriteLine(e); - } - } - } - catch (Exception ex) - { - Console.WriteLine(ex); - } - - ClearAndReturnUnknown: - ClearMediaGrid(grid); - lastMediaType = MediaSourceType.Unknown; - } - - private static async ValueTask LoadImageFromSourceAsync( - object? source, - string stretchProperty, - string horizontalAlignmentProperty, - string verticalAlignmentProperty, - LayeredBackgroundImage instance, - Grid grid) - { - try - { - // Create instance - Image image = new(); - - // Update property state - instance.SetValue(IsVideoPlayProperty, false); - - // Bind property - image.BindProperty(instance, stretchProperty, Image.StretchProperty, BindingMode.OneWay); - image.BindProperty(instance, horizontalAlignmentProperty, HorizontalAlignmentProperty, BindingMode.OneWay); - image.BindProperty(instance, verticalAlignmentProperty, VerticalAlignmentProperty, BindingMode.OneWay); - - ClearMediaGrid(grid); - grid.Children.Add(image); - - image.Tag = (grid, instance); - image.ImageOpened += Image_ImageOpened; - - Uri? sourceUri = source as Uri; - - if (sourceUri == null && - source is string asStringSource) - { - sourceUri = asStringSource.GetStringAsUri(); - } - - Stream? sourceStream = null; - if (source is Stream { CanSeek: true, CanRead: true } asSeekableStream) - { - sourceStream = asSeekableStream; - } - - if (sourceStream == null && - sourceUri == null) - { - return false; - } - - return await image.LoadImageAsync(sourceUri, sourceStream, instance, UseImageCacheProperty); - } - catch (Exception e) - { - Console.WriteLine(e); - return false; - } - } - - private static async Task LoadVideoFromSourceAsync( - object? source, - LayeredBackgroundImage instance) - { - if (instance.MediaCacheHandler is { } cacheHandler) - { - MediaCacheResult cacheResult = await cacheHandler.LoadCachedSource(source); - source = cacheResult.CachedSource; - } - - Uri? sourceUri = source as Uri; - - if (sourceUri == null && - source is string asStringSource) - { - sourceUri = asStringSource.GetStringAsUri(); - } - - Stream? sourceStream = null; - if (source is Stream { CanSeek: true, CanRead: true } asSeekableStream) - { - sourceStream = asSeekableStream; - } - - if (sourceStream == null && - sourceUri == null) - { - return false; - } - - try - { - // Set-ups Video Player upfront - instance.InitializeVideoPlayer(); - - bool useFfmpeg = instance.UseFfmpegDecoder; - MediaPlayer? player = instance._videoPlayer; - - // Assign media source using FFmpeg. - if (useFfmpeg) - { - IRandomAccessStream? sourceStreamRandom = null; - bool isError = false; - try - { - MediaSourceConfig ffmpegConfig = new() - { - Video = - { - MaxDecoderThreads = (uint)Environment.ProcessorCount, - VideoOutputAllow10bit = true, - VideoOutputAllowBgra8 = true, - VideoOutputAllowNv12 = true, - VideoDecoderMode = instance.FfmpegDecoderMode - }, - General = - { - ReadAheadBufferEnabled = true, - ReadAheadBufferDuration = TimeSpan.FromSeconds(15) - } - }; - - int loadFfmpegRetry = 5; - LoadFfmpeg: - FFmpegMediaSource? ffmpegMediaSource = null; - WindowId windowId = instance.GetElementWindowId(); - - if (sourceStream != null) - { - sourceStreamRandom = sourceStream.AsRandomAccessStream(true); - ffmpegMediaSource = - await FFmpegMediaSource.CreateFromStreamAsync(sourceStreamRandom, ffmpegConfig, windowId.Value); - } - else if (sourceUri != null) - { - ffmpegMediaSource = await CreateFFmpegSourceFromUri(sourceUri, ffmpegConfig, windowId.Value); - } - - // Yeet - if (ffmpegMediaSource == null) - { - isError = true; - return false; - } - - await ffmpegMediaSource.OpenWithMediaPlayerAsync(player); - Interlocked.Exchange(ref instance._videoFfmpegMediaSource, ffmpegMediaSource); - - // HACK: - // Sometimes the media source isn't ready when the window just get restored from minimized state, - // which causing media to stale and gets MediaPlayer.CurrentState to Closed. So, wait and reinitialize - // until it's ready. - if (!(ffmpegMediaSource.PlaybackItem?.Source.IsOpen ?? false)) - { - await Task.Delay(150); - - // Unsubscribe frame renderer event to avoid double call, and then mark deinitialization. - Interlocked.Exchange(ref instance._isVideoInitialized, 0); - player?.VideoFrameAvailable -= !instance._useSafeFrameRenderer - ? instance.VideoPlayer_VideoFrameAvailableUnsafe - : instance.VideoPlayer_VideoFrameAvailableSafe; - - ffmpegMediaSource.Dispose(); - sourceStreamRandom?.Dispose(); - sourceStreamRandom = null; - - if (loadFfmpegRetry <= 0) - { - isError = true; - return false; - } - - --loadFfmpegRetry; - goto LoadFfmpeg; - } - } - catch (Exception ex) - { - isError = true; - Logger.LogWriteLine($"Cannot load media source with FFmpeg due to an error: {ex}", - LogType.Error, - true); - } - finally - { - if (isError) sourceStreamRandom?.Dispose(); - } - } - else - { - // Assign media source using Media Foundation. - if (sourceStream != null) - { - IRandomAccessStream sourceStreamRandom = sourceStream.AsRandomAccessStream(true); - player?.SetStreamSource(sourceStreamRandom); - } - else if (sourceUri != null) - { - instance._videoPlayer?.SetUriSource(sourceUri); - } - else - { - return false; - } - } - } - catch (Exception e) - { - Console.WriteLine(e); - return false; - } - - return true; - } - - [SkipLocalsInit] - private static bool TryGetSourceHashCode(object? obj, out int hashCode) - { - Unsafe.SkipInit(out hashCode); - - switch (obj) - { - case Uri asUri: - hashCode = asUri.AbsolutePath.GetHashCode(); - return true; - case string asString: - hashCode = asString.GetHashCode(); - return true; - case FileStream asStream: - hashCode = asStream.Name.GetHashCode(); - return true; - default: - return false; - } - } - - private void InitializeVideoFrameOnMediaOpened(MediaPlayer sender, object args) - { - DispatcherQueue.TryEnqueue(Impl); - return; - - void Impl() - { - if (!IsLoaded) - { - return; - } - - // Update Media Duration and Update Binding to it. - SetValue(MediaDurationProperty, sender.NaturalDuration); - SetValue(IsCurrentMediaSeekableProperty, sender.CanSeek); - - // Create instance - Image image = new() - { - Tag = (_backgroundGrid, this), - Name = "VideoRenderFrame" - }; - - // Bind property - image.BindProperty(this, - nameof(BackgroundStretch), - Image.StretchProperty, - BindingMode.OneWay); - image.BindProperty(this, - nameof(BackgroundHorizontalAlignment), - HorizontalAlignmentProperty, - BindingMode.OneWay); - image.BindProperty(this, - nameof(BackgroundVerticalAlignment), - VerticalAlignmentProperty, - BindingMode.OneWay); - - InitializeRenderTargetSize(sender.PlaybackSession); - - // Register events - image.Loaded += Image_VideoFrameOnLoaded; - image.Unloaded += Image_VideoFrameOnUnloaded; - - // Add to children - image.Transitions.Add(new ContentThemeTransition()); - _backgroundGrid.Children.Add(image); - } - } - - private static void Image_VideoFrameOnLoaded(object sender, RoutedEventArgs e) - { - if (sender is not Image { Tag: ValueTuple parentGrid }) - { - return; - } - - 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) - { - if (sender is not Image { Tag: ValueTuple parentGrid }) - { - return; - } - - parentGrid.Item2.DisposeAndPauseVideoView(); - } - - private static void Image_ImageOpened(object sender, RoutedEventArgs e) - { - if (sender is not Image { Tag: ValueTuple parentGrid } image) - { - return; - } - - // Set placeholder to hidden once loaded - ref bool isPlaceholderHidden = ref parentGrid.Item2._isPlaceholderHidden; - if (parentGrid.Item1.Name.StartsWith("Background", StringComparison.OrdinalIgnoreCase) && - !Interlocked.Exchange(ref isPlaceholderHidden, true)) - { - VisualStateManager.GoToState(parentGrid.Item2, StateNamePlaceholderStateHidden, true); - - // Only notify early if background is an image or if autoplay is not enabled - if (parentGrid.Item2._lastBackgroundSourceType == MediaSourceType.Image || - parentGrid.Item2._lastBackgroundStaticSourceType == MediaSourceType.Image || - !(bool)parentGrid.Item2.GetValue(IsVideoAutoplayProperty)) - { - parentGrid.Item2.NotifyImageLoaded(); - } - } - - // HACK: Tells the Grid to temporarily detach all UIElement children - // then re-add the image to the grid - ClearMediaGrid(parentGrid.Item1, image); - - // Remove transition once loaded - image.Transitions.Clear(); - } - - private static MediaSourceType GetMediaSourceTypeFromPath(ReadOnlySpan path) - { - ReadOnlySpan extension = Path.GetExtension(path); - - if (SupportedImageBitmapExtensionsLookup.Contains(extension) || - SupportedImageBitmapExternalCodecExtensionsLookup.Contains(extension) || - SupportedImageVectorExtensionsLookup.Contains(extension)) - { - return MediaSourceType.Image; - } - - return SupportedVideoExtensionsLookup.Contains(extension) - ? MediaSourceType.Video - : MediaSourceType.Unknown; - } - - // Returns true if the media source is supported. Otherwise, false and return a null string path. - private static bool TryGetMediaPathFromSource(object? source, [NotNullWhen(true)] out string? path) - { - Unsafe.SkipInit(out path); - - path = source switch - { - string asString => asString, - Uri asUrl => asUrl.AbsolutePath, - FileInfo asFileInfo => asFileInfo.FullName, - FileStream asFileStream => asFileStream.Name, - _ => path - }; - - return !string.IsNullOrEmpty(path); - } - - private static void ClearMediaGrid(Grid grid, UIElement? except = null) - { - List elementExcepted = - except == null ? grid.Children.ToList() : - grid.Children.Where(x => x != except) - .ToList(); - - foreach (Image image in elementExcepted.OfType()) - { - // This one is for Image. The source will always guarantee to call this event. - image.ImageOpened -= Image_ImageOpened; - // This one is for Video since ImageOpened with Canvas source will never trigger this so we use Loaded instead. - image.Loaded -= Image_VideoFrameOnLoaded; - image.Unloaded -= Image_VideoFrameOnUnloaded; - // Clears the loaded ImageSource - image.Source = null; - } - - foreach (UIElement element in elementExcepted) - { - grid.Children.Remove(element); - } - } - - #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 deleted file mode 100644 index 717f31ee51..0000000000 --- a/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Events.cs +++ /dev/null @@ -1,511 +0,0 @@ -using CollapseLauncher.Helper; -using Microsoft.UI.Composition; -using Microsoft.UI.Dispatching; -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; -using Windows.Media.Playback; -#nullable enable - -namespace CollapseLauncher.XAMLs.Theme.CustomControls; - -public partial class LayeredBackgroundImage -{ - public event Action? ImageLoaded; - - #region Fields - - private object? _lastPlaceholderSource; - private object? _lastBackgroundSource; - private object? _lastBackgroundStaticSource; - private object? _lastForegroundSource; - - private MediaSourceType _lastPlaceholderSourceType; - private MediaSourceType _lastBackgroundSourceType; - private MediaSourceType _lastBackgroundStaticSourceType; - private MediaSourceType _lastForegroundSourceType; - - private bool _isPlaceholderHidden; - private int _isFirstLoadNotified; - - #endregion - - #region Loaded and Unloaded - - private static bool IsSourceKindEquals(object? left, object? right) - { - if (left is string asStringLeft && right is string asStringRight) - { - return string.Equals(asStringLeft, asStringRight, StringComparison.OrdinalIgnoreCase); - } - - return left == right; - } - - private void ApplyPropertySettings() - { - ParallaxView_ToggleEnable(IsParallaxEnabled); - ParallaxGrid_OnUpdateCenterPoint(); - ElevateView_ToggleEnable(IsBackgroundElevated); - ElevateGrid_OnUpdateCenterPoint(); - IsBackgroundElevated_OnChanged(); - } - - private void LayeredBackgroundImage_OnLoaded(object sender, RoutedEventArgs e) - { - if (!_isTemplateLoaded) - { - return; - } - - Interlocked.Exchange(ref _isLoaded, true); - ApplyPropertySettings(); - - if (!_isPlaceholderHidden && - !IsSourceKindEquals(_lastPlaceholderSource, PlaceholderSource)) - { - LoadFromSourceAsyncDetached(PlaceholderSourceProperty, - nameof(PlaceholderStretch), - nameof(PlaceholderHorizontalAlignment), - nameof(PlaceholderVerticalAlignment), - _placeholderGrid, - false, - ref _lastPlaceholderSourceType); - _lastPlaceholderSource = PlaceholderSource; - } - - if (CanUseStaticBackground && !IsVideoAutoplay) - { - if (!IsSourceKindEquals(_lastBackgroundStaticSource, BackgroundStaticSource)) - { - BackgroundSource_UseStatic(this); - _lastBackgroundStaticSource = BackgroundStaticSource; - } - } - else - { - if (!IsSourceKindEquals(_lastBackgroundSource, BackgroundSource)) - { - BackgroundSource_UseNormal(this); - _lastBackgroundSource = BackgroundSource; - } - } - - // ReSharper disable once InvertIf - if (!IsSourceKindEquals(_lastForegroundSource, ForegroundSource)) - { - LoadFromSourceAsyncDetached(ForegroundSourceProperty, - nameof(ForegroundStretch), - nameof(ForegroundHorizontalAlignment), - nameof(ForegroundVerticalAlignment), - _foregroundGrid, - false, - ref _lastForegroundSourceType); - _lastForegroundSource = ForegroundSource; - } - } - - private void LayeredBackgroundImage_OnUnloaded(object sender, RoutedEventArgs e) - { - Interlocked.Exchange(ref _isLoaded, false); - - ParallaxGrid_UnregisterEffect(); - _lastParallaxHoverSource = null; - } - - #endregion - - #region Parallax View - - private static void IsParallaxEnabled_OnChange(DependencyObject d, DependencyPropertyChangedEventArgs e) - { - LayeredBackgroundImage element = (LayeredBackgroundImage)d; - if (!element.IsLoaded) - { - return; - } - - element.ParallaxView_ToggleEnable(element.IsParallaxEnabled); - } - - private static void ParallaxHover_OnChange(DependencyObject d, DependencyPropertyChangedEventArgs e) - { - LayeredBackgroundImage element = (LayeredBackgroundImage)d; - if (!element.IsLoaded) - { - return; - } - - element.ParallaxGrid_RegisterPointerEvents(); // Re-register hover event - } - - private void ParallaxView_ToggleEnable(bool isEnable) - { - if (isEnable) - { - ParallaxGrid_RegisterEffect(); - return; - } - - ParallaxGrid_UnregisterEffect(); - } - - private void ParallaxGrid_RegisterEffect() - { - ParallaxGrid_RegisterPointerEvents(); - ParallaxGrid_OffsetReset(); - - if (_parallaxGrid != null!) - { - _parallaxGrid.SizeChanged += ParallaxGrid_OnSizeChanged; - } - } - - private void ParallaxGrid_UnregisterEffect() - { - ParallaxGrid_UnregisterPointerEvents(); - ParallaxGrid_OffsetReset(); - - if (_parallaxGrid != null!) - { - _parallaxGrid.SizeChanged -= ParallaxGrid_OnSizeChanged; - } - } - - private void ParallaxGrid_RegisterPointerEvents() - { - UIElement? eventSource = ParallaxGrid_UnregisterPointerEvents(); - if (eventSource == null) - { - return; - } - - eventSource.PointerMoved += ParallaxGrid_OnPointerMoved; - eventSource.PointerEntered += ParallaxGrid_OnPointerEntered; - eventSource.PointerExited += ParallaxGrid_OnPointerExited; - _lastParallaxHoverSource = eventSource; - } - - private UIElement? ParallaxGrid_UnregisterPointerEvents() - { - UIElement? eventSource = ParallaxHoverSource ?? _parallaxGrid; - if (_lastParallaxHoverSource != null) - { - _lastParallaxHoverSource.PointerMoved -= ParallaxGrid_OnPointerMoved; - _lastParallaxHoverSource.PointerEntered -= ParallaxGrid_OnPointerEntered; - _lastParallaxHoverSource.PointerExited -= ParallaxGrid_OnPointerExited; - } - - if (eventSource == null!) - { - return null; - } - - eventSource.PointerMoved -= ParallaxGrid_OnPointerMoved; - eventSource.PointerEntered -= ParallaxGrid_OnPointerEntered; - eventSource.PointerExited -= ParallaxGrid_OnPointerExited; - return eventSource; - } - - private void ParallaxGrid_OnSizeChanged(object sender, SizeChangedEventArgs e) - { - ParallaxGrid_OnUpdateCenterPoint(); - } - - private void ParallaxGrid_OnUpdateCenterPoint() - { - if (!IsLoaded || _parallaxGridVisual == null!) - { - return; - } - - _parallaxGridVisual.CenterPoint = new Vector3((float)_parallaxGrid.RenderSize.Width / 2, - (float)_parallaxGrid.RenderSize.Height / 2, - 0); - } - - private void ParallaxGrid_OnPointerExited(object sender, PointerRoutedEventArgs e) - { - if (ParallaxResetOnUnfocused) - { - ParallaxGrid_OffsetReset(); - } - } - - private void ParallaxGrid_OnPointerEntered(object sender, PointerRoutedEventArgs e) - { - ParallaxGrid_OnPointerMovedCore(sender, e, true); - } - - private void ParallaxGrid_OffsetReset() - { - StartElementElevateEasingAnimation(_parallaxGridCompositor, - _parallaxGridVisual, - Vector3.Zero, - Vector3.One, - 250d, - easingFunction: _parallaxEasingFunction); - } - - private void ParallaxGrid_OnPointerMoved(object sender, PointerRoutedEventArgs e) - { - ParallaxGrid_OnPointerMovedCore(sender, e); - } - - private void ParallaxGrid_OnPointerMovedCore( - object sender, - PointerRoutedEventArgs args, - bool isUseResetAnimation = false) - { - FrameworkElement element = (FrameworkElement)sender; - - // Move - Point pos = args.GetCurrentPoint(element).Position; - double w = element.ActualWidth; - double h = element.ActualHeight; - - if (w <= 0 || h <= 0) - return; - - // Normalize mouse position to range [-1, +1] - double nx = pos.X / w * 2d - 1d; - double ny = pos.Y / h * 2d - 1d; - - double offsetX = ParallaxHorizontalShift; - double offsetY = ParallaxVerticalShift; - - double deltaMs = Math.Max(1000d / WindowUtility.CurrentWindowMonitorRefreshRate, 8d); - - StartElementOffsetAnimation(_parallaxGridCompositor, - _parallaxGridVisual, - _parallaxGrid, - offsetX, - offsetY, - nx, - ny, - isUseResetAnimation ? 250d : deltaMs, - easingFunction: _parallaxEasingFunction, - useAnimatedOffset: isUseResetAnimation); - } - - private static void StartElementOffsetAnimation( - Compositor compositor, - Visual visual, - UIElement element, - double offsetX, - double offsetY, - double centerPointX, - double centerPointY, - double durationMs, - CompositionEasingFunction? easingFunction = null, - bool useAnimatedOffset = true) - { - // Move opposite to center point - float tx = (float)(-centerPointX * offsetX); - float ty = (float)(-centerPointY * offsetY); - - // Scale with x2 as it counts with each side of axis (Left, Right) (Top, Bottom) - Vector2 size = element.ActualSize; - double sizeToX = size.X + Math.Abs(offsetX) * 2; - double sizeToY = size.Y + Math.Abs(offsetY) * 2; - - double addScaleX = sizeToX / size.X; - double addScaleY = sizeToY / size.Y; - - // Gets the stronger axis - float factorScale = (float)Math.Max(addScaleX, addScaleY); - - StartElementElevateEasingAnimation(compositor, - visual, - Vector3.Zero with { X = tx, Y = ty }, - Vector3.One with { X = factorScale, Y = factorScale }, - durationMs, - easingFunction, - useAnimatedOffset); - } - - private static void StartElementElevateEasingAnimation( - Compositor compositor, - Visual visual, - Vector3 offset, - Vector3 scale, - double duration, - CompositionEasingFunction? easingFunction = null, - bool useAnimatedOffset = true) - { - const string targetTranslation = "Offset"; - const string targetScale = "Scale"; - - if (compositor == null!) - { - return; - } - - // Scale - Vector3KeyFrameAnimation scaleAnim = CreateVector3Keyframe(scale, targetScale); - visual.StartAnimation(targetScale, scaleAnim); - - if (useAnimatedOffset) - { - // Move - Vector3KeyFrameAnimation offsetAnim = CreateVector3Keyframe(offset, targetTranslation); - visual.StartAnimation(targetTranslation, offsetAnim); - return; - } - - // Directly set visual offset - visual.Offset = offset; - return; - - Vector3KeyFrameAnimation CreateVector3Keyframe( - Vector3 valueTo, - string targetProperty) - { - Vector3KeyFrameAnimation keyframe = compositor.CreateVector3KeyFrameAnimation(); - keyframe.InsertKeyFrame(1, valueTo, easingFunction); - keyframe.Target = targetProperty; - keyframe.Duration = TimeSpan.FromMilliseconds(duration); - - return keyframe; - } - } - - #endregion - - #region Local Events - - private void NotifyImageLoaded() - { - if (Interlocked.Exchange(ref _isFirstLoadNotified, 1) == 0) - { - 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); - } - - private void NotifyVideoLoaded(MediaPlayer sender, object args) - { - sender.VideoFrameAvailable -= NotifyVideoLoaded; - DispatcherQueue.TryEnqueue(DispatcherQueuePriority.Low, NotifyImageLoaded); - } - - #endregion - - #region Background Elevation - - private static void IsBackgroundElevated_OnChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) => - ((LayeredBackgroundImage)d).IsBackgroundElevated_OnChanged(); - - private void IsBackgroundElevated_OnChanged() - { - if (!IsLoaded) - { - return; - } - - ElevateView_ToggleEnable(IsBackgroundElevated); - } - - private void ElevateView_ToggleEnable(bool isEnable) - { - if (_elevateGrid == null!) - { - return; - } - - try - { - if (isEnable) - { - ElevateGrid_RegisterEffect(); - return; - } - - ElevateGrid_UnregisterEffect(); - } - finally - { - ElevateGrid_UpdateElevationSize(isEnable); - } - } - - private void ElevateGrid_RegisterEffect() - { - if (_elevateGrid != null!) - { - _elevateGrid.SizeChanged += ElevateGrid_OnSizeChanged; - } - } - - private void ElevateGrid_UnregisterEffect() - { - if (_parallaxGrid != null!) - { - _parallaxGrid.SizeChanged -= ElevateGrid_OnSizeChanged; - } - } - - private void ElevateGrid_OnSizeChanged(object sender, SizeChangedEventArgs e) - { - ElevateGrid_OnUpdateCenterPoint(); - } - - private void ElevateGrid_OnUpdateCenterPoint() - { - if (!IsLoaded || _elevateGrid == null!) - { - return; - } - - _elevateGridVisual.CenterPoint = new Vector3((float)_elevateGrid.RenderSize.Width / 2, - (float)_elevateGrid.RenderSize.Height / 2, - 0); - - ElevateGrid_UpdateElevationSize(IsBackgroundElevated); - } - - private void ElevateGrid_UpdateElevationSize(bool enable) - { - double elevationOffset = enable - ? BackgroundElevationPixels - : 0d; - - StartElementOffsetAnimation(_elevateGridCompositor, - _elevateGridVisual, - _elevateGrid, - elevationOffset, - elevationOffset, - 0d, - 0d, - 500d, - _elevateEasingFunction); - } - - #endregion -} - diff --git a/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Loader.PresenterFactory.cs b/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Loader.PresenterFactory.cs new file mode 100644 index 0000000000..ff69616a6f --- /dev/null +++ b/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Loader.PresenterFactory.cs @@ -0,0 +1,146 @@ +using CollapseLauncher.Extension; +using CollapseLauncher.GameManagement.ImageBackground; +using CollapseLauncher.XAMLs.Theme.CustomControls.VideoPresenter; +using FFmpegInteropX; +using Microsoft.UI; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Data; +using Microsoft.UI.Xaml.Media; +using System.Threading; +using System.Threading.Tasks; +using Windows.Media.Playback; +// ReSharper disable AccessToModifiedClosure + +#nullable enable +namespace CollapseLauncher.XAMLs.Theme.CustomControls; + +public partial class LayeredBackgroundImage +{ + private Image? AddImagePresenterAsync( + Panel parent, + object? source, + SourceContext sourceContext, + RoutedEventHandler? onLoadedEventHandler, + ExceptionRoutedEventHandler? onFailedEventHandler = null, + CancellationToken token = default) + { + if (token.IsCancellationRequested) + return null; + + if (!LayeredBackgroundImageExtensions + .TryGetBindingPropertyNames(parent, + out string? stretchNameOf, + out string? hAlignNameOf, + out string? vAlignNameOf)) + { + return null; + } + + Image image = new() + { + Tag = sourceContext + }; + parent.Children.Add(image); + image.ImageOpened += onLoadedEventHandler; + image.ImageFailed += onFailedEventHandler; + + ImageSource? imageSource = LayeredBackgroundImageExtensions.GetImageSource(source, null, token); + if (imageSource is null) + return null; + + sourceContext.MediaSource = imageSource; + image.BindProperty(this, + stretchNameOf, + Image.StretchProperty, + BindingMode.OneWay); + + image.BindProperty(this, + hAlignNameOf, + HorizontalAlignmentProperty, + BindingMode.OneWay); + + image.BindProperty(this, + vAlignNameOf, + VerticalAlignmentProperty, + BindingMode.OneWay); + + image.Source = imageSource; + + // As the source might be ImageSource, and we can't rely on its ImageOpened might be + // triggered early before this method, so we notify the event handler manually. + if (source is ImageSource) + onLoadedEventHandler?.Invoke(image, null!); + + return image; + } + + private async Task AddVideoPresenterAsync( + Panel parent, + object? source, + SourceContext sourceContext, + RoutedEventHandler? onLoadedEventHandler, + CancellationToken token) + { + if (token.IsCancellationRequested) + return null; + + if (!LayeredBackgroundImageExtensions + .TryGetBindingPropertyNames(parent, + out _, + out string? hAlignNameOf, + out string? vAlignNameOf)) + { + return null; + } + + bool isUseFfmpeg = UseFfmpegDecoder && ImageBackgroundManager.Shared.GlobalIsFFmpegAvailable; + WindowId windowId = parent.GetElementWindowId(); + + MediaFoundationPresenter? presenter = null; + + (MediaPlayer? mediaPlayer, FFmpegMediaSource? ffmpegMediaSource) = + await LayeredBackgroundImageExtensions.GetMediaSourceAsync(source, + isUseFfmpeg, + FfmpegDecoderMode, + windowId, + IsVideoAutoplay, + !IsAudioEnabled, + OnMediaOpened, + token: token); + + if (mediaPlayer is null) + return null; + + presenter = new MediaFoundationPresenter + { + Tag = sourceContext + }; + + sourceContext.Player = mediaPlayer; + sourceContext.PlayerPresenter = presenter; + sourceContext.MediaSource = ffmpegMediaSource; + + /* + presenter.BindProperty(this, + stretchNameOf, + MediaFoundationPresenter.StretchProperty, + BindingMode.OneWay); + */ + + presenter.BindProperty(this, + hAlignNameOf, + HorizontalAlignmentProperty, + BindingMode.OneWay); + + presenter.BindProperty(this, + vAlignNameOf, + VerticalAlignmentProperty, + BindingMode.OneWay); + + parent.Children.Add(presenter); + return presenter; + + void OnMediaOpened() => presenter?.DispatcherQueue.TryEnqueue(() => onLoadedEventHandler?.Invoke(presenter, null)); + } +} diff --git a/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Loader.cs b/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Loader.cs new file mode 100644 index 0000000000..ef73a10fde --- /dev/null +++ b/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Loader.cs @@ -0,0 +1,287 @@ +using CollapseLauncher.XAMLs.Theme.CustomControls.VideoPresenter; +using Hi3Helper; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using Windows.Media.Playback; + +// ReSharper disable AccessToModifiedClosure + +#nullable enable +namespace CollapseLauncher.XAMLs.Theme.CustomControls; + +public partial class LayeredBackgroundImage +{ + #region Loader + + private async void LoadAllPresenters() + { + Task foregroundLoadTask = LoadPresenter(_foregroundGrid, ForegroundSource); + Task backgroundLoadTask = LoadPresenter(_backgroundGrid, BackgroundSource); + + await Task.WhenAll(foregroundLoadTask, backgroundLoadTask); + } + + private async Task LoadPresenter( + Panel parent, + object? source) + { + FrameworkElement? currentPresenter = null; + ulong sourceContextGen = Interlocked.Increment(ref _sourceContextGeneration); + try + { + InvalidateLastSourceLoadingToken(parent); + if (source == null) + return; + + bool isBackgroundParent = parent.Name.StartsWith("Background", StringComparison.OrdinalIgnoreCase); + SourceContext sourceContext = new(this, sourceContextGen) + { + Source = source + }; + + CancellationToken loadToken = sourceContext.SourceLoadingCts.Token; + TaskCompletionSource tcs = new(); + + SourceGuessResult sourceGuessResult = LayeredBackgroundImageExtensions.GuessSourceType(source); + if (sourceGuessResult.ObjectType == SourceObjectType.Unknown || + sourceGuessResult.FormatType == SourceFormatType.Unknown) + { + // Can't read. Return and clear the media instead. + return; + } + + sourceContext.IsDisposeSource = sourceGuessResult.ObjectType + is SourceObjectType.URL + or SourceObjectType.IRandomAccessStream // Reason? It's just a wrapper. It won't dispose the managed Stream underneath. + or SourceObjectType.ImageSource + or SourceObjectType.MediaSource; + + if (sourceGuessResult.FormatType == SourceFormatType.Image) + { + // Notify duration earlier + SetValue(MediaDurationPositionProperty, TimeSpan.Zero); + SetValue(MediaDurationProperty, TimeSpan.Zero); + currentPresenter = AddImagePresenterAsync(parent, + source, + sourceContext, + ImagePresenterOnLoaded, + token: loadToken); + + async void ImagePresenterOnLoaded(object sender, RoutedEventArgs e) + { + if (sender is not Image asImage) + { + return; + } + + asImage.ImageOpened -= ImagePresenterOnLoaded; + asImage.ImageFailed -= ImagePresenterOnLoaded; + asImage.Loaded -= ImagePresenterOnLoaded; + await UnloadLastPresenter(parent, asImage); + tcs.TrySetResult(); + } + } + + if (sourceGuessResult.FormatType == SourceFormatType.Video) + { + currentPresenter = await AddVideoPresenterAsync(parent, + source, + sourceContext, + VideoPresenterOnLoaded, + loadToken); + + async void VideoPresenterOnLoaded(object sender, RoutedEventArgs e) + { + if (sender is not MediaFoundationPresenter presenter) + { + return; + } + + // -- For Background Grid Only. Update MediaState and assign subscriber for it. + if (isBackgroundParent && + sourceGuessResult.FormatType == SourceFormatType.Video && + presenter.Tag is SourceContext { Player: { } player } thisSourceContext) + { + if (IsAudioEnabled) + SetVolume(AudioVolume); + + SetValue(MediaStateProperty, player.CurrentState); // Update the state one time. + SetValue(MediaDurationPositionProperty, player.Position); // Update the state one time. + SetValue(MediaDurationProperty, player.NaturalDuration); // Update the state one time. + thisSourceContext.RegisterPlayerCallbacks(MediaPlayer_OnCurrentStateChanged, + MediaPlayer_OnPositionChanged); + + TimeSpan lastPosition = thisSourceContext.LoadPlayerLastPosition(); + player.Position = lastPosition; // Seek to the last position. + } + + await UnloadLastPresenter(parent, presenter); + tcs.TrySetResult(); + } + } + + // -- For Background Grid Only and notify if the source is loaded as image, notify + // the MediaState as Closed. + if (isBackgroundParent && sourceGuessResult.FormatType == SourceFormatType.Image) + { + SetValue(MediaStateProperty, MediaPlayerState.Closed); + } + + if (currentPresenter == null) + tcs.TrySetResult(); + + await tcs.Task; + } + catch (Exception ex) + { + Logger.LogWriteLine($"An error has occurred while loading source for: {parent.Name}\r\n{ex}", LogType.Error, true); + } + finally + { + // Clear the parent if no presenter is available + if (currentPresenter is null) + { + _ = UnloadLastPresenter(parent); + } + } + } + + #endregion + + #region Invalidator + + private static void InvalidateLastSourceLoadingToken(Panel parent) + { + foreach (UIElement element in parent.Children) + { + if (element is not FrameworkElement { Tag: SourceContext sourceContext }) + { + continue; + } + + sourceContext.SourceLoadingCts?.Cancel(); + } + } + + #endregion + + #region Unloader + + private static async Task UnloadLastPresenter( + Panel parent, + UIElement? currentPresenter = null) + { + Task volumeFadeOutTask = Task.CompletedTask; + List others = []; + + try + { + UIElement? lastPresenter = currentPresenter == null + ? parent.Children.LastOrDefault() + : parent.Children.LastOrDefault(x => x != currentPresenter); + + others.AddRange(currentPresenter == null + ? parent.Children + : parent.Children.Where(other => currentPresenter != other)); + + if (lastPresenter is FrameworkElement { Tag: SourceContext lastSourceContext }) + { + TaskCompletionSource volumeFadeOutTcs = new(); + volumeFadeOutTask = volumeFadeOutTcs.Task; + + // If it has player in its source context, fade out the player first before disposing. + lastSourceContext.SetVolume(0, () => volumeFadeOutTcs.TrySetResult()); // Tell to complete the task. + } + + Task visualFadeOutTask = LayeredBackgroundImageExtensions + .StartFadeReplaceAnimationAsync(lastPresenter, currentPresenter); + + await Task.WhenAll(volumeFadeOutTask, visualFadeOutTask); + + foreach (UIElement other in others) + { + parent.Children.Remove(other); + if (other is FrameworkElement { Tag: SourceContext otherSourceContext }) + { + otherSourceContext.UnregisterPlayerCallbacks(); // Unregister callback early + otherSourceContext.Dispose(); + } + } + + Logger.LogWriteLine($"Parent Grid: {parent.Name} has remained: {parent.Children.Count} children", LogType.Debug); + } + catch (Exception ex) + { + Console.WriteLine(ex); + } + } + + private async void UnloadAllPresenters() + { + Task foregroundTask = UnloadLastPresenter(_foregroundGrid); + Task backgroundTask = UnloadLastPresenter(_backgroundGrid); + + await Task.WhenAll(foregroundTask, backgroundTask); + } + + #endregion + + #region Helper + + public bool TryGetBackgroundSourceContext( + [NotNullWhen(true)] out SourceContext? sourceContext, + [NotNullWhen(true)] out MediaPlayer? player) + { + Unsafe.SkipInit(out sourceContext); + Unsafe.SkipInit(out player); + + if (!TryGetPresenterSourceContextFromParent(_backgroundGrid, out sourceContext) || + sourceContext is not { Player: { } thisPlayer }) + { + return false; + } + + player = thisPlayer; + return true; + } + + private static bool TryGetPresenterSourceContextFromParent( + Panel parent, + [NotNullWhen(true)] out SourceContext? sourceContext) + { + Unsafe.SkipInit(out sourceContext); + + if (parent == null! || + parent.Children.LastOrDefault() is not FrameworkElement presenter || + !TryGetPresenterSourceContext(presenter, out sourceContext)) + { + return false; + } + + return true; + } + + private static bool TryGetPresenterSourceContext( + FrameworkElement? presenter, + [NotNullWhen(true)] out SourceContext? sourceContext) + { + Unsafe.SkipInit(out sourceContext); + + if (presenter is not { Tag: SourceContext thisSourceContext }) + { + return false; + } + + sourceContext = thisSourceContext; + return true; + } + + #endregion +} diff --git a/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Properties.cs b/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Properties.cs index be2593feef..468cccde2c 100644 --- a/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Properties.cs +++ b/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Properties.cs @@ -1,10 +1,11 @@ -#nullable enable -using FFmpegInteropX; +using FFmpegInteropX; using Hi3Helper.Data; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Media; using System; +using Windows.Media.Playback; +#nullable enable namespace CollapseLauncher.XAMLs.Theme.CustomControls; public partial class LayeredBackgroundImage @@ -23,8 +24,6 @@ public bool IsVideoAutoplay set => SetValue(IsVideoAutoplayProperty, value); } - public bool IsVideoPlay => (bool)GetValue(IsVideoPlayProperty); - public bool UseFfmpegDecoder { get => (bool)GetValue(UseFfmpegDecoderProperty); @@ -67,9 +66,9 @@ public double ParallaxVerticalShift set => SetValue(ParallaxVerticalShiftProperty, value); } - public UIElement? ParallaxHoverSource + public FrameworkElement? ParallaxHoverSource { - get => (UIElement?)GetValue(ParallaxHoverSourceProperty); + get => (FrameworkElement?)GetValue(ParallaxHoverSourceProperty); set => SetValue(ParallaxHoverSourceProperty, value); } @@ -79,42 +78,12 @@ public bool ParallaxResetOnUnfocused set => SetValue(ParallaxResetOnUnfocusedProperty, value); } - public object? PlaceholderSource - { - get => (object?)GetValue(PlaceholderSourceProperty); - set => SetValue(PlaceholderSourceProperty, value); - } - - public Stretch PlaceholderStretch - { - get => (Stretch)GetValue(PlaceholderStretchProperty); - set => SetValue(PlaceholderStretchProperty, value); - } - - public HorizontalAlignment PlaceholderHorizontalAlignment - { - get => (HorizontalAlignment)GetValue(PlaceholderHorizontalAlignmentProperty); - set => SetValue(PlaceholderHorizontalAlignmentProperty, value); - } - - public VerticalAlignment PlaceholderVerticalAlignment - { - get => (VerticalAlignment)GetValue(PlaceholderVerticalAlignmentProperty); - set => SetValue(PlaceholderVerticalAlignmentProperty, value); - } - public object? BackgroundSource { get => (object?)GetValue(BackgroundSourceProperty); set => SetValue(BackgroundSourceProperty, value); } - public object? BackgroundStaticSource - { - get => (object?)GetValue(BackgroundStaticSourceProperty); - set => SetValue(BackgroundStaticSourceProperty, value); - } - public Stretch BackgroundStretch { get => (Stretch)GetValue(BackgroundStretchProperty); @@ -163,15 +132,22 @@ public double ForegroundOpacity set => SetValue(ForegroundOpacityProperty, value); } + public MediaPlayerState MediaState + { + get => (MediaPlayerState)GetValue(MediaStateProperty); + set => SetValue(MediaStateProperty, value); + } + public TimeSpan MediaDurationPosition { get => (TimeSpan)GetValue(MediaDurationPositionProperty); set => SetValue(MediaDurationPositionProperty, value); } - public TimeSpan MediaDuration => (TimeSpan)GetValue(MediaDurationProperty); - - public bool IsCurrentMediaSeekable => (bool)GetValue(IsCurrentMediaSeekableProperty); + public TimeSpan MediaDuration + { + get => (TimeSpan)GetValue(MediaDurationProperty); + } public double BackgroundElevationPixels { @@ -215,26 +191,12 @@ public string? MediaSourceCacheDir set => SetValue(MediaSourceCacheDirProperty, value); } - private bool CanUseStaticBackground => BackgroundStaticSource is not null; - - private bool IsUseStaticBackgroundUsed - { - get; - set; - } - public double CanvasWidth => (double)GetValue(CanvasWidthProperty); public double CanvasHeight => (double)GetValue(CanvasHeightProperty); #endregion - #region Fields - - private UIElement? _lastParallaxHoverSource; - - #endregion - #region Dependency Properties public static readonly DependencyProperty MediaCacheHandlerProperty = @@ -247,7 +209,7 @@ private bool IsUseStaticBackgroundUsed DependencyProperty.Register(nameof(IsAudioEnabled), typeof(bool), typeof(LayeredBackgroundImage), - new PropertyMetadata(false, IsAudioEnabled_OnChange)); + new PropertyMetadata(false, PropertyIsAudioEnabled_OnChange)); public static readonly DependencyProperty IsVideoAutoplayProperty = DependencyProperty.Register(nameof(IsVideoAutoplay), @@ -255,12 +217,6 @@ private bool IsUseStaticBackgroundUsed typeof(LayeredBackgroundImage), new PropertyMetadata(true)); - public static readonly DependencyProperty IsVideoPlayProperty = - DependencyProperty.Register(nameof(IsVideoPlay), - typeof(bool), - typeof(LayeredBackgroundImage), - new PropertyMetadata(false)); - public static readonly DependencyProperty UseFfmpegDecoderProperty = DependencyProperty.Register(nameof(UseFfmpegDecoder), typeof(bool), @@ -277,13 +233,13 @@ private bool IsUseStaticBackgroundUsed DependencyProperty.Register(nameof(AudioVolume), typeof(double), typeof(LayeredBackgroundImage), - new PropertyMetadata(50d, AudioVolume_OnChange)); + new PropertyMetadata(.5d, PropertyAudioVolume_OnChange)); public static readonly DependencyProperty IsParallaxEnabledProperty = DependencyProperty.Register(nameof(IsParallaxEnabled), typeof(bool), typeof(LayeredBackgroundImage), - new PropertyMetadata(false, IsParallaxEnabled_OnChange)); + new PropertyMetadata(false, PropertyIsParallelEnabled_OnChange)); public static readonly DependencyProperty ParallaxHorizontalShiftProperty = DependencyProperty.Register(nameof(ParallaxHorizontalShift), @@ -301,7 +257,7 @@ private bool IsUseStaticBackgroundUsed DependencyProperty.Register(nameof(ParallaxHoverSource), typeof(UIElement), typeof(LayeredBackgroundImage), - new PropertyMetadata(null!, ParallaxHover_OnChange)); + new PropertyMetadata(null!, PropertyParallaxHoverSource_OnChange)); public static readonly DependencyProperty ParallaxResetOnUnfocusedProperty = DependencyProperty.Register(nameof(ParallaxResetOnUnfocused), @@ -309,41 +265,11 @@ private bool IsUseStaticBackgroundUsed typeof(LayeredBackgroundImage), new PropertyMetadata(true)); - public static readonly DependencyProperty PlaceholderSourceProperty = - DependencyProperty.Register(nameof(PlaceholderSource), - typeof(object), - typeof(LayeredBackgroundImage), - new PropertyMetadata(null!, PlaceholderSource_OnChange)); - - public static readonly DependencyProperty PlaceholderStretchProperty = - DependencyProperty.Register(nameof(PlaceholderStretch), - typeof(Stretch), - typeof(LayeredBackgroundImage), - new PropertyMetadata(Stretch.UniformToFill)); - - public static readonly DependencyProperty PlaceholderHorizontalAlignmentProperty = - DependencyProperty.Register(nameof(PlaceholderHorizontalAlignment), - typeof(HorizontalAlignment), - typeof(LayeredBackgroundImage), - new PropertyMetadata(HorizontalAlignment.Center)); - - public static readonly DependencyProperty PlaceholderVerticalAlignmentProperty = - DependencyProperty.Register(nameof(PlaceholderVerticalAlignment), - typeof(VerticalAlignment), - typeof(LayeredBackgroundImage), - new PropertyMetadata(VerticalAlignment.Center)); - public static readonly DependencyProperty BackgroundSourceProperty = DependencyProperty.Register(nameof(BackgroundSource), typeof(object), typeof(LayeredBackgroundImage), - new PropertyMetadata(null!, BackgroundSource_OnChange)); - - public static readonly DependencyProperty BackgroundStaticSourceProperty = - DependencyProperty.Register(nameof(BackgroundStaticSource), - typeof(object), - typeof(LayeredBackgroundImage), - new PropertyMetadata(null!, BackgroundSource_OnChange)); + new PropertyMetadata(null!, PropertyBackgroundSource_OnChange)); public static readonly DependencyProperty BackgroundStretchProperty = DependencyProperty.Register(nameof(BackgroundStretch), @@ -367,7 +293,7 @@ private bool IsUseStaticBackgroundUsed DependencyProperty.Register(nameof(ForegroundSource), typeof(object), typeof(LayeredBackgroundImage), - new PropertyMetadata(null!, ForegroundSource_OnChange)); + new PropertyMetadata(null!, PropertyForegroundSource_OnChange)); public static readonly DependencyProperty ForegroundStretchProperty = DependencyProperty.Register(nameof(ForegroundStretch), @@ -393,11 +319,17 @@ private bool IsUseStaticBackgroundUsed typeof(LayeredBackgroundImage), new PropertyMetadata(1d)); + public static readonly DependencyProperty MediaStateProperty = + DependencyProperty.Register(nameof(MediaState), + typeof(MediaPlayerState), + typeof(LayeredBackgroundImage), + new PropertyMetadata(MediaPlayerState.Stopped)); + public static readonly DependencyProperty MediaDurationPositionProperty = DependencyProperty.Register(nameof(MediaDurationPosition), typeof(TimeSpan), typeof(LayeredBackgroundImage), - new PropertyMetadata(TimeSpan.Zero, MediaDurationPosition_OnChanged)); + new PropertyMetadata(TimeSpan.Zero, PropertyMediaDurationPosition_OnChange)); public static readonly DependencyProperty MediaDurationProperty = DependencyProperty.Register(nameof(MediaDuration), @@ -405,12 +337,6 @@ private bool IsUseStaticBackgroundUsed typeof(LayeredBackgroundImage), new PropertyMetadata(TimeSpan.Zero)); - public static readonly DependencyProperty IsCurrentMediaSeekableProperty = - DependencyProperty.Register(nameof(IsCurrentMediaSeekable), - typeof(bool), - typeof(LayeredBackgroundImage), - new PropertyMetadata(false)); - public static readonly DependencyProperty BackgroundElevationPixelsProperty = DependencyProperty.Register(nameof(BackgroundElevationPixels), typeof(double), @@ -421,7 +347,7 @@ private bool IsUseStaticBackgroundUsed DependencyProperty.Register(nameof(IsBackgroundElevated), typeof(bool), typeof(LayeredBackgroundImage), - new PropertyMetadata(false, IsBackgroundElevated_OnChanged)); + new PropertyMetadata(false, PropertyIsBackgroundElevated_OnChange)); public static readonly DependencyProperty SmokeBackgroundProperty = DependencyProperty.Register(nameof(SmokeBackground), diff --git a/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.SourceContext.cs b/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.SourceContext.cs new file mode 100644 index 0000000000..a0d36cf98c --- /dev/null +++ b/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.SourceContext.cs @@ -0,0 +1,347 @@ +using CollapseLauncher.XAMLs.Theme.CustomControls.VideoPresenter; +using Hi3Helper; +using Hi3Helper.Win32.ManagedTools; +using Hi3Helper.Win32.Native.Interfaces.D3D; +using Hi3Helper.Win32.Native.Interfaces.DXGI; +using Hi3Helper.Win32.Native.LibraryImport; +using Hi3Helper.Win32.Native.Structs; +using Hi3Helper.Win32.Native.Structs.DXGI; +using Microsoft.Graphics.Canvas; +using Microsoft.UI.Xaml.Media.Imaging; +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; +using Windows.Foundation; +using Windows.Graphics.DirectX; +using Windows.Graphics.DirectX.Direct3D11; +using Windows.Media.Playback; +using WinRT; +// ReSharper disable InconsistentNaming + +#nullable enable +namespace CollapseLauncher.XAMLs.Theme.CustomControls; + +public partial class LayeredBackgroundImage +{ + private readonly Dictionary LastMediaSourcePosition = []; + + public partial class SourceContext : IDisposable + { + private static readonly ConcurrentDictionary LoadedContexts = []; + + private static readonly Guid IID_IMediaPlayer5 = new("cfe537fd-f86a-4446-bf4d-c8e792b7b4b3"); + + public ulong Generation; + + public object? Source { get; set; } + public bool IsDisposeSource { get; set; } + + public LayeredBackgroundImage ParentElement { get; init; } + + private nint _playerAbi; + public MediaPlayer? Player + { + get; + set + { + nint oldPlayerAbi = Interlocked.Exchange(ref _playerAbi, nint.Zero); + if (oldPlayerAbi != nint.Zero) Marshal.Release(oldPlayerAbi); + + if (value == null) + { + field?.MediaOpened -= PlayerOnUpdateCanvasSize; + } + else + { + value.MediaOpened += PlayerOnUpdateCanvasSize; + } + + (value as IWinRTObject)?.NativeObject?.TryAs(IID_IMediaPlayer5, out _playerAbi); + field = value; + } + } + + public TypedEventHandler? CurrentStateChangedCallback { get; set; } + public TypedEventHandler? PositionChangedCallback { get; set; } + + + public object? MediaSource { get; set; } + + public MediaFoundationPresenter? PlayerPresenter + { + get; + set + { + if (value == null) + { + Player?.VideoFrameAvailable -= PlayerOnVideoFrameAvailable; + } + else + { + Player?.VideoFrameAvailable += PlayerOnVideoFrameAvailable; + } + + field = value; + } + } + + public CancellationTokenSource SourceLoadingCts = new(); + + private CancellationTokenSource? _volumeChangedCts; + private bool _disposed; + + private int _cachedPlayerWidth; + private int _cachedPlayerHeight; + + public SourceContext(LayeredBackgroundImage parentElement, ulong generation) + { + LoadedContexts.TryAdd(this, true); + ParentElement = parentElement; + Generation = generation; + } + + private void PlayerOnUpdateCanvasSize(MediaPlayer sender, object args) + { + _cachedPlayerWidth = (int)sender.PlaybackSession.NaturalVideoWidth; + _cachedPlayerHeight = (int)sender.PlaybackSession.NaturalVideoHeight; + } + + private void PlayerOnVideoFrameAvailable(MediaPlayer sender, object args) + { + PlayerPresenter?.DrawUnsafe(DrawConsumer, _cachedPlayerWidth, _cachedPlayerHeight); + return; + + unsafe void DrawConsumer(nint surfaceAbi) + => ((delegate* unmanaged[MemberFunction])(*(*(void***)_playerAbi + 10)))(_playerAbi, surfaceAbi); + } + + public void RegisterPlayerCallbacks( + TypedEventHandler currentStateChangedCallback, + TypedEventHandler positionChangedCallback) + { + if (Player is not { } player) + { + return; + } + + // -- Un-register previous callbacks before assigning a new one. + UnregisterPlayerCallbacks(); + + player.PlaybackSession.PositionChanged += PositionChangedCallback = positionChangedCallback; + player.CurrentStateChanged += CurrentStateChangedCallback = currentStateChangedCallback; + } + + public void UnregisterPlayerCallbacks() + { + if (Player is not { } player) + { + return; + } + + if (CurrentStateChangedCallback != null) player.CurrentStateChanged -= CurrentStateChangedCallback; + if (PositionChangedCallback != null) player.PlaybackSession.PositionChanged -= PositionChangedCallback; + } + + public void SetVolume(double volume, Action? onCompleted = null) + { + if (Player is null) + { + onCompleted?.Invoke(); + return; + } + + if (Player.IsMuted) + { + Player.Volume = volume; + onCompleted?.Invoke(); + return; + } + + CancellationToken token = CancellationToken.None; + if (Interlocked.Exchange(ref _volumeChangedCts, new CancellationTokenSource()) is { } lastCts) + { + lastCts.Cancel(); + lastCts.Dispose(); + token = _volumeChangedCts.Token; + } + + Player.SetVolumeFadeCore(Player.Volume, volume, onCompleted: onCompleted, coopToken: token); + } + + public void SavePlayerLastPosition() + { + if (Player is not { } player) + { + return; + } + + ParentElement.LastMediaSourcePosition[player.NaturalDuration] = player.Position; + } + + public TimeSpan LoadPlayerLastPosition() + { + if (Player is not { } player) + { + return TimeSpan.Zero; + } + + return ParentElement.LastMediaSourcePosition.GetValueOrDefault(player.NaturalDuration); + } + + public async Task CopyCurrentFrame() + => PlayerPresenter?.CopyCurrentFrame() ?? + await TryCopyImageFrameFromBitmapSource(); + + private async Task TryCopyImageFrameFromBitmapSource() + { + if (MediaSource is not BitmapImage bitmapImage) + { + return null; + } + + if (Source is string localPath && + Uri.TryCreate(localPath, UriKind.Absolute, out Uri? localPathUri) && + localPathUri.IsFile) + { + return await TryCopyImageFrameFromLocalUri(localPathUri); + } + + nint bitmapImageAbi = ((IWinRTObject)bitmapImage).NativeObject.ThisPtr; + Unsafe.SkipInit(out IDirect3DDxgiInterfaceAccess? access); + Unsafe.SkipInit(out IDXGISurface? dxgiSurface); + Unsafe.SkipInit(out nint accessAbi); + Unsafe.SkipInit(out nint dxgiSurfaceAbi); + Unsafe.SkipInit(out nint graphicsSurfaceAbi); + + try + { + HResult hr = Marshal.QueryInterface(bitmapImageAbi, typeof(IDirect3DDxgiInterfaceAccess).GUID, out accessAbi); + if (!hr || !ComMarshal + .TryCreateComObjectFromReference(bitmapImageAbi, + out access, + out _, + releaseReference: false, + useUnique: true)) + { + return null; + } + + access.GetInterface(typeof(IDXGISurface).GUID, out dxgiSurfaceAbi); + hr = PInvoke.CreateDirect3D11SurfaceFromDXGISurface(dxgiSurfaceAbi, out graphicsSurfaceAbi); + + if (!ComMarshal + .TryCreateComObjectFromReference(graphicsSurfaceAbi, + out dxgiSurface, + out _, + releaseReference: false, + useUnique: true)) + { + return null; + } + + dxgiSurface.GetDesc(out DXGI_SURFACE_DESC dxgiSurfaceDesc); + + if (!hr) return null; + + using IDirect3DSurface surface = MarshalInterface.FromAbi(graphicsSurfaceAbi); + + CanvasDevice device = CanvasDevice.GetSharedDevice(); + using CanvasBitmap canvasBitmapFromSurface = CanvasBitmap.CreateFromDirect3D11Surface(device, surface); + CanvasRenderTarget target = new(device, + dxgiSurfaceDesc.Width, + dxgiSurfaceDesc.Height, + 96, + (DirectXPixelFormat)dxgiSurfaceDesc.Format, + CanvasAlphaMode.Premultiplied); + + target.CopyPixelsFromBitmap(canvasBitmapFromSurface); + return target; + } + finally + { + if (graphicsSurfaceAbi != nint.Zero) Marshal.Release(graphicsSurfaceAbi); + if (dxgiSurfaceAbi != nint.Zero) Marshal.Release(dxgiSurfaceAbi); + ComMarshal.FinalRelease(dxgiSurface); + ComMarshal.FinalRelease(access); + if (accessAbi != nint.Zero) Marshal.Release(accessAbi); + } + + static async Task TryCopyImageFrameFromLocalUri(Uri uri) + { + if (!uri.IsFile || + !File.Exists(uri.LocalPath)) + { + return null; + } + + CanvasDevice device = CanvasDevice.GetSharedDevice(); + using CanvasBitmap bitmap = await CanvasBitmap.LoadAsync(device, uri.LocalPath); + CanvasRenderTarget target = new(device, + bitmap.SizeInPixels.Width, + bitmap.SizeInPixels.Height, + bitmap.Dpi, + bitmap.Format, + bitmap.AlphaMode); + + target.CopyPixelsFromBitmap(bitmap); + return target; + } + } + + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, true)) + return; + + LoadedContexts.TryRemove(this, out _); + Console.WriteLine($"Total Remained SourceContext instances: {LoadedContexts.Count} instances", LogType.Debug); + + GC.SuppressFinalize(this); // Early suppress finalization here. + + _volumeChangedCts?.Cancel(); + _volumeChangedCts?.Dispose(); + _volumeChangedCts = null; + + SourceLoadingCts.Cancel(); + SourceLoadingCts.Dispose(); + SourceLoadingCts = null!; + + Player?.MediaOpened -= PlayerOnUpdateCanvasSize; + Player?.VideoFrameAvailable -= PlayerOnVideoFrameAvailable; + Player?.Pause(); + + UnregisterPlayerCallbacks(); + SavePlayerLastPosition(); + + // Skidaddle Skidoddle, your COM object is now a noodle! + // -- Early dispose the underneath ABI earlier for MediaPlayer and supposedly, + // WinRT's MediaSource objects. + nint oldPlayerAbi = Interlocked.Exchange(ref _playerAbi, nint.Zero); + if (oldPlayerAbi != nint.Zero) Marshal.Release(oldPlayerAbi); + DisposeObject(Player); + Player = null; + + DisposeObject(MediaSource); + MediaSource = null; + + if (IsDisposeSource) + { + DisposeObject(Source); + Source = null; + } + + DisposeObject(PlayerPresenter); + PlayerPresenter = null; + } + + private static void DisposeObject(object? obj) + { + (obj as IDisposable)?.Dispose(); + } + } +} diff --git a/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Templates.cs b/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Templates.cs index 8e45977049..017641ba5c 100644 --- a/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Templates.cs +++ b/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Templates.cs @@ -1,11 +1,11 @@ -#nullable enable -using CollapseLauncher.Extension; +using CollapseLauncher.Extension; using Microsoft.UI.Composition; using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Hosting; using System.Numerics; using System.Threading; +#nullable enable namespace CollapseLauncher.XAMLs.Theme.CustomControls; public partial class LayeredBackgroundImage @@ -13,22 +13,25 @@ public partial class LayeredBackgroundImage #region Constants private const string TemplateNameParallaxGrid = "ParallaxGrid"; - private const string TemplateNamePlaceholderGrid = "PlaceholderGrid"; private const string TemplateNameBackgroundGrid = "BackgroundGrid"; private const string TemplateNameForegroundGrid = "ForegroundGrid"; private const string TemplateNameElevateGrid = "ElevateGrid"; - private const string StateNamePlaceholderStateHidden = "PlaceholderStateHidden"; - #endregion #region Fields + // Container-level Grid private Grid _parallaxGrid = null!; - private Grid _placeholderGrid = null!; + private Grid _elevateGrid = null!; + + // Media Grid private Grid _backgroundGrid = null!; private Grid _foregroundGrid = null!; - private Grid _elevateGrid = null!; + + // Source Context Generation + + private ulong _sourceContextGeneration; private Visual _parallaxGridVisual = null!; private Compositor _parallaxGridCompositor = null!; @@ -36,10 +39,10 @@ public partial class LayeredBackgroundImage private Visual _elevateGridVisual = null!; private Compositor _elevateGridCompositor = null!; - private bool _isTemplateLoaded; - private CubicBezierEasingFunction _parallaxEasingFunction; private CubicBezierEasingFunction _elevateEasingFunction; + private bool _templateInitialized; + private bool _requireOnLoadedCall = true; #endregion @@ -47,11 +50,13 @@ public partial class LayeredBackgroundImage protected override void OnApplyTemplate() { - _parallaxGrid = this.GetTemplateChild(TemplateNameParallaxGrid); - _placeholderGrid = this.GetTemplateChild(TemplateNamePlaceholderGrid); - _backgroundGrid = this.GetTemplateChild(TemplateNameBackgroundGrid); - _foregroundGrid = this.GetTemplateChild(TemplateNameForegroundGrid); - _elevateGrid = this.GetTemplateChild(TemplateNameElevateGrid); + if (Interlocked.Exchange(ref _templateInitialized, true)) + return; + + _parallaxGrid = this.GetTemplateChild(TemplateNameParallaxGrid); + _backgroundGrid = this.GetTemplateChild(TemplateNameBackgroundGrid); + _foregroundGrid = this.GetTemplateChild(TemplateNameForegroundGrid); + _elevateGrid = this.GetTemplateChild(TemplateNameElevateGrid); _parallaxGridVisual = ElementCompositionPreview.GetElementVisual(_parallaxGrid); _parallaxGridCompositor = _parallaxGridVisual.Compositor; @@ -59,14 +64,17 @@ protected override void OnApplyTemplate() _elevateGridVisual = ElementCompositionPreview.GetElementVisual(_elevateGrid); _elevateGridCompositor = _elevateGridVisual.Compositor; - _parallaxEasingFunction = _parallaxGridCompositor - .CreateCubicBezierEasingFunction(new Vector2(.45f, .05f), new Vector2(.55f, .95f)); - _elevateEasingFunction = _parallaxGridCompositor - .CreateCubicBezierEasingFunction(new Vector2(0.19f, 1), new Vector2(0.22f, 1)); - - Interlocked.Exchange(ref _isTemplateLoaded, true); + _parallaxEasingFunction = _parallaxGridCompositor.CreateCubicBezierEasingFunction(new Vector2(.45f, .05f), new Vector2(.55f, .95f)); + _elevateEasingFunction = _parallaxGridCompositor.CreateCubicBezierEasingFunction(new Vector2(0.19f, 1), new Vector2(0.22f, 1)); base.OnApplyTemplate(); + + // Help the uncalled OnLoaded routine to be called after this. + // + // Note to myself @neon-nyan: + // See the reason on the comments under the OnLoaded code in case you forgot :) + if (_requireOnLoadedCall) + LayeredBackgroundImage_OnLoaded(this, null!); } #endregion diff --git a/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.cs b/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.cs index a287fbd38a..b44daa4fdc 100644 --- a/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.cs +++ b/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.cs @@ -1,4 +1,6 @@ -using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using System.Threading; #pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable. #nullable enable @@ -8,9 +10,43 @@ public partial class LayeredBackgroundImage : Control { public LayeredBackgroundImage() { - Loaded += LayeredBackgroundImage_OnLoaded; - Unloaded += LayeredBackgroundImage_OnUnloaded; + DefaultStyleKey = typeof(LayeredBackgroundImage); + Loaded += LayeredBackgroundImage_OnLoaded; + Unloaded += LayeredBackgroundImage_OnUnloaded; + } + + private static void LayeredBackgroundImage_OnLoaded(object sender, RoutedEventArgs e) + { + LayeredBackgroundImage instance = (LayeredBackgroundImage)sender; + + // Avoid to perform Subscribe and LoadAll calls before OnApplyTemplate getting called + // first. The issue is that, due to Microsoft's design flaw, the OnApplyTemplate + // didn't get called first (while it should've been) if the parent (which containing + // this element) is not visible (due to Opacity = 0 or Visibility = Collapsed). + // So, we need a bit of help here... + // + // Let's just ignore this routine. If the said case happen, let OnApplyTemplate try + // to call OnLoaded event once more. + if (!instance._templateInitialized) + return; + + // Gate it. Avoid the routine to be called twice. + if (!Interlocked.Exchange(ref instance._requireOnLoadedCall, false)) + return; + + instance.SubscribeGridEvents(); + instance.LoadAllPresenters(); + } + + private static void LayeredBackgroundImage_OnUnloaded(object sender, RoutedEventArgs e) + { + LayeredBackgroundImage instance = (LayeredBackgroundImage)sender; + + // Same reason as above. If OnApplyTemplate not yet called, don't do anything. + if (!instance._templateInitialized) + return; - DefaultStyleKey = typeof(LayeredBackgroundImage); + instance.UnsubscribeGridEvents(); + instance.UnloadAllPresenters(); } } diff --git a/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImageExtensions.Animations.cs b/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImageExtensions.Animations.cs new file mode 100644 index 0000000000..dbe6c3a4c8 --- /dev/null +++ b/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImageExtensions.Animations.cs @@ -0,0 +1,91 @@ +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Media.Animation; +using System; +using System.Threading.Tasks; + +#nullable enable +namespace CollapseLauncher.XAMLs.Theme.CustomControls; + +internal static partial class LayeredBackgroundImageExtensions +{ + private static readonly TimeSpan FadeDuration = TimeSpan.FromSeconds(0.2); + private static readonly TimeSpan FadeOutDelay = FadeDuration * 0.1d; + + internal static async Task StartFadeReplaceAnimationAsync( + UIElement? fromElement, + UIElement? toElement, + Action? onCompleted = null) + { + Storyboard? fromStoryboard = null; + Storyboard? toStoryboard = null; + + if (fromElement != null) + { + fromStoryboard = CreateFadeOpacityStoryboard(fromElement, + 1, + 0, + FadeDuration, + FadeOutDelay); + } + + if (toElement != null) + { + toStoryboard = CreateFadeOpacityStoryboard(toElement, + 0, + 1, + FadeDuration); + } + + Task fromTcs = fromStoryboard?.BeginAsync() ?? Task.CompletedTask; + Task toTcs = toStoryboard?.BeginAsync() ?? Task.CompletedTask; + + await Task.WhenAll(fromTcs, toTcs); + onCompleted?.Invoke(); + } + + private static Storyboard CreateFadeOpacityStoryboard( + UIElement element, + double from, + double to, + TimeSpan duration, + TimeSpan delay = default) + { + Storyboard storyboard = new(); + DoubleAnimation opacityAnimation = new() + { + From = from, + To = to, + Duration = duration, + BeginTime = delay + }; + + Storyboard.SetTarget(opacityAnimation, element); + Storyboard.SetTargetProperty(opacityAnimation, "Opacity"); + storyboard.Children.Add(opacityAnimation); + + return storyboard; + } + + private static Task BeginAsync(this Storyboard sb) + { + TaskCompletionSource tcs = new(); + sb.Completed += SbOnCompleted; + + try + { + sb.Begin(); + } + catch (Exception ex) + { + tcs.SetException(ex); + } + + return tcs.Task; + + void SbOnCompleted(object? sender, object e) + { + sb.Completed -= SbOnCompleted; + tcs.SetResult(); + } + } +} diff --git a/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImageExtensions.Formats.cs b/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImageExtensions.Formats.cs new file mode 100644 index 0000000000..1728130a4a --- /dev/null +++ b/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImageExtensions.Formats.cs @@ -0,0 +1,126 @@ +using Hi3Helper.Win32.WinRT.WindowsCodec; +using Microsoft.UI.Xaml.Media; +using System; +using System.IO; +using Windows.Media.Core; +using Windows.Storage.Streams; +// ReSharper disable InconsistentNaming +// ReSharper disable IdentifierTypo + +#nullable enable +namespace CollapseLauncher.XAMLs.Theme.CustomControls; + +[Flags] +public enum SourceFormatType +{ + Unknown, + Image, + Video +} + +public enum SourceObjectType +{ + Unknown, + Stream, + IRandomAccessStream, + ImageSource, + MediaSource, + URL +} + +public struct SourceGuessResult +{ + public SourceFormatType FormatType; + public SourceObjectType ObjectType; +} + +internal partial class LayeredBackgroundImageExtensions +{ + public static SourceGuessResult GuessSourceType(object? source) + { + SourceFormatType formatType = default; + SourceObjectType objectType = default; + + switch (source) + { + // -- Check if source is either MediaSource or ImageSource respectively. + case MediaSource: + objectType = SourceObjectType.MediaSource; + formatType = SourceFormatType.Video; + goto Result; + case ImageSource: + objectType = SourceObjectType.ImageSource; + formatType = SourceFormatType.Image; + goto Result; + } + + Stream? lendWrapperStream = null; + if (source is IRandomAccessStream { CanRead: true } sourceAsIRAS) + { + objectType = SourceObjectType.IRandomAccessStream; + source = lendWrapperStream = sourceAsIRAS.AsStream(); + + // Borrow check from Stream type. Continue... + } + + if (source is Stream { CanRead: true, CanSeek: true } sourceAsStream) + { + if (objectType == SourceObjectType.Unknown) + objectType = SourceObjectType.Stream; + + sourceAsStream.Position = 0; + if (WindowsCodecHelper.IsFileSupportedImage(sourceAsStream)) + { + formatType = SourceFormatType.Image; + } + + // Try to guess with path extension if media is coming from a FileStream + // and have an extension. We can't determine the format from its header for now. + if (formatType == SourceFormatType.Unknown && + sourceAsStream is FileStream sourceAsFileStream && + !string.IsNullOrEmpty(Path.GetExtension(sourceAsFileStream.Name))) + { + source = new Uri(sourceAsFileStream.Name); + } + + // Dispose IRandomAccessStream wrapper if assigned. + lendWrapperStream?.Dispose(); + } + + // -- Check the source types if it's either Uri or a string. + if (source is Uri or string) + { + goto GuessFromPath; + } + + goto Result; + + GuessFromPath: + string stringifySource = source is Uri sourceAsUri + ? sourceAsUri.IsFile ? sourceAsUri.LocalPath : sourceAsUri.ToString() + : (string)source; + + ReadOnlySpan fileExtension = Path.GetExtension(stringifySource); + if (LayeredBackgroundImage.SupportedImageFormatsFilter + .Contains(fileExtension, StringComparison.OrdinalIgnoreCase)) + { + objectType = SourceObjectType.URL; + formatType = SourceFormatType.Image; + goto Result; + } + + if (LayeredBackgroundImage.SupportedVideoFormatsFilter + .Contains(fileExtension, StringComparison.OrdinalIgnoreCase)) + { + objectType = SourceObjectType.URL; + formatType = SourceFormatType.Video; + } + + Result: + return new SourceGuessResult + { + FormatType = formatType, + ObjectType = objectType + }; + } +} \ No newline at end of file diff --git a/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImageExtensions.ImageSourceFactory.cs b/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImageExtensions.ImageSourceFactory.cs new file mode 100644 index 0000000000..2d6fed078a --- /dev/null +++ b/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImageExtensions.ImageSourceFactory.cs @@ -0,0 +1,250 @@ +using CollapseLauncher.GameManagement.ImageBackground; +using CollapseLauncher.Helper; +using Hi3Helper.Win32.WinRT.WindowsStream; +using Microsoft.Extensions.Logging; +using Microsoft.UI.Xaml.Media; +using Microsoft.UI.Xaml.Media.Imaging; +using PhotoSauce.MagicScaler; +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Windows.Storage.Streams; +// ReSharper disable AccessToDisposedClosure + +#nullable enable +namespace CollapseLauncher.XAMLs.Theme.CustomControls; + +internal static partial class LayeredBackgroundImageExtensions +{ + private static readonly ProcessImageSettings DecodingSettings = new() + { + EncoderOptions = PngEncoderOptions.Default + }; + + public static ImageSource? GetImageSource(object? sourceObj, + ILogger? logger = null, + CancellationToken token = default) + { + switch (sourceObj) + { + case null: + return null; + case ImageSource asImageSource: + return asImageSource; + } + + Uri? sourceAsUri = sourceObj as Uri; + string? sourceAsString = sourceObj as string; + Stream? sourceAsStream = sourceObj as Stream; + IRandomAccessStream? sourceAsIRandomStream = sourceObj as IRandomAccessStream ?? + sourceAsStream?.AsRandomAccessStream(false, true); + bool isOwnIRandomStream = sourceAsStream is null && sourceAsIRandomStream != null; + + if (sourceAsUri != null) + return GetImageSourceFromUri(sourceAsUri, logger, token); + + if (sourceAsString != null && sourceAsUri == null) + { + if (Uri.TryCreate(sourceAsString, UriKind.RelativeOrAbsolute, out sourceAsUri)) + return GetImageSourceFromUri(sourceAsUri, logger, token); + + if (!Uri.TryCreate(sourceAsString, UriKind.RelativeOrAbsolute, out sourceAsUri) && + StringUtility.TryGetStreamFromUrlDataString(sourceAsString, + out _, + out MemoryStream? memoryStreamFromB64)) + { + isOwnIRandomStream = true; + sourceAsIRandomStream = memoryStreamFromB64.AsRandomAccessStream(false); + } + } + + if (sourceAsIRandomStream == null) + return null; + + return GetImageSourceFromRandomStream(sourceAsIRandomStream, isOwnIRandomStream, logger, token); + } + + private static ImageSource? GetImageSourceFromUri(Uri uri, ILogger? logger = null, CancellationToken token = default) + { + ReadOnlySpan fileExtension = TryGetFileExtensionFromUri(uri); + ImageSource? returnImageSource = null; + if (LayeredBackgroundImage.SupportedImageBitmapExtensionsLookup + .Contains(fileExtension) || + LayeredBackgroundImage.SupportedImageBitmapExternalCodecExtensionsLookup + .Contains(fileExtension)) + { + returnImageSource = new BitmapImage(); + ReadBitmapImageFromUriDetached(uri, returnImageSource, logger, token); + return returnImageSource; + } + + if (LayeredBackgroundImage.SupportedImageVectorExtensionsLookup + .Contains(fileExtension)) + { + returnImageSource = new SvgImageSource(uri); + } + + return returnImageSource; + } + + private static ImageSource? GetImageSourceFromRandomStream(IRandomAccessStream stream, + bool isOwn, + ILogger? logger, + CancellationToken token) + { + // Guess format + if (!IsImageStreamSupported(stream, + out bool isSvg, + out bool requireExternalDecoder)) + { + if (isOwn) stream.Dispose(); // Dispose early + return null; + } + + stream.Seek(0); + if (isSvg) + { + SvgImageSource svgImageSource = new(); + ReadVectorImageFromStreamDetached(stream, svgImageSource, logger, isOwn); + return svgImageSource; + } + + BitmapImage bitmapImage = new(); + ReadBitmapImageFromStreamDetached(stream, bitmapImage, requireExternalDecoder, isOwn, logger, token); + return bitmapImage; + } + + private static async void ReadBitmapImageFromUriDetached(Uri uri, + ImageSource imageSource, + ILogger? logger, + CancellationToken token) + { + try + { + if (imageSource is not BitmapImage asBitmapImage) + return; + + // Download file if URL is a remote and get its cached path, or get the local path (if it's already local). + Uri cachedUri = await ImageBackgroundManager.GetLocalOrDownloadedFilePath(uri, token); + + // Decode the image or get the cached one. + (cachedUri, _) = await ImageBackgroundManager.GetNativeOrDecodedImagePath(cachedUri, token); + + // Load the load + await using FileStream fileStream = File.Open(cachedUri.LocalPath, FileMode.Open, FileAccess.ReadWrite, FileShare.ReadWrite); + using IRandomAccessStream randomFileStream = fileStream.AsRandomAccessStream(); + await asBitmapImage.SetSourceAsync(randomFileStream); + } + catch (Exception ex) + { + logger?.LogError(ex, "An error has occurred while trying to load BitmapImage from: {uri}", uri); + } + } + + private static async void ReadVectorImageFromStreamDetached(IRandomAccessStream stream, + SvgImageSource imageSource, + ILogger? logger, + bool isOwn) + { + try + { + await imageSource.SetSourceAsync(stream); + } + catch (Exception ex) + { + logger?.LogError(ex, "An error has occurred while trying to load SvgImageSource from Stream"); + } + finally + { + if (isOwn) stream.Dispose(); + } + } + + private static async void ReadBitmapImageFromStreamDetached(IRandomAccessStream stream, + BitmapImage imageSource, + bool requireExternalDecoder, + bool isOwn, + ILogger? logger, + CancellationToken token) + { + try + { + stream.Seek(0); + if (!requireExternalDecoder) + { + await imageSource.SetSourceAsync(stream); + return; + } + + await using Stream encodedStream = stream.AsStream(true); + await using MemoryStream decodedStream = new(); + + await Task.Factory.StartNew(() => + { + using ProcessingPipeline decodingPipeline = MagicImageProcessor.BuildPipeline(encodedStream, DecodingSettings); + decodingPipeline.WriteOutput(decodedStream); + decodedStream.Position = 0; + }, token); + + TaskCompletionSource tcs = new(); + imageSource.DispatcherQueue.TryEnqueue(async void () => + { + try + { + using IRandomAccessStream randomDecodedStream = decodedStream.AsRandomAccessStream(); + await imageSource.SetSourceAsync(randomDecodedStream); + + tcs.SetResult(); + } + catch (Exception e) + { + tcs.SetException(e); + } + }); + + await tcs.Task; + } + catch (Exception ex) + { + logger?.LogError(ex, "An error has occurred while trying to load BitmapImage from Stream"); + } + finally + { + if (isOwn) stream.Dispose(); + } + } + + private static bool IsImageStreamSupported(IRandomAccessStream stream, out bool isSvg, out bool requireExternalDecoder) + { + Span buffer = stackalloc byte[512]; + using Stream asStream = stream.AsStream(true); + _ = asStream.Read(buffer); + + ImageExternalCodecType internalType = ExtensionDetectUtility.IsHeaderInternalCodecImage(buffer); + if (internalType == ImageExternalCodecType.Svg) + { + requireExternalDecoder = false; + isSvg = true; + return true; + } + + ImageExternalCodecType externalType = ExtensionDetectUtility.IsHeaderExternalCodecImage(buffer); + isSvg = false; + requireExternalDecoder = externalType != ImageExternalCodecType.NotSupported && + internalType == ImageExternalCodecType.NotSupported; + + return internalType != ImageExternalCodecType.NotSupported || + externalType != ImageExternalCodecType.NotSupported; + } + + private static ReadOnlySpan TryGetFileExtensionFromUri(Uri uri) + { + ReadOnlySpan pathAndQuery = uri.PathAndQuery; + int indexOfQuerySign = pathAndQuery.IndexOf('?'); + if (indexOfQuerySign > 0) + pathAndQuery = pathAndQuery[..indexOfQuerySign]; + + return Path.GetExtension(pathAndQuery); + } +} diff --git a/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImageExtensions.MediaPlayer.cs b/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImageExtensions.MediaPlayer.cs new file mode 100644 index 0000000000..e8c95250cb --- /dev/null +++ b/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImageExtensions.MediaPlayer.cs @@ -0,0 +1,107 @@ +using Hi3Helper; +using System; +using System.Threading; +using System.Timers; +using Windows.Media.Playback; +using Timer = System.Timers.Timer; + +// ReSharper disable AccessToModifiedClosure + +#nullable enable +namespace CollapseLauncher.XAMLs.Theme.CustomControls; + +internal static partial class LayeredBackgroundImageExtensions +{ + private const double VolumeFadeDuration = 500d; // milliseconds + private const double VolumeFadeResolution = 10d; // milliseconds + + internal static void SetVolumeFadeCore( + this MediaPlayer mediaPlayer, + double fromValue, + double toValue, + double durationMs = VolumeFadeDuration, + double resolutionMs = VolumeFadeResolution, + Action? onCompleted = null, + CancellationToken coopToken = default) + { + try + { + bool isFadeOut = fromValue > toValue; + + double timeDelta = resolutionMs / durationMs; + double step = Math.Abs(Math.Max(fromValue, toValue) * timeDelta); + + if (isFadeOut) + step *= -1; + + if (coopToken.IsCancellationRequested || mediaPlayer == null!) + { + onCompleted?.Invoke(); + return; + } + + Timer timer = new(TimeSpan.FromMilliseconds(resolutionMs)); + coopToken.Register(timer.Dispose); + timer.Elapsed += Impl; + timer.Start(); + + return; + + void Impl(object? sender, ElapsedEventArgs args) + { + try + { + if (coopToken.IsCancellationRequested || mediaPlayer == null!) + { + timer.Stop(); + timer.Dispose(); + return; + } + + fromValue += step; + TrySetVolume(fromValue); + + if ((isFadeOut && fromValue <= toValue) || + (!isFadeOut && fromValue >= toValue)) + { + timer.Stop(); + timer.Dispose(); + onCompleted?.Invoke(); + } + durationMs -= resolutionMs; + } + catch (Exception ex) + { + Logger.LogWriteLine($"[LayeredBackgroundImage::VolumeFadeCore::Impl] {ex}", + LogType.Error, + true); + + timer.Stop(); + timer.Dispose(); + } + } + + void TrySetVolume(double value) + { + try + { + mediaPlayer.Volume = isFadeOut + ? Math.Max(value, toValue) + : Math.Min(value, toValue); + } + catch (Exception e) + { + Logger.LogWriteLine($"[LayeredBackgroundImage::VolumeFadeCore::TrySetVolume] {e}", + LogType.Error, + true); + } + } + } + catch (Exception e) + { + Logger.LogWriteLine($"[LayeredBackgroundImage::StartVideoPlayerVolumeFadeCore] {e}", + LogType.Error, + true); + } + } +} diff --git a/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImageExtensions.MediaSourceFactory.cs b/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImageExtensions.MediaSourceFactory.cs new file mode 100644 index 0000000000..798ec7ee47 --- /dev/null +++ b/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImageExtensions.MediaSourceFactory.cs @@ -0,0 +1,184 @@ +using FFmpegInteropX; +using Hi3Helper.Data; +using Hi3Helper.Win32.WinRT.WindowsStream; +using Microsoft.UI; +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Windows.Media.Core; +using Windows.Media.Playback; +using Windows.Storage.Streams; +// ReSharper disable InconsistentNaming +// ReSharper disable AccessToDisposedClosure +// ReSharper disable AccessToModifiedClosure +// ReSharper disable IdentifierTypo + +#nullable enable +namespace CollapseLauncher.XAMLs.Theme.CustomControls; + +internal static partial class LayeredBackgroundImageExtensions +{ + internal static async Task<(MediaPlayer? MediaPlayer, FFmpegMediaSource? FFmpegMediaSource)> + GetMediaSourceAsync( + object? source, + bool useFFmpeg, + VideoDecoderMode ffmpegDecoderMode, + WindowId windowId, + bool isAutoPlay, + bool isMuted, + Action? onMediaOpened = null, + TimeSpan lastOpenedTime = default, + CancellationToken token = default) + { + if (token.IsCancellationRequested) + return (null, null); + + MediaPlayer? mediaPlayer = new() + { + IsLoopingEnabled = true, + IsVideoFrameServerEnabled = true, + IsMuted = isMuted, + CommandManager = { IsEnabled = false }, + AutoPlay = isAutoPlay, + Volume = 0 + }; + + if (onMediaOpened != null) + { + mediaPlayer.MediaOpened += MediaPlayerOnMediaOpened; + } + + IMediaPlaybackSource? mediaPlaybackSource = null; + FFmpegMediaSource? ffmpegMediaSource = null; + + try + { + mediaPlaybackSource = !useFFmpeg ? CreateMediaSourceFromMFTAsync(source) : null; + + if (useFFmpeg) + { + MediaSourceConfig ffmpegConfig = new() + { + Video = + { + MaxDecoderThreads = (uint)Environment.ProcessorCount, + VideoDecoderMode = ffmpegDecoderMode + }, + General = + { + ReadAheadBufferEnabled = true, + ReadAheadBufferDuration = TimeSpan.FromSeconds(15) + } + }; + + if (token.IsCancellationRequested) + return (null, null); + + if (source is IRandomAccessStream or Stream) + { + IRandomAccessStream? randomStream = source as IRandomAccessStream; + randomStream ??= (source as Stream)?.AsRandomAccessStream(true, true); + + ffmpegMediaSource = await FFmpegMediaSource + .CreateFromStreamAsync(randomStream, + ffmpegConfig, + windowId.Value); + } + + if (source is Uri sourceAsUri || + (source is string sourceUriAsString && + Uri.TryCreate(sourceUriAsString, UriKind.Absolute, out sourceAsUri!))) + { + if (sourceAsUri.IsFile) + { + string fullyQualifiedPath = sourceAsUri.LocalPath.GetFullyQualifiedPath(); + ffmpegMediaSource = await FFmpegMediaSource + .CreateFromFileAsync(fullyQualifiedPath, + ffmpegConfig, + windowId.Value); + } + + ffmpegMediaSource ??= await FFmpegMediaSource + .CreateFromUriAsync(sourceAsUri.ToString(), + ffmpegConfig, + windowId.Value); + } + + if (source is string sourceAsString) + { + sourceAsString = sourceAsString.GetFullyQualifiedPath(); + if (Uri.TryCreate(sourceAsString, UriKind.Absolute, out Uri? localPathUri)) + { + ffmpegMediaSource = await FFmpegMediaSource + .CreateFromFileAsync(localPathUri.LocalPath, + ffmpegConfig, + windowId.Value); + } + } + + if (ffmpegMediaSource != null) + { + mediaPlaybackSource = ffmpegMediaSource.CreateMediaPlaybackItem(lastOpenedTime); + } + } + + if (mediaPlaybackSource != null) + { + mediaPlayer.Source = mediaPlaybackSource; + } + } + finally + { + if (mediaPlaybackSource == null) + { + mediaPlayer.Dispose(); + mediaPlayer = null; + } + } + + return (mediaPlayer, ffmpegMediaSource); + + void MediaPlayerOnMediaOpened(MediaPlayer sender, object args) + { + mediaPlayer.MediaOpened -= MediaPlayerOnMediaOpened; + onMediaOpened(); + } + } + + private static MediaSource? CreateMediaSourceFromMFTAsync(object? source) + { + try + { + if (source is IRandomAccessStream or Stream) + { + IRandomAccessStream? randomStream = source as IRandomAccessStream; + randomStream ??= (source as Stream)?.AsRandomAccessStream(true, true); + + return MediaSource.CreateFromStream(randomStream, null); + } + + if (source is Uri sourceAsUri || + (source is string sourceUriAsString && + Uri.TryCreate(sourceUriAsString, UriKind.Absolute, out sourceAsUri!))) + { + return MediaSource.CreateFromUri(sourceAsUri); + } + + if (source is string sourceAsString) + { + sourceAsString = sourceAsString.GetFullyQualifiedPath(); + if (Uri.TryCreate(sourceAsString, UriKind.Absolute, out Uri? localPathUri)) + { + return MediaSource.CreateFromUri(localPathUri); + } + } + + return null; + } + catch + { + return null; + } + } +} \ No newline at end of file diff --git a/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImageExtensions.Strings.cs b/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImageExtensions.Strings.cs new file mode 100644 index 0000000000..5689efe5be --- /dev/null +++ b/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImageExtensions.Strings.cs @@ -0,0 +1,40 @@ +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Media; +using System; +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; + +#nullable enable +namespace CollapseLauncher.XAMLs.Theme.CustomControls; + +internal partial class LayeredBackgroundImageExtensions +{ + public static bool TryGetBindingPropertyNames( + FrameworkElement? parentElement, + [NotNullWhen(true)] out string? stretchNameOf, + [NotNullWhen(true)] out string? horizontalAlignNameOf, + [NotNullWhen(true)] out string? verticalAlignNameOf) + { + const string gridSuffix = nameof(Grid); + + Unsafe.SkipInit(out stretchNameOf); + Unsafe.SkipInit(out horizontalAlignNameOf); + Unsafe.SkipInit(out verticalAlignNameOf); + + ReadOnlySpan nameSpan = parentElement?.Name; + if (nameSpan.IsEmpty) + return false; + + if (!nameSpan.EndsWith(gridSuffix, StringComparison.OrdinalIgnoreCase) || + (nameSpan[0] | 0x20) is < 'a' or > 'z') // Check if first char is not a letter. + return false; + + string nameTrimmed = nameSpan[..^gridSuffix.Length].ToString(); + stretchNameOf = nameTrimmed + nameof(Stretch); + horizontalAlignNameOf = nameTrimmed + nameof(HorizontalAlignment); + verticalAlignNameOf = nameTrimmed + nameof(VerticalAlignment); + + return true; + } +} diff --git a/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImageExtensions.cs b/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImageExtensions.cs index 255ae24d2c..a4ab0feb90 100644 --- a/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImageExtensions.cs +++ b/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImageExtensions.cs @@ -1,31 +1,21 @@ -using Hi3Helper.Win32.WinRT.WindowsStream; -using Microsoft.UI; +using Microsoft.UI; using Microsoft.UI.Xaml; -using Microsoft.UI.Xaml.Controls; -using Microsoft.UI.Xaml.Media.Imaging; -using PhotoSauce.MagicScaler; -using PhotoSauce.MagicScaler.Transforms; using System; -using System.IO; using System.Net.Http; -using System.Threading; -using System.Threading.Tasks; -using Windows.Storage.Streams; // ReSharper disable GrammarMistakeInComment - // ReSharper disable SwitchStatementHandlesSomeKnownEnumValuesWithDefault // ReSharper disable ConvertIfStatementToSwitchStatement #nullable enable namespace CollapseLauncher.XAMLs.Theme.CustomControls; -internal static class LayeredBackgroundImageExtensions +internal static partial class LayeredBackgroundImageExtensions { - private static readonly HttpClient Client; + private static readonly HttpClient SharedHttpClient; static LayeredBackgroundImageExtensions() { - Client = CreateHttpClient(); + SharedHttpClient = CreateHttpClient(); } private static HttpClient CreateHttpClient() @@ -38,402 +28,7 @@ private static HttpClient CreateHttpClient() return new HttpClient(handler, false); } - internal static async ValueTask LoadImageAsync( - this Image image, - Uri? sourceFromPath, - Stream? sourceFromStream, - LayeredBackgroundImage instance, - DependencyProperty useCacheProperty, - IPixelTransform? pixelTransform = null) - { - IMediaCacheHandler? cacheHandler = instance.MediaCacheHandler; - bool isDisposeStream = sourceFromStream == null; - bool forceUseInternalDecoder = false; - - // Try get cached source from cache handler - if (cacheHandler != null) - { - object? source = sourceFromPath; - source ??= sourceFromStream; - MediaCacheResult cacheResult = await cacheHandler.LoadCachedSource(source); - - forceUseInternalDecoder = cacheResult.ForceUseInternalDecoder; - isDisposeStream = cacheResult.DisposeStream; - - if (cacheResult.CachedSource is Uri sourceAsPath) - { - sourceFromPath = sourceAsPath; - sourceFromStream = null; - } - else if (cacheResult.CachedSource is Stream sourceAsStream) - { - sourceFromPath = null; - sourceFromStream = sourceAsStream; - } - else - { - return false; - } - } - - try - { - if (sourceFromPath != null) - { - return await image.LoadImageFromUriPathSourceAsync(sourceFromPath, - forceUseInternalDecoder, - instance, - useCacheProperty, - pixelTransform); - } - - if (sourceFromStream != null) - { - return await image.LoadImageFromStreamSourceAsync(sourceFromStream, - forceUseInternalDecoder, - instance, - useCacheProperty, - pixelTransform); - } - - return false; - } - finally - { - if (isDisposeStream && sourceFromStream != null) - { - await sourceFromStream.DisposeAsync(); - } - } - } - - private static async ValueTask LoadImageFromUriPathSourceAsync( - this Image image, - Uri sourceFromPath, - bool forceUseInternalDecoder, - LayeredBackgroundImage instance, - DependencyProperty useCacheProperty, - IPixelTransform? pixelTransform = null) - { - string extension = Path.GetExtension(sourceFromPath.ToString()); - bool isInternalFormat = LayeredBackgroundImage - .SupportedImageBitmapExtensionsLookup - .Contains(extension); - bool isSvgFormat = LayeredBackgroundImage - .SupportedImageVectorExtensionsLookup - .Contains(extension); - - if ((forceUseInternalDecoder || - isInternalFormat || - isSvgFormat) && pixelTransform == null) - { - if (isSvgFormat) - { - image.Source = new SvgImageSource(sourceFromPath); - return true; - } - - image.Source = new BitmapImage(sourceFromPath) - { - CreateOptions = (bool)instance.GetValue(useCacheProperty) - ? BitmapCreateOptions.None - : BitmapCreateOptions.IgnoreImageCache - }; - return true; - } - - await using FileStream? fileStream = await TryGetStreamFromPathAsync(sourceFromPath, instance.MediaSourceCacheDir); - if (fileStream == null) - { - return false; - } - - // Borrow loading process from stream source loader. - // We gonna ignore internal decoder enforcement here because, uh... idk. - return await image - .LoadImageFromStreamSourceAsync(fileStream, - false, - instance, - useCacheProperty, - pixelTransform); - } - - private static async ValueTask LoadImageFromStreamSourceAsync( - this Image image, - Stream sourceFromStream, - bool forceUseInternalDecoder, - LayeredBackgroundImage instance, - DependencyProperty useCacheProperty, - IPixelTransform? pixelTransform = null) - { - (Stream? stream, bool isTemporaryStream) = - await sourceFromStream.GetNativeOrCopiedStreamIfNotSeekable(CancellationToken.None); - - if (stream == null) - { - return false; - } - - try - { - // Reset position - stream.Position = 0; - - // Guess codec type - ImageExternalCodecType codecType = await stream.GuessImageFormatFromStreamAsync(); - if (codecType == ImageExternalCodecType.NotSupported) - { - return false; - } - - // Reset position after format guessing - stream.Position = 0; - - if ((forceUseInternalDecoder || - codecType == ImageExternalCodecType.Default) && - pixelTransform != null) - { - using IRandomAccessStream sourceRandomStream = stream.AsRandomAccessStream(true); - BitmapImage bitmapImage = new() - { - CreateOptions = (bool)instance.GetValue(useCacheProperty) - ? BitmapCreateOptions.None - : BitmapCreateOptions.IgnoreImageCache - }; - await bitmapImage.SetSourceAsync(sourceRandomStream); - image.Source = bitmapImage; - return true; - } - - if (codecType == ImageExternalCodecType.Svg) - { - using IRandomAccessStream sourceRandomStream = stream.AsRandomAccessStream(true); - SvgImageSource svgImageSource = new(); - await svgImageSource.SetSourceAsync(sourceRandomStream); - image.Source = svgImageSource; - return true; - } - - FileStream? tempStream = null; - - try - { - string? userDefinedCacheDir = instance.MediaSourceCacheDir; - bool requireConversion = true; - - if (!string.IsNullOrEmpty(userDefinedCacheDir) && stream is FileStream sourceAsFileStream) - { - Directory.CreateDirectory(userDefinedCacheDir); - - string cachedFilename = $"decoded_{Path.GetFileNameWithoutExtension(sourceAsFileStream.Name)}.png"; - string decodedFilepath = Path.Combine(userDefinedCacheDir, cachedFilename); - FileInfo decodedFileInfo = new(decodedFilepath); - - if (decodedFileInfo is { Exists: true, Length: > 256 }) - { - requireConversion = false; - } - - tempStream = decodedFileInfo.Open(FileMode.OpenOrCreate, - FileAccess.ReadWrite, - FileShare.ReadWrite); - } - - tempStream ??= CreateTemporaryStream(); - - if (requireConversion) - { - // Use MagicScaler for external codec types - await Task.Run(() => - { - ProcessImageSettings settings = new(); - settings.TrySetEncoderFormat(ImageMimeTypes.Png); - - using ProcessingPipeline pipeline = MagicImageProcessor.BuildPipeline(stream, settings); - if (pixelTransform != null) - { - pipeline.AddTransform(pixelTransform); - } - - pipeline.WriteOutput(tempStream); - }); - } - - tempStream.Position = 0; - using IRandomAccessStream tempRandomStream = tempStream.AsRandomAccessStream(true); - BitmapImage tempBitmapImage = new() - { - CreateOptions = (bool)instance.GetValue(useCacheProperty) - ? BitmapCreateOptions.None - : BitmapCreateOptions.IgnoreImageCache - }; - await tempBitmapImage.SetSourceAsync(tempRandomStream); - image.Source = tempBitmapImage; - return true; - } - finally - { - if (tempStream != null) - { - await tempStream.DisposeAsync(); - } - } - } - finally - { - if (isTemporaryStream) - { - await stream.DisposeAsync(); - } - } - } - - private static async Task TryGetStreamFromPathAsync(Uri? url, string? userDefinedTempDir) - { - if (url == null) - { - return null; - } - - if (url.IsFile) - { - return File.Open(url.LocalPath, FileMode.Open, FileAccess.Read, FileShare.Read); - } - - if (await GetFileLengthFromUrl(Client, url) is var fileLength && - fileLength == 0) - { - return null; - } - - HttpRequestMessage requestMessage = new(HttpMethod.Get, url); - HttpResponseMessage responseMessage = - await Client.SendAsync(requestMessage, - HttpCompletionOption.ResponseHeadersRead); - - if (!responseMessage.IsSuccessStatusCode) - { - responseMessage.Dispose(); - requestMessage.Dispose(); - return null; - } - - FileStream tempStream = url.CreateTemporaryStreamFromUrl(userDefinedTempDir); - if (tempStream.Length == fileLength) - { - return tempStream; - } - - try - { - tempStream.SetLength(0); // Reset length. - - await using Stream networkStream = await responseMessage.Content.ReadAsStreamAsync(); - await networkStream.CopyToAsync(tempStream); - return tempStream; - } - catch (Exception e) - { - Console.WriteLine(e); - await tempStream.DisposeAsync(); - } - - return null; - } - - private static async Task GetFileLengthFromUrl(HttpClient client, Uri url) - { - HttpRequestMessage requestMessage = new(HttpMethod.Head, url); - HttpResponseMessage responseMessage = - await client.SendAsync(requestMessage, HttpCompletionOption.ResponseHeadersRead); - - if (responseMessage.IsSuccessStatusCode) - { - return responseMessage.Content.Headers.ContentLength ?? 0; - } - - responseMessage.Dispose(); - requestMessage.Dispose(); - return 0; - } - - internal static double GetClampedVolume(this double thisVolume) - { - if (double.IsNaN(thisVolume) || - double.IsInfinity(thisVolume)) - { - thisVolume = 0; - } - - return Math.Clamp(thisVolume, 0, 100d) / 100d; - } - - private static async ValueTask<(Stream? Stream, bool IsTemporaryStream)> - GetNativeOrCopiedStreamIfNotSeekable( - this T stream, - CancellationToken token) - where T : Stream - { - // If it's seekable, then just return it - if (stream.CanSeek) - { - return (stream, false); - } - - // Copy over - FileStream tempStream = CreateTemporaryStream(); - try - { - await stream.CopyToAsync(tempStream, token); - return (tempStream, true); - } - catch - { - await tempStream.DisposeAsync(); - return (null, false); - } - } - - private static FileStream CreateTemporaryStreamFromUrl(this Uri url, string? userDefinedTempDir) - { - string path; - if (string.IsNullOrEmpty(userDefinedTempDir)) - { - string extension = Path.GetExtension(url.AbsolutePath); - path = Path.Combine(Path.GetTempPath(), Path.GetTempFileName() + extension); - } - else - { - Directory.CreateDirectory(userDefinedTempDir); - path = Path.Combine(userDefinedTempDir, Path.GetFileName(url.AbsolutePath)); - } - - return File.Open(path, - new FileStreamOptions - { - Mode = FileMode.Create, - Access = FileAccess.ReadWrite, - Share = FileShare.ReadWrite, - Options = string.IsNullOrEmpty(userDefinedTempDir) ? - FileOptions.None : - FileOptions.DeleteOnClose - }); - } - - private static FileStream CreateTemporaryStream() - { - string path = Path.Combine(Path.GetTempPath(), Path.GetTempFileName()); - return File.Open(path, - new FileStreamOptions - { - Mode = FileMode.Create, - Access = FileAccess.ReadWrite, - Share = FileShare.ReadWrite, - Options = FileOptions.DeleteOnClose - }); - } - - internal static WindowId GetElementWindowId(this LayeredBackgroundImage element) + internal static WindowId GetElementWindowId(this FrameworkElement element) { XamlRoot root = element.XamlRoot; WindowId windowId = root.ContentIslandEnvironment.AppWindowId; diff --git a/CollapseLauncher/XAMLs/Theme/CustomControls/PanelSlideshow.Converters.cs b/CollapseLauncher/XAMLs/Theme/CustomControls/PanelSlideshow.Converters.cs index d02d5cc98a..b16b84cd85 100644 --- a/CollapseLauncher/XAMLs/Theme/CustomControls/PanelSlideshow.Converters.cs +++ b/CollapseLauncher/XAMLs/Theme/CustomControls/PanelSlideshow.Converters.cs @@ -25,6 +25,24 @@ public object Convert(object value, Type targetType, object parameter, string la return (radius.BottomLeft + radius.BottomRight + radius.TopLeft + radius.TopRight) / 4d; } + public object ConvertBack(object value, Type targetType, object parameter, string language) + { + throw new NotImplementedException(); + } +} + +internal partial class ReverseProgressBarValue : IValueConverter +{ + public object Convert(object value, Type targetType, object parameter, string language) + { + double doubleValue = value.TryGetDouble(); + return parameter switch + { + PanelSlideshow slideshow => slideshow.SlideshowDuration - doubleValue, + _ => 0d + }; + } + public object ConvertBack(object value, Type targetType, object parameter, string language) { throw new NotImplementedException(); diff --git a/CollapseLauncher/XAMLs/Theme/CustomControls/PanelSlideshow.Templates.cs b/CollapseLauncher/XAMLs/Theme/CustomControls/PanelSlideshow.Templates.cs index f0239c769c..2de0e460dd 100644 --- a/CollapseLauncher/XAMLs/Theme/CustomControls/PanelSlideshow.Templates.cs +++ b/CollapseLauncher/XAMLs/Theme/CustomControls/PanelSlideshow.Templates.cs @@ -8,13 +8,14 @@ namespace CollapseLauncher.XAMLs.Theme.CustomControls; -[TemplatePart(Name = TemplateNameRootGrid, Type = typeof(Grid))] -[TemplatePart(Name = TemplateNamePresenterGrid, Type = typeof(Grid))] -[TemplatePart(Name = TemplateNamePreviousButton, Type = typeof(Button))] -[TemplatePart(Name = TemplateNameNextButton, Type = typeof(Button))] -[TemplatePart(Name = TemplateNameCountdownProgressBar, Type = typeof(ProgressBar))] -[TemplatePart(Name = TemplateNamePreviousButtonShadow, Type = typeof(AttachedDropShadow))] -[TemplatePart(Name = TemplateNameNextButtonShadow, Type = typeof(AttachedDropShadow))] +[TemplatePart(Name = TemplateNameRootGrid, Type = typeof(Grid))] +[TemplatePart(Name = TemplateNamePresenterGrid, Type = typeof(Grid))] +[TemplatePart(Name = TemplateNamePreviousButton, Type = typeof(Button))] +[TemplatePart(Name = TemplateNameNextButton, Type = typeof(Button))] +[TemplatePart(Name = TemplateNameCountdownProgressBar, Type = typeof(ProgressRing))] +[TemplatePart(Name = TemplateNameCountdownProgressBarTickerText, Type = typeof(TextBlock))] +[TemplatePart(Name = TemplateNamePreviousButtonShadow, Type = typeof(AttachedDropShadow))] +[TemplatePart(Name = TemplateNameNextButtonShadow, Type = typeof(AttachedDropShadow))] [TemplateVisualState(GroupName = StateGroupNameCommon, Name = StateNameNormal)] [TemplateVisualState(GroupName = StateGroupNameCommon, Name = StateNamePointerOver)] @@ -25,13 +26,14 @@ public partial class PanelSlideshow { #region Constants - private const string TemplateNameRootGrid = "RootGrid"; - private const string TemplateNamePresenterGrid = "PresenterGrid"; - private const string TemplateNamePreviousButton = "PreviousButton"; - private const string TemplateNamePreviousButtonShadow = "PreviousButtonShadow"; - private const string TemplateNameNextButton = "NextButton"; - private const string TemplateNameNextButtonShadow = "NextButtonShadow"; - private const string TemplateNameCountdownProgressBar = "CountdownProgressBar"; + private const string TemplateNameRootGrid = "RootGrid"; + private const string TemplateNamePresenterGrid = "PresenterGrid"; + private const string TemplateNamePreviousButton = "PreviousButton"; + private const string TemplateNamePreviousButtonShadow = "PreviousButtonShadow"; + private const string TemplateNameNextButton = "NextButton"; + private const string TemplateNameNextButtonShadow = "NextButtonShadow"; + private const string TemplateNameCountdownProgressBar = "CountdownProgressBar"; + private const string TemplateNameCountdownProgressBarTickerText = "CountdownProgressBarTickerText"; private const string StateGroupNameCommon = "CommonStates"; private const string StateNameNormal = "Normal"; @@ -45,14 +47,15 @@ public partial class PanelSlideshow #region Fields - private Grid _presenterGrid = null!; - private Button _previousButton = null!; - private AttachedDropShadow _previousButtonShadow = null!; - private Grid _previousButtonGrid = null!; - private Button _nextButton = null!; - private AttachedDropShadow _nextButtonShadow = null!; - private Grid _nextButtonGrid = null!; - private ProgressBar _countdownProgressBar = null!; + private Grid _presenterGrid = null!; + private Button _previousButton = null!; + private AttachedDropShadow _previousButtonShadow = null!; + private Grid _previousButtonGrid = null!; + private Button _nextButton = null!; + private AttachedDropShadow _nextButtonShadow = null!; + private Grid _nextButtonGrid = null!; + private ProgressRing _countdownProgressBar = null!; + private TextBlock _countdownProgressBarTickerText = null!; private bool _isTemplateLoaded; @@ -70,12 +73,13 @@ protected override void OnApplyTemplate() return; } - _presenterGrid = this.GetTemplateChild(TemplateNamePresenterGrid); - _previousButton = this.GetTemplateChild