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/Extension/UIElementExtensions.cs b/CollapseLauncher/Classes/Extension/UIElementExtensions.cs index 88e3c5a1b4..2214d2376f 100644 --- a/CollapseLauncher/Classes/Extension/UIElementExtensions.cs +++ b/CollapseLauncher/Classes/Extension/UIElementExtensions.cs @@ -148,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) - { - element.SetBinding(dependencyProperty, new Binding + internal static void BindProperty(this FrameworkElement? element, + object? objectToBind, + string propertyName, + DependencyProperty dependencyProperty, + BindingMode bindingMode, + IValueConverter? converter = null) + { + element?.SetBinding(dependencyProperty, new Binding { Source = objectToBind, Mode = bindingMode, 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 1156e2df3a..ab907d7c89 100644 --- a/CollapseLauncher/Classes/GameManagement/ImageBackground/ImageBackgroundManager.CodecDetect.cs +++ b/CollapseLauncher/Classes/GameManagement/ImageBackground/ImageBackgroundManager.CodecDetect.cs @@ -6,10 +6,11 @@ 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; @@ -18,72 +19,60 @@ public partial class ImageBackgroundManager { #region Codec Checks - private async ValueTask<(bool IsSupported, bool IsVideo, bool ForceFFmpeg)> CheckCodecOrSpawnDialog(Uri? fileUri) + private async ValueTask<(bool IsSupported, bool ForceFFmpeg)> CheckCodecOrSpawnDialog(string? localPath) { - // -- Cancel if null or URI is not a local file - if (fileUri == null || !fileUri.IsFile) - { - return (false, false, false); - } - - string filePath = fileUri.LocalPath; + if (localPath == null) + return (false, false); // -- Check for supported extension first - if (!IsMediaFileExtensionSupported(filePath)) + if (!IsMediaFileExtensionSupported(localPath)) { - await SimpleDialogs.Dialog_SpawnMediaExtensionNotSupportedDialog(filePath); - return (false, false, false); + 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, false); + return (true, false); } - await SimpleDialogs.Dialog_SpawnImageNotSupportedDialog(filePath); - return (false, false, false); + await SimpleDialogs.Dialog_SpawnImageNotSupportedDialog(localPath); + return (false, false); } // -- Check using FFmpeg if available - MediaSupport codecInfo = FFmpegPInvoke.GetMediaSupport(filePath); + MediaSupport codecInfo = FFmpegPInvoke.GetMediaSupport(localPath); + PixelColorModel pixelFormat = codecInfo.VideoPixelFormatInfo.ColorModel; if ((GlobalIsUseFFmpeg && GlobalIsFFmpegAvailable) || - RequiresFFmpegDecoder(codecInfo)) + (codecInfo.Video.DecoderAvailable && pixelFormat.HasFlag(PixelColorModel.Rgb))) { - return (true, true, true); + 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, true); + return (true, false); } return (await SimpleDialogs - .Dialog_SpawnVideoNotSupportedDialog(filePath, + .Dialog_SpawnVideoNotSupportedDialog(localPath, canPlayVideo, canPlayAudio, videoCodecGuid, - audioCodecGuid), true, false); + audioCodecGuid), false); } #endregion - private static bool RequiresFFmpegDecoder(MediaSupport codecInfo) - { - // Windows' H.264 decoder does not support the High 10 profile used by some backgrounds. - return codecInfo.Video.DecoderAvailable && - (codecInfo.VideoPixelFormatInfo.ColorModel.HasFlag(PixelColorModel.Rgb) || - codecInfo.VideoPixelFormatInfo.Format is AVPixelFormat.AV_PIX_FMT_YUV420P10LE or AVPixelFormat.AV_PIX_FMT_YUV420P10BE); - } - private static bool IsMediaFileExtensionSupported(ReadOnlySpan filePath) { return IsImageMediaFileExtensionSupported(filePath) || @@ -98,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.ImageCropperAndConvert.cs b/CollapseLauncher/Classes/GameManagement/ImageBackground/ImageBackgroundManager.ImageCropperAndConvert.cs index 3cb1b575be..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,32 +565,43 @@ 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 ValueTask<(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); + } } /// @@ -511,7 +609,7 @@ private static bool IsWebpExtendedFormat(Stream stream, out ColorProfileMode col /// /// If the codec of the image is not supported. internal static async ValueTask<(Uri, ImageExternalCodecType)> GetNativeOrDecodedImagePath( - Uri filePath, CancellationToken token) + 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 003848bca3..1c93090f12 100644 --- a/CollapseLauncher/Classes/GameManagement/ImageBackground/ImageBackgroundManager.Loaders.cs +++ b/CollapseLauncher/Classes/GameManagement/ImageBackground/ImageBackgroundManager.Loaders.cs @@ -1,35 +1,21 @@ -using CollapseLauncher.Extension; +using CollapseLauncher.Extension; using CollapseLauncher.Helper; using CollapseLauncher.Helper.Background; -using CollapseLauncher.Helper.Image; -using CollapseLauncher.Helper.InternalPInvoke; -using CollapseLauncher.Helper.InternalPInvoke.FFmpeg; -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.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; @@ -40,643 +26,255 @@ 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; - } - - public void PreloadBackground(PresetConfig? preset, Grid? presenterGrid) - { - if (preset == null || presenterGrid == null) return; - - CancelCurrentBackgroundLoad(); - Interlocked.Increment(ref _loadGeneration); - - PresenterGrid = presenterGrid; - string autoPlayKey = $"LastIsEnableBackgroundAutoPlay-{preset.GameName}-{preset.ZoneName}"; - CurrentIsEnableBackgroundAutoPlayKey = autoPlayKey; - - 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(); - - if (string.IsNullOrEmpty(backgroundUrl) && string.IsNullOrEmpty(staticBgUrl)) - { - IsBackgroundLoading = true; - return; - } + // -- 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); - string? primaryBg = backgroundUrl ?? staticBgUrl; + stopwatch = Stopwatch.StartNew(); - 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); + // -- Notify changes on context menu properties + DispatcherQueueExtensions.CurrentDispatcherQueue.TryEnqueue(() => OnPropertyChanged(nameof(CurrentSelectedBackgroundHasOverlayImage))); - bool overlayRequired = !string.IsNullOrEmpty(overlayUrl); - bool staticRequired = !string.IsNullOrEmpty(staticBgUrl); + // -- Start clean-up if force reload requested + if (context.ForceReload) + { + context.DeleteCachedImagePath(context.OriginOverlayImagePath); + context.DeleteCachedImagePath(context.OriginBackgroundImagePath); + context.DeleteCachedImagePath(context.OriginPlaceholderImagePath); + } - 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); + // -- Get Cached path. + string? downloadedOverlayUri = null; + string? downloadedBackgroundUri = null; + string? downloadedPlaceholderUri = null; - _ = RestoreSavedAccent(cachedBgKey, accentPreviewUri); + 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); - ImageContextSources.Clear(); - ImageContextSources.Add(context); - OnPropertyChanged(nameof(CurrentSelectedBackgroundContext)); - OnPropertyChanged(nameof(CurrentBackgroundCount)); + await Task.WhenAll(overlayLoadTask, + backgroundLoadTask, + placeholderLoadTask); - bool isUseFFmpeg = GlobalIsUseFFmpeg && GlobalIsFFmpegAvailable; - bool isVideo = IsVideoMediaFileExtensionSupported(primaryBg ?? staticBgUrl!); + token.ThrowIfCancellationRequested(); - MediaSupport codecInfo = isVideo ? FFmpegPInvoke.GetMediaSupport(localBackground) : default; - if (isUseFFmpeg || RequiresFFmpegDecoder(codecInfo)) + if (!string.IsNullOrEmpty(CurrentCachedBgKey)) { - isUseFFmpeg = true; + LauncherConfig.SetAndSaveConfigValue($"{CurrentCachedBgKey}-Background", context.OriginBackgroundImagePath ?? ""); + LauncherConfig.SetAndSaveConfigValue($"{CurrentCachedBgKey}-StaticBackground", context.OriginPlaceholderImagePath ?? ""); + LauncherConfig.SetAndSaveConfigValue($"{CurrentCachedBgKey}-Overlay", context.OriginOverlayImagePath ?? ""); } - CurrentBackgroundElement = CreateLayerElement(localOverlay != null ? new Uri(localOverlay) : null, - new Uri(localBackground), - localStatic != null ? new Uri(localStatic) : null, - context, - isVideo, - isUseFFmpeg); - 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; } - - token.ThrowIfCancellationRequested(); - - // -- Check for codec support (Also spawn dialog to install either native WIC/MediaFoundation decoder or using Ffmpeg decoder) - (bool isSupported, bool isVideo, bool forceFFmpeg) = await CheckCodecOrSpawnDialog(downloadedBackgroundUri); - if (!isSupported) return; - - // -- Force the use of FFmpeg if necessary. - if (forceFFmpeg) isUseFFmpeg = true; - - // -- Get upscaled image file if Waifu2X is enabled - if (GlobalIsWaifu2XEnabled && !isVideo) + 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); } - - // Try to force loading static image if requested. - if (forceLoadToStatic && downloadedBackgroundStaticUri != null) + catch (OperationCanceledException) { - isVideo = false; - downloadedBackgroundUri = downloadedBackgroundStaticUri; - downloadedBackgroundStaticUri = null; + // Suppress: cancellation is expected on game switch or rapid load cycles. } - - // Read accent color from the source that is actually displayed. - bool willDisplayStatic = !isVideo || - (!CurrentIsEnableCustomImage && - !GlobalIsEnableCustomImage && - !CurrentIsEnableBackgroundAutoPlay); - Uri? accentSourceUri = willDisplayStatic - ? downloadedBackgroundStaticUri ?? downloadedBackgroundUri - : downloadedBackgroundUri; - - new Thread(async void (ctx) => + finally { - 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 ?? ""); - } + stopwatch?.Stop(); + Logger.LogWriteLine($"Background image loading took: {stopwatch?.Elapsed.TotalSeconds} second(s)"); - // -- Use UI thread and load image layer - int captureGen = _loadGeneration; - bool captureForceReload = forceReload; - DispatcherQueueExtensions - .CurrentDispatcherQueue - .TryEnqueue(() => SpawnImageLayer(downloadedOverlayUri, - downloadedBackgroundUri, - downloadedBackgroundStaticUri, - isVideo, - forceFFmpeg, - 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, - bool forceFFmpeg, - 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 && - CurrentBackgroundElement.UseFfmpegDecoder == (forceFFmpeg || (GlobalIsUseFFmpeg && GlobalIsFFmpegAvailable)) && - _displayedContext != null) - { - if (context.Equals(_displayedContext)) - { - return; - } + lastCts?.Cancel(); + lastCts?.Dispose(); - if (CurrentBackgroundElement is { } existingLayer && - IsSameLocalFile(existingLayer.BackgroundSource, backgroundFilePath) && - IsSameLocalFile(existingLayer.BackgroundStaticSource, backgroundStaticFilePath)) - { - return; - } - } - - CurrentBackgroundElement = CreateLayerElement(overlayFilePath, backgroundFilePath, backgroundStaticFilePath, context, isVideo, forceFFmpeg); - } - - 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; - } - } - - 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(ReadOnlySpan 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; - } - - private LayeredBackgroundImage CreateLayerElement(Uri? overlayFilePath, - Uri? backgroundFilePath, - Uri? backgroundStaticFilePath, - LayeredImageBackgroundContext context, - bool isVideo, - bool forceFFmpeg) - { - LayeredBackgroundImage layerElement = new() - { - Opacity = 0, - BackgroundSource = backgroundFilePath, - BackgroundStaticSource = backgroundStaticFilePath, - ForegroundSource = isVideo ? overlayFilePath : null, - UseFfmpegDecoder = forceFFmpeg || (GlobalIsUseFFmpeg && GlobalIsFFmpegAvailable), - Tag = context, - ParallaxResetOnUnfocused = false, - BackgroundElevationPixels = 64d - }; - - if (!CurrentIsEnableCustomImage && - !GlobalIsEnableCustomImage && - backgroundStaticFilePath != null) - { - 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; - } - - layerElement.Opacity = 1; - - if (CurrentIsEnableBackgroundAutoPlay && WindowUtility.CurrentWindowIsVisible) - { - Play(false); + 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); } - CurrentBackgroundIsVideo = CurrentSelectedBackgroundContext?.IsVideo ?? false; - if (layerElement.Tag is bool isDisplayControl) - { - CurrentBackgroundIsSeekable = isDisplayControl; - } + return CurrentBackgroundElement; } - 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) 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); } } -} +} \ No newline at end of file diff --git a/CollapseLauncher/Classes/GameManagement/ImageBackground/ImageBackgroundManager.StreamAndPath.cs b/CollapseLauncher/Classes/GameManagement/ImageBackground/ImageBackgroundManager.StreamAndPath.cs index be30af72a9..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 ValueTask 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 ValueTask 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, @@ -66,11 +76,6 @@ private static async ValueTask OpenStreamFromFileOrUrl(Uri uri, Canc 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(); @@ -116,7 +121,7 @@ internal static async ValueTask GetLocalOrDownloadedFilePath(string path : uri.ToString(); } - private static bool TryGetCroppedImageFilePath(string filePath, + internal static bool TryGetCroppedImageFilePath(string filePath, out string croppedFilePath) { string imageDirPath = LauncherConfig.AppGameImgFolder; @@ -126,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"; @@ -142,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)}"; @@ -185,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 6f4c85439c..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 ValueTask 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 ValueTask 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); + } } } 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/StreamUtility/StreamExtension.cs b/CollapseLauncher/Classes/Helper/StreamUtility/StreamExtension.cs index cb96a4469a..33df15f4ba 100644 --- a/CollapseLauncher/Classes/Helper/StreamUtility/StreamExtension.cs +++ b/CollapseLauncher/Classes/Helper/StreamUtility/StreamExtension.cs @@ -154,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 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/WindowUtility.cs b/CollapseLauncher/Classes/Helper/WindowUtility.cs index af1981e3ed..72c72197c6 100644 --- a/CollapseLauncher/Classes/Helper/WindowUtility.cs +++ b/CollapseLauncher/Classes/Helper/WindowUtility.cs @@ -156,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; } } diff --git a/CollapseLauncher/Classes/RegionManagement/RegionManagement.cs b/CollapseLauncher/Classes/RegionManagement/RegionManagement.cs index a33361e46f..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) diff --git a/CollapseLauncher/CollapseLauncher.csproj b/CollapseLauncher/CollapseLauncher.csproj index e71960ace3..e3d488246b 100644 --- a/CollapseLauncher/CollapseLauncher.csproj +++ b/CollapseLauncher/CollapseLauncher.csproj @@ -321,6 +321,7 @@ + diff --git a/CollapseLauncher/Program.cs b/CollapseLauncher/Program.cs index 998d8f1422..5215f28577 100644 --- a/CollapseLauncher/Program.cs +++ b/CollapseLauncher/Program.cs @@ -9,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; @@ -19,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; @@ -326,6 +329,11 @@ private static void InitCriticalModules() ZstdNet.DllUtils.IsIgnoreMissingLibrary = true; #endif + /* --------------------------------------------------------------------------------------------- + * Module: MagicScaler External Codecs for Image Decoding + */ + InitMagicScalerExternalCodecs(); + /* --------------------------------------------------------------------------------------------- * Module: Velopack */ @@ -369,11 +377,6 @@ private static async void InitOtherSdkAsync() */ await InitDatabaseHandler(); - /* --------------------------------------------------------------------------------------------- - * Module: MagicScaler External Codecs for Image Decoding - */ - InitMagicScalerExternalCodecs(); - /* --------------------------------------------------------------------------------------------- * Module: Waifu2X (Start device test in the background and cache it) */ @@ -389,12 +392,7 @@ private static void InitMagicScalerExternalCodecs() { try { - CodecManager.Configure(codecs => - { - codecs.UseLibwebp(); - codecs.UseLibheif(); - codecs.UseLibjxl(); - }); + CodecManager.Configure(RegisterExternalCodec); } catch (Exception ex) { @@ -402,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.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 be8ba59619..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 diff --git a/CollapseLauncher/XAMLs/MainApp/Pages/HomePage.Background.cs b/CollapseLauncher/XAMLs/MainApp/Pages/HomePage.Background.cs index e238a62fd6..b152aeed21 100644 --- a/CollapseLauncher/XAMLs/MainApp/Pages/HomePage.Background.cs +++ b/CollapseLauncher/XAMLs/MainApp/Pages/HomePage.Background.cs @@ -30,7 +30,7 @@ using System.Threading.Tasks; using System.Windows.Input; using Windows.Foundation; - +using Windows.Media.Playback; // ReSharper disable CheckNamespace #nullable enable @@ -42,7 +42,7 @@ public HypLauncherBackgroundList? CurrentGameBackgroundData { get { - ILauncherApi? api = CurrentPresetConfig?.GameLauncherApi; + ILauncherApi? api = CurrentPresetConfig == null! ? null : CurrentPresetConfig.GameLauncherApi; HypLauncherBackgroundList? data = api?.LauncherGameBackground?.Data; return data; } @@ -173,7 +173,7 @@ private static void ToggleMultiBackgroundPipsPagerGridHoverState(Panel grid, boo grid.Opacity = 1f; // Trigger this bihhh to appear on the visual first since its own .Opacity and Visual.Opacity don't match. visual.Opacity = show ? 0f : 1f; - double duration = 200d; + const double duration = 200d; CompositionAnimationGroup animGroup = compositor.CreateAnimationGroup(); CompositionEasingFunction? function = compositor.TryCreateEasingFunction(EasingType.Quintic); @@ -228,6 +228,7 @@ private static void ToggleMultiBackgroundPipsPagerGridHoverState(Panel grid, boo grid.IsHitTestVisible = false; } } + #endregion #region Background context menu commands @@ -235,32 +236,18 @@ private static void ToggleMultiBackgroundPipsPagerGridHoverState(Panel grid, boo public ICommand BackgroundImageContextMenuCopyUrlToClipboardCommand { get; } = new RelayCommand(OnBackgroundImageContextMenuCopyUrlToClipboardCommand); private static void OnBackgroundImageContextMenuCopyUrlToClipboardCommand(object? sourcePath) { - Uri? sourceUri = sourcePath as Uri; - if (sourcePath is string asString && - !Uri.TryCreate(asString, UriKind.Absolute, out sourceUri)) - { + if (sourcePath is not string sourcePathAsString) return; - } - LayeredBackgroundImage? currentElement = ImageBackgroundManager.Shared.CurrentBackgroundElement; LayeredImageBackgroundContext? currentContext = ImageBackgroundManager.Shared.CurrentSelectedBackgroundContext; - bool isUseStaticBackground = !(currentElement?.IsVideoPlay ?? true) && - currentElement.BackgroundStaticSource != null; + bool overrideToPlaceholder = !ImageBackgroundManager.Shared.CurrentIsEnableBackgroundAutoPlay && + !string.IsNullOrEmpty(currentContext?.OriginPlaceholderImagePath) && + currentContext.OriginBackgroundImagePath == sourcePathAsString; - if (isUseStaticBackground && - !Uri.TryCreate(currentContext?.OriginBackgroundStaticImagePath, UriKind.Absolute, out sourceUri)) - { - return; - } - - if (sourceUri == null) - { - return; - } + if (overrideToPlaceholder) + sourcePathAsString = currentContext?.OriginPlaceholderImagePath ?? ""; - Clipboard.CopyStringToClipboard(sourceUri.IsFile - ? sourceUri.LocalPath - : sourceUri.AbsoluteUri); + Clipboard.CopyStringToClipboard(sourcePathAsString); } public ICommand BackgroundImageContextMenuCopyImageToClipboardCommand { get; } = new RelayCommand(OnBackgroundImageContextMenuCopyImageToClipboardCommand); @@ -313,7 +300,7 @@ private static async void OnBackgroundImageContextMenuSaveCommand(object? onlyCu List contexts = onlyCurrent && ImageBackgroundManager.Shared.CurrentSelectedBackgroundContext is {} currentContext ? [currentContext] - : ImageBackgroundManager.Shared.ImageContextSources.ToList(); + : [.. ImageBackgroundManager.Shared.ImageContextSources]; foreach ((int Index, LayeredImageBackgroundContext Item) context in contexts.Index()) { @@ -389,7 +376,7 @@ private void BackgroundVideoPlayToggle_OnClick(object sender, RoutedEventArgs e) return; } - if (element.IsVideoPlay) + if (element.MediaState == MediaPlayerState.Playing) { CurrentBackgroundManager.Pause(); return; diff --git a/CollapseLauncher/XAMLs/MainApp/Pages/HomePage.GameManagement.cs b/CollapseLauncher/XAMLs/MainApp/Pages/HomePage.GameManagement.cs index b993fa4f6f..3051f9c00e 100644 --- a/CollapseLauncher/XAMLs/MainApp/Pages/HomePage.GameManagement.cs +++ b/CollapseLauncher/XAMLs/MainApp/Pages/HomePage.GameManagement.cs @@ -6,6 +6,7 @@ using CollapseLauncher.Helper.Metadata; using CollapseLauncher.InstallManager.Base; using CollapseLauncher.XAMLs.Theme.ContentDialog; +using CollapseLauncher.XAMLs.Theme.CustomControls; using CommunityToolkit.WinUI.Animations; using Hi3Helper; using Hi3Helper.SentryHelper; @@ -616,7 +617,7 @@ private async void ChangeGameBGButton_Click(object sender, RoutedEventArgs e) return; } - string file = await FileDialogNative.GetFilePicker(ImageLoaderHelper.SupportedBackgroundFormats); + string file = await FileDialogNative.GetFilePicker(LayeredBackgroundImage.SupportedAllBackgroundFormatsFilter); if (string.IsNullOrEmpty(file) || string.IsNullOrWhiteSpace(file)) { diff --git a/CollapseLauncher/XAMLs/MainApp/Pages/HomePage.Variable.cs b/CollapseLauncher/XAMLs/MainApp/Pages/HomePage.Variable.cs index a906feebaf..9010912b25 100644 --- a/CollapseLauncher/XAMLs/MainApp/Pages/HomePage.Variable.cs +++ b/CollapseLauncher/XAMLs/MainApp/Pages/HomePage.Variable.cs @@ -6,7 +6,6 @@ using CollapseLauncher.WindowSize; using Hi3Helper.Plugin.Core.Management; using Hi3Helper.Shared.ClassStruct; -using Microsoft.UI.Xaml; using System; using System.Collections.Generic; using System.Text; @@ -183,40 +182,6 @@ private set } } - internal int CurrentBannerIconHeight => - CurrentGameProperty?.GamePreset.LauncherType == LauncherType.Sophon ? - WindowSize.WindowSize.CurrentWindowSize.BannerIconHeight : - WindowSize.WindowSize.CurrentWindowSize.BannerIconHeightHYP; - - internal Thickness CurrentBannerIconMargin => - CurrentGameProperty?.GamePreset.LauncherType == LauncherType.Sophon ? - WindowSize.WindowSize.CurrentWindowSize.BannerIconMargin : - WindowSize.WindowSize.CurrentWindowSize.BannerIconMarginHYP; - - internal int CurrentBannerIconColumn => - CurrentGameProperty?.GamePreset.LauncherType == LauncherType.Sophon ? - 1 : - 0; - - internal static int CurrentBannerIconColumnSpan => 1; - - internal int CurrentBannerIconRow => - CurrentGameProperty?.GamePreset.LauncherType == LauncherType.Sophon ? - 1 : - 0; - - internal static int CurrentBannerIconRowSpan => 1; - - internal HorizontalAlignment CurrentBannerIconHorizontalAlign => - CurrentGameProperty?.GamePreset.LauncherType == LauncherType.Sophon ? - WindowSize.WindowSize.CurrentWindowSize.BannerIconAlignHorizontal : - WindowSize.WindowSize.CurrentWindowSize.BannerIconAlignHorizontalHYP; - - internal VerticalAlignment CurrentBannerIconVerticalAlign => - CurrentGameProperty?.GamePreset.LauncherType == LauncherType.Sophon ? - WindowSize.WindowSize.CurrentWindowSize.BannerIconAlignVertical : - WindowSize.WindowSize.CurrentWindowSize.BannerIconAlignVerticalHYP; - private ImageBackgroundManager CurrentBackgroundManager => field ??= ImageBackgroundManager.Shared; internal string? StartTooltipText diff --git a/CollapseLauncher/XAMLs/MainApp/Pages/HomePage.xaml b/CollapseLauncher/XAMLs/MainApp/Pages/HomePage.xaml index 6c57fa4334..5e0823581c 100644 --- a/CollapseLauncher/XAMLs/MainApp/Pages/HomePage.xaml +++ b/CollapseLauncher/XAMLs/MainApp/Pages/HomePage.xaml @@ -45,6 +45,7 @@ + true false @@ -109,7 +110,9 @@ Text="{x:Bind helper:Locale.Current.Lang._HomePage.BgContextMenu_CopyUrlToClipboardOverlayText, Mode=OneWay}" ToolTipService.Placement="Left" ToolTipService.ToolTip="{x:Bind helper:Locale.Current.Lang._HomePage.BgContextMenu_CopyUrlToClipboardOverlayTooltip, Mode=OneWay}" /> - @@ -193,7 +196,7 @@ Visibility="{Binding FeaturedEventIconUrl, Mode=OneWay, Converter={StaticResource NullableVisibilityConverter}}"> + Visibility="{Binding CurrentIsEnableCustomImage, Mode=OneWay, Converter={StaticResource InverseBooleanVisibilityConverter}}"> - + - + Glyph="" /> + + + @@ -1453,7 +1462,7 @@ VerticalAlignment="Stretch" IsHitTestVisible="False" Margin="8,0,-32,0" - Source="{x:Bind NoNewsSplashMascot}" /> + Source="{x:Bind NoNewsSplashMascot, Mode=OneWay}" /> @@ -1536,7 +1545,7 @@ VerticalAlignment="Stretch" IsHitTestVisible="False" Margin="8,0,-32,0" - Source="{x:Bind NoNewsSplashMascot}" /> + Source="{x:Bind NoNewsSplashMascot, Mode=OneWay}" /> @@ -1619,7 +1628,7 @@ VerticalAlignment="Stretch" IsHitTestVisible="False" Margin="8,0,-32,0" - Source="{x:Bind NoNewsSplashMascot}" /> + Source="{x:Bind NoNewsSplashMascot, Mode=OneWay}" /> diff --git a/CollapseLauncher/XAMLs/MainApp/Pages/OOBE/OOBESelectGame.xaml b/CollapseLauncher/XAMLs/MainApp/Pages/OOBE/OOBESelectGame.xaml index 87b36970e3..009837d04c 100644 --- a/CollapseLauncher/XAMLs/MainApp/Pages/OOBE/OOBESelectGame.xaml +++ b/CollapseLauncher/XAMLs/MainApp/Pages/OOBE/OOBESelectGame.xaml @@ -37,7 +37,7 @@ + Text="{x:Bind helper:Locale.Current.Lang._StartupPage.Pg2NextBtn, Mode=OneWay}" /> + Text="{x:Bind helper:Locale.Current.Lang._StartupPage.Pg2PrevBtnNew, Mode=OneWay}" /> @@ -109,36 +109,36 @@ + Text="{x:Bind helper:Locale.Current.Lang._StartupPage.Pg2Title, Mode=OneWay}" /> + Text="{x:Bind helper:Locale.Current.Lang._StartupPage.Pg2Subtitle1_1, Mode=OneWay}" /> + Text="{x:Bind helper:Locale.Current.Lang._StartupPage.Pg2Subtitle1_2, Mode=OneWay}" /> + Text="{x:Bind helper:Locale.Current.Lang._StartupPage.Pg2Subtitle1_3, Mode=OneWay}" /> + Text="{x:Bind helper:Locale.Current.Lang._StartupPage.Pg2Subtitle2_1, Mode=OneWay}" /> + Text="{x:Bind helper:Locale.Current.Lang._StartupPage.Pg2Subtitle2_2, Mode=OneWay}" /> + Text="{x:Bind helper:Locale.Current.Lang._StartupPage.Pg2Subtitle2_3, Mode=OneWay}" /> + Text="{x:Bind helper:Locale.Current.Lang._StartupPage.Pg2Subtitle2_4, Mode=OneWay}" /> + Text="{x:Bind helper:Locale.Current.Lang._StartupPage.Pg2Subtitle2_5, Mode=OneWay}" /> @@ -152,7 +152,7 @@ extension:UIElementExtensions.CursorType="Hand" CornerRadius="16" IsEnabled="False" - PlaceholderText="{x:Bind helper:Locale.Current.Lang._StartupPage.Pg2ComboBoxRegion}" + PlaceholderText="{x:Bind helper:Locale.Current.Lang._StartupPage.Pg2ComboBoxRegion, Mode=OneWay}" SelectionChanged="GameSelect_SelectionChanged"> diff --git a/CollapseLauncher/XAMLs/MainApp/Pages/OOBE/OOBESelectGame.xaml.cs b/CollapseLauncher/XAMLs/MainApp/Pages/OOBE/OOBESelectGame.xaml.cs index 4a947eb2af..cee54cccba 100644 --- a/CollapseLauncher/XAMLs/MainApp/Pages/OOBE/OOBESelectGame.xaml.cs +++ b/CollapseLauncher/XAMLs/MainApp/Pages/OOBE/OOBESelectGame.xaml.cs @@ -1,4 +1,5 @@ using CollapseLauncher.Extension; +using CollapseLauncher.GameManagement.ImageBackground; using CollapseLauncher.Helper; using CollapseLauncher.Helper.Background; using CollapseLauncher.Helper.Image; @@ -58,7 +59,7 @@ private void NextPage_Click(object sender, RoutedEventArgs e) SpawnGreetingsToastNotification(asGameTitleString, asGameRegionConfig.ZoneName ?? ""); } - private static void SpawnGreetingsToastNotification(string? gameTitle, string? gameRegion) + private static async void SpawnGreetingsToastNotification(string? gameTitle, string? gameRegion) { if (string.IsNullOrEmpty(gameTitle) || string.IsNullOrEmpty(gameRegion)) { @@ -77,19 +78,16 @@ private static void SpawnGreetingsToastNotification(string? gameTitle, string? g // Get logo name and poster name (string? logoName, string? posterName) = GetLogoAndHeroImgPath(gamePresetConfig); + if (!string.IsNullOrEmpty(posterName)) + (posterName, _) = await ImageBackgroundManager.GetNativeOrDecodedImagePath(posterName); + // Create Toast Notification Content - NotificationContent toastContent = NotificationContent.Create() - .SetTitle(Locale.Current.Lang?._NotificationToast?.OOBE_WelcomeTitle) - .SetContent( - string.Format( - Locale.Current.Lang?._NotificationToast?.OOBE_WelcomeSubtitle ?? "", - gameTitleTranslated, - gameRegionTranslated)) - .SetAppLogoPath( - logoName, - true) - .AddAppHeroImagePath( - posterName); + NotificationContent toastContent = NotificationContent + .Create() + .SetTitle(Locale.Current.Lang?._NotificationToast?.OOBE_WelcomeTitle) + .SetContent(string.Format(Locale.Current.Lang?._NotificationToast?.OOBE_WelcomeSubtitle ?? "", gameTitleTranslated, gameRegionTranslated)) + .SetAppLogoPath(logoName, true) + .AddAppHeroImagePath(posterName); // Create Toast Notification Service ToastNotification? toastService = WindowUtility.CurrentToastNotificationService?.CreateToastNotification(toastContent); @@ -111,7 +109,7 @@ internal static (string? LogoPath, string? HeroPath) GetLogoAndHeroImgPath(Prese string? appIconUrl = ImageLoaderHelper.CopyToLocalIfBase64(iconUrl); string? appPosterUrl = ImageLoaderHelper.CopyToLocalIfBase64(posterUrl); - return (appIconUrl ?? @"Assets\CollapseLauncherLogoSmall.png", appPosterUrl ?? @"Assets\Images\PageBackground\StartupBackground2.png"); + return (appIconUrl ?? @"Assets\CollapseLauncherLogoSmall.png", appPosterUrl); } // Get logo name and poster name @@ -125,7 +123,7 @@ internal static (string? LogoPath, string? HeroPath) GetLogoAndHeroImgPath(Prese // Return paths return ($@"Assets\Images\GameLogo\{logoName}-logo.png", - $@"Assets\Images\GamePoster\poster_{posterName}.png"); + $@"Assets\Images\GamePoster\poster_{posterName}.avif"); } diff --git a/CollapseLauncher/XAMLs/MainApp/Pages/OOBE/OOBESelectGameBG.xaml b/CollapseLauncher/XAMLs/MainApp/Pages/OOBE/OOBESelectGameBG.xaml index fcdd9cda7a..b60d8eea73 100644 --- a/CollapseLauncher/XAMLs/MainApp/Pages/OOBE/OOBESelectGameBG.xaml +++ b/CollapseLauncher/XAMLs/MainApp/Pages/OOBE/OOBESelectGameBG.xaml @@ -7,6 +7,7 @@ xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:extension="using:CollapseLauncher.Extension" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" + xmlns:pages="using:CollapseLauncher.Pages" xmlns:oobe="using:CollapseLauncher.Pages.OOBE" xmlns:s="using:CommunityToolkit.WinUI" Background="Transparent" @@ -16,12 +17,13 @@ BlurRadius="32" CastTo="{x:Bind GameDetailsLogoShadow}" Opacity="1" /> + @@ -78,8 +80,8 @@ Click="GameDetailsHomepage_Click" CornerRadius="0,0,8,8" Style="{ThemeResource AccentButtonStyle}" - Tag="{x:Bind oobe:OOBESelectGameBG.GameDetailsHomepageLink}" - ToolTipService.ToolTip="{x:Bind oobe:OOBESelectGameBG.GameDetailsHomepageLink}"> + Tag="{x:Bind oobe:OOBESelectGameBG.GameDetailsHomepageLink, Mode=OneWay}" + ToolTipService.ToolTip="{x:Bind oobe:OOBESelectGameBG.GameDetailsHomepageLink, Mode=OneWay}"> @@ -88,7 +90,7 @@ field ??= new Uri("ms-appx:///Assets/Images/PageBackground/StartupBackground2.avif"); public OOBESelectGameBG() { @@ -111,13 +112,13 @@ internal static async Task TryLoadGameDetails(PresetConfig? config) OOBESelectGameBG.IsUseBackgroundMask = config is PluginPresetConfigWrapper; _gamePosterPath = config.GameType switch - { - GameNameType.Honkai => Path.Combine(AppExecutableDir, @"Assets\Images\GamePoster\poster_honkai.png"), - GameNameType.Genshin => Path.Combine(AppExecutableDir, @"Assets\Images\GamePoster\poster_genshin.png"), - GameNameType.StarRail => Path.Combine(AppExecutableDir, @"Assets\Images\GamePoster\poster_starrail.png"), - GameNameType.Zenless => Path.Combine(AppExecutableDir, @"Assets\Images\GamePoster\poster_zzz.png"), - _ => DecideOtherPosterPath(config) - }; + { + GameNameType.Honkai => Path.Combine(AppExecutableDir, @"Assets\Images\GamePoster\poster_honkai.avif"), + GameNameType.Genshin => Path.Combine(AppExecutableDir, @"Assets\Images\GamePoster\poster_genshin.avif"), + GameNameType.StarRail => Path.Combine(AppExecutableDir, @"Assets\Images\GamePoster\poster_starrail.avif"), + GameNameType.Zenless => Path.Combine(AppExecutableDir, @"Assets\Images\GamePoster\poster_zzz.avif"), + _ => DecideOtherPosterPath(config) + }; _gameLogoPath = config.ZoneLogoURL; // -- Load poster images. @@ -131,8 +132,6 @@ internal static async Task TryLoadGameDetails(PresetConfig? config) } // -- Load logo images. - _gameLogoPath = await ImageBackgroundManager.GetLocalOrDownloadedFilePath(_gameLogoPath, CancellationToken.None); - (_gameLogoPath, _) = await ImageBackgroundManager.GetNativeOrDecodedImagePath(_gameLogoPath, CancellationToken.None); _gameLogoBitmapImage = new BitmapImage(new Uri(_gameLogoPath)); } catch (Exception ex) diff --git a/CollapseLauncher/XAMLs/MainApp/Pages/OOBE/OOBEStartUpMenu.xaml b/CollapseLauncher/XAMLs/MainApp/Pages/OOBE/OOBEStartUpMenu.xaml index 45c7e8303f..ba98f68cd9 100644 --- a/CollapseLauncher/XAMLs/MainApp/Pages/OOBE/OOBEStartUpMenu.xaml +++ b/CollapseLauncher/XAMLs/MainApp/Pages/OOBE/OOBEStartUpMenu.xaml @@ -10,11 +10,11 @@ xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:extension="using:CollapseLauncher.Extension" xmlns:helper="using:CollapseLauncher.Helper" - xmlns:innerConfig="using:Hi3Helper.Shared.Region" xmlns:localeMetadata="using:Hi3Helper.LocaleSourceGen" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:pages="using:CollapseLauncher.Pages" Background="Transparent" + Loaded="OOBEStartUpMenu_OnLoaded" mc:Ignorable="d"> @@ -32,7 +32,7 @@ + Opacity="0.5" /> + Source="ms-appx:///Assets/CollapseLauncherLogoSmall.png"> @@ -99,7 +99,7 @@ + Text="{x:Bind helper:Locale.Current.Lang._OOBEStartUpMenu.SetupBackButton, Mode=OneWay}" /> @@ -113,12 +113,12 @@ Orientation="Vertical"> @@ -137,22 +137,22 @@ FontSize="32" Foreground="{ThemeResource SystemAccentColor}" Style="{ThemeResource TitleLargeTextBlockStyle}" - Text="{x:Bind helper:Locale.Current.Lang._StartupPage.Subtitle1}" /> + Text="{x:Bind helper:Locale.Current.Lang._StartupPage.Subtitle1, Mode=OneWay}" /> + Text="{x:Bind helper:Locale.Current.Lang._StartupPage.Subtitle2, Mode=OneWay}" /> + Text="{x:Bind helper:Locale.Current.Lang._StartupPage.Subtitle3, Mode=OneWay}" /> + Text="{x:Bind helper:Locale.Current.Lang._StartupPage.Subtitle4_1, Mode=OneWay}" /> + Text="{x:Bind helper:Locale.Current.Lang._StartupPage.Subtitle4_2, Mode=OneWay}" /> + Text="{x:Bind helper:Locale.Current.Lang._StartupPage.Subtitle4_3, Mode=OneWay}" /> @@ -197,17 +197,25 @@ + Text="{x:Bind helper:Locale.Current.Lang._OOBEStartUpMenu.CustomizationTitle, Mode=OneWay}" /> + + Margin="1" + CornerRadius="12" + Background="{ThemeResource UpdatePageAcrylicBrush}"> + Description="{x:Bind helper:Locale.Current.Lang._OOBEStartUpMenu.CustomizationSettingsLanguageDescription, Mode=OneWay}" + Header="{x:Bind helper:Locale.Current.Lang._OOBEStartUpMenu.CustomizationSettingsLanguageHeader, Mode=OneWay}"> @@ -263,8 +271,8 @@ + Description="{x:Bind helper:Locale.Current.Lang._OOBEStartUpMenu.CustomizationSettingsWindowSizeDescription, Mode=OneWay}" + Header="{x:Bind helper:Locale.Current.Lang._OOBEStartUpMenu.CustomizationSettingsWindowSizeHeader, Mode=OneWay}"> @@ -272,15 +280,15 @@ extension:UIElementExtensions.CursorType="Hand" SelectedIndex="{x:Bind SelectedWindowSizeProfile, Mode=TwoWay}" SelectionChanged="FixComboBoxSize"> - - + + + Description="{x:Bind helper:Locale.Current.Lang._OOBEStartUpMenu.CustomizationSettingsCDNDescription, Mode=OneWay}" + Header="{x:Bind helper:Locale.Current.Lang._OOBEStartUpMenu.CustomizationSettingsCDNHeader, Mode=OneWay}"> @@ -309,31 +317,31 @@ + Text="{x:Bind helper:Locale.Current.Lang._StartupPage.CDNHelpTitle_1, Mode=OneWay}" /> + Text="{x:Bind helper:Locale.Current.Lang._StartupPage.CDNHelpTitle_2, Mode=OneWay}" /> + Text="{x:Bind helper:Locale.Current.Lang._StartupPage.CDNHelpTitle_3, Mode=OneWay}" /> + Text="{x:Bind helper:Locale.Current.Lang._StartupPage.CDNHelpTitle_4, Mode=OneWay}" /> - + - + Text="{x:Bind helper:Locale.Current.Lang._StartupPage.CDNHelpDetail_2, Mode=OneWay}" /> + - + + Text="{x:Bind helper:Locale.Current.Lang._StartupPage.CDNsAvailable, Mode=OneWay}" /> + ItemsSource="{x:Bind collapse:FallbackCDNUtil.CDNList, Mode=OneWay}"> @@ -359,14 +367,15 @@ + Description="{x:Bind helper:Locale.Current.Lang._OOBEStartUpMenu.CustomizationSettingsStyleDescription, Mode=OneWay}" + Header="{x:Bind helper:Locale.Current.Lang._OOBEStartUpMenu.CustomizationSettingsStyleHeader, Mode=OneWay}" + DataContext="{x:Bind OOBEBackgroundManager}"> - + @@ -374,25 +383,24 @@ extension:UIElementExtensions.CursorType="Hand" SelectedIndex="{x:Bind SelectedTheme, Mode=TwoWay}" SelectionChanged="FixComboBoxSize"> - - - + + + - + - + @@ -401,7 +409,6 @@ + OffContent="{x:Bind helper:Locale.Current.Lang._Misc.Disabled, Mode=OneWay}" + OnContent="{x:Bind helper:Locale.Current.Lang._Misc.Enabled, Mode=OneWay}" /> - + + Spacing="16" + DataContext="{x:Bind OOBEBackgroundManager}"> @@ -438,8 +445,8 @@ + OffContent="{x:Bind helper:Locale.Current.Lang._Misc.Disabled, Mode=OneWay}" + OnContent="{x:Bind helper:Locale.Current.Lang._Misc.Enabled, Mode=OneWay}" /> @@ -484,7 +491,7 @@ + Text="{x:Bind helper:Locale.Current.Lang._DisconnectedPage.GoToAppSettingsBtn, Mode=OneWay}" /> @@ -510,7 +517,7 @@ + Text="{x:Bind helper:Locale.Current.Lang._OOBEStartUpMenu.SetupNextButton, Mode=OneWay}" /> + Text="{x:Bind helper:Locale.Current.Lang._DisconnectedPage.GoBackOverlayFrameBtn, Mode=OneWay}" /> diff --git a/CollapseLauncher/XAMLs/MainApp/Pages/OOBE/OOBEStartUpMenu.xaml.cs b/CollapseLauncher/XAMLs/MainApp/Pages/OOBE/OOBEStartUpMenu.xaml.cs index 5d09decc66..d154835833 100644 --- a/CollapseLauncher/XAMLs/MainApp/Pages/OOBE/OOBEStartUpMenu.xaml.cs +++ b/CollapseLauncher/XAMLs/MainApp/Pages/OOBE/OOBEStartUpMenu.xaml.cs @@ -5,9 +5,9 @@ using CollapseLauncher.GameManagement.ImageBackground; using CollapseLauncher.Helper; using CollapseLauncher.Helper.Animation; -using CollapseLauncher.Helper.Image; using CollapseLauncher.Helper.Loading; using CollapseLauncher.Helper.Metadata; +using CollapseLauncher.XAMLs.Theme.CustomControls; using CommunityToolkit.WinUI.Animations; using Hi3Helper; using Hi3Helper.Shared.ClassStruct; @@ -427,33 +427,21 @@ private async void CustomBackgroundCheckedOpen(object sender, RoutedEventArgs e) return; } - string selectedPath = await FileDialogNative.GetFilePicker(ImageLoaderHelper.SupportedBackgroundFormats); - if (string.IsNullOrEmpty(selectedPath)) - { - OOBEBackgroundManager.Initialize(null); - OOBEBackgroundManager.GlobalCustomBackgroundImagePath = null; - OOBEBackgroundManager.GlobalIsEnableCustomImage = false; - BackgroundGrid.Children.Clear(); - return; - } - - OOBEBackgroundManager.Initialize(BackgroundGrid); - if (await OOBEBackgroundManager.SetGlobalCustomBackground(selectedPath)) + string selectedPath = await FileDialogNative.GetFilePicker(LayeredBackgroundImage.SupportedAllBackgroundFormatsFilter); + if (!string.IsNullOrEmpty(selectedPath) && await OOBEBackgroundManager.SetGlobalCustomBackground(selectedPath)) { + OOBEBackgroundManager.GlobalIsEnableCustomImage = true; await StartBackgroundTransition(false, IsAppThemeLight ? 0.2f : 0.175f, 0.5f); return; } - OOBEBackgroundManager.GlobalIsEnableCustomImage = false; + CustomBackgroundCheckedClose(sender, e); } private async void CustomBackgroundCheckedClose(object? sender, RoutedEventArgs e) { - CheckBox? senderSource = sender as CheckBox; - if (senderSource == null) return; - - OOBEBackgroundManager.Initialize(null); - BackgroundGrid.Children.Clear(); + OOBEBackgroundManager.GlobalIsEnableCustomImage = false; + OOBEBackgroundManager.GlobalCustomBackgroundImagePath = null; await StartBackgroundTransition(true, IsAppThemeLight ? 0.2f : 0.175f, 0.5f); } @@ -471,27 +459,17 @@ private async Task StartBackgroundTransition(bool isShow, { ContainerBackgroundImage.Visibility = Visibility.Visible; await ContainerBackgroundImage.StartAnimation(transitionDuration, - currentCompositor - .CreateScalarKeyFrameAnimation("Opacity", toOpacity, 0), - currentCompositor.CreateVector3KeyFrameAnimation("Scale", - new Vector3(1f), new Vector3(toScale)), - currentCompositor - .CreateVector3KeyFrameAnimation("Translation", - new Vector3(0), - new Vector3(toTranslateX, toTranslateY, 0)) + currentCompositor.CreateScalarKeyFrameAnimation("Opacity", toOpacity, 0), + currentCompositor.CreateVector3KeyFrameAnimation("Scale", new Vector3(1f), new Vector3(toScale)), + currentCompositor.CreateVector3KeyFrameAnimation("Translation", new Vector3(0), new Vector3(toTranslateX, toTranslateY, 0)) ); return; } await ContainerBackgroundImage.StartAnimation(transitionDuration, - currentCompositor.CreateScalarKeyFrameAnimation("Opacity", 0, - fromOpacity), - currentCompositor.CreateVector3KeyFrameAnimation("Scale", - new Vector3(toScale), new Vector3(1f)), - currentCompositor - .CreateVector3KeyFrameAnimation("Translation", - new Vector3(toTranslateX, toTranslateY, 0), - new Vector3(0)) + currentCompositor.CreateScalarKeyFrameAnimation("Opacity", 0, fromOpacity), + currentCompositor.CreateVector3KeyFrameAnimation("Scale", new Vector3(toScale), new Vector3(1f)), + currentCompositor.CreateVector3KeyFrameAnimation("Translation", new Vector3(toTranslateX, toTranslateY, 0), new Vector3(0)) ); ContainerBackgroundImage.Visibility = Visibility.Collapsed; } @@ -542,10 +520,8 @@ private async void ToggleLogoMode(bool shrink = false, bool isInSm if (!LastTitleTextInitialScale.HasValue || !LastLogoInitialScale.HasValue) return; ErrMsg.RequestedTheme = IsAppThemeLight ? ElementTheme.Light : ElementTheme.Dark; - Vector3 titleTextAnimScaleVect = - new Vector3(isInSmallMode ? (float)SmallWindowFactor + 0.09f : (float)SmallWindowFactor - 0.075f); - Vector3 logoAnimScaleVect = - new Vector3(isInSmallMode ? (float)SmallWindowFactor - 0.2f : (float)SmallWindowFactor - 0.1f); + Vector3 titleTextAnimScaleVect = new(isInSmallMode ? (float)SmallWindowFactor + 0.09f : (float)SmallWindowFactor - 0.075f); + Vector3 logoAnimScaleVect = new(isInSmallMode ? (float)SmallWindowFactor - 0.2f : (float)SmallWindowFactor - 0.1f); TimeSpan animDuration = TimeSpan.FromMilliseconds(500); int titleTextXOffset = isInSmallMode ? 90 : 100; @@ -685,7 +661,7 @@ private async void ChooseFolder(object sender, RoutedEventArgs e) selected = true; break; case ContentDialogResult.Secondary: - var folder = await FileDialogHelper.GetRestrictedFolderPathDialog(Locale.Current.Lang?._Dialogs?.FolderDialogTitle1); + string folder = await FileDialogHelper.GetRestrictedFolderPathDialog(Locale.Current.Lang?._Dialogs?.FolderDialogTitle1); if (!string.IsNullOrEmpty(folder)) { AppGameFolder = folder; @@ -756,77 +732,66 @@ await OverlayFrame.StartAnimation(TimeSpan.FromSeconds(0.5), ); } - private readonly List _previouslyHiddenUIsList = []; - private readonly List> _previouslyFromHiddenUIsElement = []; - private readonly List> _previouslyToHiddenUIsElement = []; + private struct PreviousHiddenUIInfo + { + public FrameworkElement Element; + public ValueTuple FromTuple; + public ValueTuple ToTuple; + } + + private readonly List _previouslyHiddenUIsList = []; private async void HideUIs(params FrameworkElement[] elements) { _previouslyHiddenUIsList.Clear(); - _previouslyFromHiddenUIsElement.Clear(); - _previouslyToHiddenUIsElement.Clear(); TimeSpan duration = TimeSpan.FromSeconds(0.5f); - await Task.WhenAll(elements.Select(async x => - { - // float toScale = 1.2f; - // float toTranslateX = -((float)x.ActualWidth * (toScale - 1f) / 2); - // float toTranslateY = -((float)x.ActualHeight * (toScale - 1f) / 2); - - Tuple - from = new((float)x.Opacity, new Vector3(1f), new Vector3(0), - x.Visibility), - to = new(0f, new Vector3(1f), - new Vector3(0, -(float)x.ActualHeight, 0), - Visibility.Collapsed); - _previouslyHiddenUIsList.Add(x); - _previouslyFromHiddenUIsElement.Add(from); - _previouslyToHiddenUIsElement.Add(to); - - // PreviouslyFromHiddenUIsElement.Add(new Tuple((float)x.Opacity, new Vector3(1f), new Vector3(0), x.Visibility)); - // PreviouslyToHiddenUIsElement.Add(new Tuple(0f, new Vector3(toScale), new Vector3(toTranslateX, toTranslateY, 0), Visibility.Collapsed)); - - Task task = x.StartAnimation(duration, - currentCompositor - .CreateScalarKeyFrameAnimation("Opacity", - to.Item1, from.Item1), - currentCompositor - .CreateVector3KeyFrameAnimation("Scale", - to.Item2, from.Item2), - currentCompositor - .CreateVector3KeyFrameAnimation("Translation", - to.Item3, from.Item3) - ); - await task; - DispatcherQueue?.TryEnqueue(() => x.Visibility = to.Item4); - return Task.CompletedTask; - })); + + List tasks = []; + foreach (FrameworkElement element in elements) + { + (float, Vector3, Vector3, Visibility) fromTuple = ((float)element.Opacity, new Vector3(1f), new Vector3(0), element.Visibility); + (float, Vector3, Vector3, Visibility) toTuple = (0f, new Vector3(1f), new Vector3(0, -(float)element.ActualHeight, 0), Visibility.Collapsed); + + _previouslyHiddenUIsList.Add(new PreviousHiddenUIInfo + { + Element = element, + FromTuple = fromTuple, + ToTuple = toTuple + }); + + ScalarKeyFrameAnimation opacityAnim = currentCompositor.CreateScalarKeyFrameAnimation("Opacity", toTuple.Item1, fromTuple.Item1); + Vector3KeyFrameAnimation scaleAnim = currentCompositor.CreateVector3KeyFrameAnimation("Scale", toTuple.Item2, fromTuple.Item2); + Vector3KeyFrameAnimation translationAnim = currentCompositor.CreateVector3KeyFrameAnimation("Translation", toTuple.Item3, fromTuple.Item3); + + Task task = element.StartAnimation(duration, opacityAnim, scaleAnim, translationAnim) + .ContinueWith(_ => + { + DispatcherQueue?.TryEnqueue(() => element.Visibility = toTuple.Item4); + }); + tasks.Add(task); + } + + await Task.WhenAll(tasks); } private async void ShowResetUIs() { - TimeSpan duration = TimeSpan.FromSeconds(0.5f); - int len = _previouslyHiddenUIsList.Count; - + TimeSpan duration = TimeSpan.FromSeconds(0.5f); List animTask = []; - for (int i = 0; i < len; i++) + + foreach (PreviousHiddenUIInfo info in _previouslyHiddenUIsList) { - FrameworkElement element = _previouslyHiddenUIsList[i]; - Tuple fromValue = _previouslyToHiddenUIsElement[i]; - Tuple toValue = _previouslyFromHiddenUIsElement[i]; - animTask.Add(Task.Run(async () => - { - DispatcherQueue?.TryEnqueue(() => element.Visibility = toValue.Item4); - await element.StartAnimation(duration, - currentCompositor - .CreateScalarKeyFrameAnimation("Opacity", - toValue.Item1, fromValue.Item1), - currentCompositor - .CreateVector3KeyFrameAnimation("Scale", - toValue.Item2, fromValue.Item2), - currentCompositor - .CreateVector3KeyFrameAnimation("Translation", - toValue.Item3, fromValue.Item3)); - })); + FrameworkElement element = info.Element; + (float, Vector3, Vector3, Visibility) fromTuple = info.FromTuple; + (float, Vector3, Vector3, Visibility) toTuple = info.ToTuple; + + ScalarKeyFrameAnimation opacityAnim = currentCompositor.CreateScalarKeyFrameAnimation("Opacity", fromTuple.Item1, toTuple.Item1); + Vector3KeyFrameAnimation scaleAnim = currentCompositor.CreateVector3KeyFrameAnimation("Scale", fromTuple.Item2, toTuple.Item2); + Vector3KeyFrameAnimation translationAnim = currentCompositor.CreateVector3KeyFrameAnimation("Translation", fromTuple.Item3, toTuple.Item3); + + DispatcherQueue?.TryEnqueue(() => element.Visibility = fromTuple.Item4); + Task task = element.StartAnimation(duration, opacityAnim, scaleAnim, translationAnim); + animTask.Add(task); } await Task.WhenAll(animTask); @@ -844,29 +809,24 @@ private async void NextPageButton_Click(object sender, RoutedEventArgs e) { if (!IsSelectLauncherFolderDone) { - await Task.WhenAll( - Task.Run(async () => - { - DispatcherQueue?.TryEnqueue(() => ToggleLogoMode(true, IsSmallSize, - IsLauncherCustomizationDone)); - await LauncherFolderContainer.StartAnimation(TimeSpan.FromSeconds(0.5), - currentCompositor.CreateScalarKeyFrameAnimation("Opacity", 0, - 1), - currentCompositor.CreateVector3KeyFrameAnimation("Translation", - new Vector3(0, -32, 0), new Vector3(0, 0, 0))); - DispatcherQueue?.TryEnqueue(() => LauncherFolderContainer.Visibility = - Visibility.Collapsed); - }), + await Task.WhenAll(Task.Run(async () => + { + DispatcherQueue?.TryEnqueue(() => ToggleLogoMode(true, IsSmallSize, IsLauncherCustomizationDone)); + await LauncherFolderContainer.StartAnimation(TimeSpan.FromSeconds(0.5), + currentCompositor.CreateScalarKeyFrameAnimation("Opacity", 0, 1), + currentCompositor.CreateVector3KeyFrameAnimation("Translation", new Vector3(0, -32, 0), new Vector3(0, 0, 0)) + ); + + DispatcherQueue?.TryEnqueue(() => LauncherFolderContainer.Visibility = Visibility.Collapsed); + }), Task.Run(async () => - { - DispatcherQueue?.TryEnqueue(() => CustomizationContainer.Visibility = - Visibility.Visible); - await CustomizationContainer.StartAnimation(TimeSpan.FromSeconds(0.5), - currentCompositor.CreateScalarKeyFrameAnimation("Opacity", 1, - 0), - currentCompositor.CreateVector3KeyFrameAnimation("Translation", - new Vector3(0, 0, 0), new Vector3(0, 32, 0))); - })); + { + DispatcherQueue?.TryEnqueue(() => CustomizationContainer.Visibility = Visibility.Visible); + await CustomizationContainer.StartAnimation(TimeSpan.FromSeconds(0.5), + currentCompositor.CreateScalarKeyFrameAnimation("Opacity", 1, 0), + currentCompositor.CreateVector3KeyFrameAnimation("Translation", new Vector3(0, 0, 0), new Vector3(0, 32, 0)) + ); + })); IsSelectLauncherFolderDone = true; return; } @@ -889,8 +849,8 @@ internal async void OverlayFrameGoBack() OOBEBackgroundManager.Play(false); IsLauncherCustomizationDone = false; - ShowResetUIs(); OverlayFrame.GoBack(); + ShowResetUIs(); await OverlayFrame.StartAnimation(TimeSpan.FromSeconds(0.5), currentCompositor.CreateVector3KeyFrameAnimation("Translation", new Vector3(0, (float)OverlayFrame.ActualHeight, 0), @@ -904,29 +864,24 @@ private async void PrevPageButton_Click(object sender, RoutedEventArgs e) if (IsLauncherCustomizationDone) IsLauncherCustomizationDone = false; if (!IsSelectLauncherFolderDone) return; - await Task.WhenAll( - Task.Run(async () => - { - DispatcherQueue?.TryEnqueue(() => ToggleLogoMode(false, IsSmallSize, - IsLauncherCustomizationDone)); - await CustomizationContainer.StartAnimation(TimeSpan.FromSeconds(0.5), - currentCompositor.CreateScalarKeyFrameAnimation("Opacity", 0, - 1), - currentCompositor.CreateVector3KeyFrameAnimation("Translation", - new Vector3(0, -32, 0), new Vector3(0, 0, 0))); - DispatcherQueue?.TryEnqueue(() => CustomizationContainer.Visibility = - Visibility.Collapsed); - }), - Task.Run(async () => - { - DispatcherQueue?.TryEnqueue(() => LauncherFolderContainer.Visibility = - Visibility.Visible); - await LauncherFolderContainer.StartAnimation(TimeSpan.FromSeconds(0.5), - currentCompositor.CreateScalarKeyFrameAnimation("Opacity", 1, - 0), - currentCompositor.CreateVector3KeyFrameAnimation("Translation", - new Vector3(0, 0, 0), new Vector3(0, 32, 0))); - })); + await Task.WhenAll(Task.Run(async () => + { + DispatcherQueue?.TryEnqueue(() => ToggleLogoMode(false, IsSmallSize, IsLauncherCustomizationDone)); + await CustomizationContainer.StartAnimation(TimeSpan.FromSeconds(0.5), + currentCompositor.CreateScalarKeyFrameAnimation("Opacity", 0, 1), + currentCompositor.CreateVector3KeyFrameAnimation("Translation", new Vector3(0, -32, 0), new Vector3(0, 0, 0)) + ); + + DispatcherQueue?.TryEnqueue(() => CustomizationContainer.Visibility = Visibility.Collapsed); + }), + Task.Run(async () => + { + DispatcherQueue?.TryEnqueue(() => LauncherFolderContainer.Visibility = Visibility.Visible); + await LauncherFolderContainer.StartAnimation(TimeSpan.FromSeconds(0.5), + currentCompositor.CreateScalarKeyFrameAnimation("Opacity", 1, 0), + currentCompositor.CreateVector3KeyFrameAnimation("Translation", new Vector3(0, 0, 0), new Vector3(0, 32, 0)) + ); + })); IsSelectLauncherFolderDone = false; } @@ -939,15 +894,15 @@ private void FixComboBoxSize(object sender, SelectionChangedEventArgs e) { // Sync PlaceholderText with current selected item // Fix https://github.com/microsoft/microsoft-ui-xaml/issues/4551 - var comboBox = sender as ComboBox; + ComboBox? comboBox = sender as ComboBox; if (comboBox == null || comboBox.SelectedValue == null) return; - var selectedValueString = comboBox.SelectedValue switch + string selectedValueString = comboBox.SelectedValue switch { ContentControl contentControl => contentControl.Content as string ?? "", - TextBlock textBlock => textBlock.Text, - string str => str, - _ => "" + TextBlock textBlock => textBlock.Text, + string str => str, + _ => "" }; comboBox.PlaceholderText = selectedValueString; @@ -1001,5 +956,11 @@ private void GoBackFromOverlayFrame(object sender, RoutedEventArgs e) SettingsPageFrame.Visibility = Visibility.Collapsed; btn.Visibility = Visibility.Collapsed; } + + private void OOBEStartUpMenu_OnLoaded(object sender, RoutedEventArgs e) + { + OOBEBackgroundManager.Initialize(BackgroundGrid); + OOBEBackgroundManager.GlobalParallaxHoverSource = this; + } } } diff --git a/CollapseLauncher/XAMLs/MainApp/Pages/SettingsPage.xaml.cs b/CollapseLauncher/XAMLs/MainApp/Pages/SettingsPage.xaml.cs index b4704e3372..5d5e2dc752 100644 --- a/CollapseLauncher/XAMLs/MainApp/Pages/SettingsPage.xaml.cs +++ b/CollapseLauncher/XAMLs/MainApp/Pages/SettingsPage.xaml.cs @@ -12,6 +12,7 @@ using CollapseLauncher.Helper.Loading; #endif using CollapseLauncher.Helper.Metadata; +using CollapseLauncher.Helper.StreamUtility; using CollapseLauncher.Helper.Update; using CollapseLauncher.Interfaces; using CollapseLauncher.Interfaces.Class; @@ -313,8 +314,8 @@ .. typeof(SimpleDialogs) #endif AppBGCustomizerNote.Text = string.Format(Locale.Current.Lang?._SettingsPage?.AppBG_Note, - ImageLoaderHelper.SupportedImageFormats, - ImageLoaderHelper.SupportedVideoFormats + LayeredBackgroundImage.SupportedImageFormatsFilter, + LayeredBackgroundImage.SupportedVideoFormatsFilter ); #if !ENABLEUSERFEEDBACK @@ -432,10 +433,14 @@ private async void ClearImgFolder(object sender, RoutedEventArgs e) try { (sender as Button).IsEnabled = false; - if (Directory.Exists(AppGameImgFolder)) - Directory.Delete(AppGameImgFolder, true); + DirectoryInfo dir = new(AppGameImgFolder); + if (!dir.Exists) + return; - Directory.CreateDirectory(AppGameImgFolder); + foreach (FileInfo fileInfo in dir.EnumerateFiles("*", SearchOption.AllDirectories)) + { + fileInfo.TryDeleteFile(); + } } catch (Exception ex) { @@ -443,9 +448,11 @@ private async void ClearImgFolder(object sender, RoutedEventArgs e) ErrorSender.SendException(ex); LogWriteLine(msg, LogType.Error, true); } - - await Task.Delay(1000); - (sender as Button).IsEnabled = true; + finally + { + await Task.Delay(1000); + (sender as Button).IsEnabled = true; + } } private async void ClearLogsFolder(object sender, RoutedEventArgs e) @@ -686,7 +693,7 @@ private void ClickButtonLinkFromTag(object sender, RoutedEventArgs e) private async void SelectBackgroundImg(object sender, RoutedEventArgs e) { - string file = await FileDialogNative.GetFilePicker(ImageLoaderHelper.SupportedBackgroundFormats); + string file = await FileDialogNative.GetFilePicker(LayeredBackgroundImage.SupportedAllBackgroundFormatsFilter); if (string.IsNullOrEmpty(file) || string.IsNullOrWhiteSpace(file)) { diff --git a/CollapseLauncher/XAMLs/MainApp/ValueConverter.ImageDecoderValueConverter.cs b/CollapseLauncher/XAMLs/MainApp/ValueConverter.ImageDecoderValueConverter.cs new file mode 100644 index 0000000000..129fcfa36e --- /dev/null +++ b/CollapseLauncher/XAMLs/MainApp/ValueConverter.ImageDecoderValueConverter.cs @@ -0,0 +1,26 @@ +using CollapseLauncher.XAMLs.Theme.CustomControls; +using Hi3Helper; +using Microsoft.Extensions.Logging; +using Microsoft.UI.Xaml.Data; +using System; +// ReSharper disable AccessToDisposedClosure + +#pragma warning disable IDE0130 + +#nullable enable +namespace CollapseLauncher.Pages; + +public partial class ImageDecoderValueConverter : IValueConverter +{ + public static readonly ImageDecoderValueConverter Shared = new(); + + private static readonly ILogger Logger = ILoggerHelper.GetILogger("ImageDecoderValueConverter"); + + public object? Convert(object? value, Type targetType, object? parameter, string? language) + => LayeredBackgroundImageExtensions.GetImageSource(value, Logger) ?? value; + + public object ConvertBack(object? value, Type targetType, object? parameter, string? language) + { + throw new NotSupportedException(); + } +} diff --git a/CollapseLauncher/XAMLs/MainApp/ValueConverters.cs b/CollapseLauncher/XAMLs/MainApp/ValueConverters.cs index 570b96ba2a..ba98938e73 100644 --- a/CollapseLauncher/XAMLs/MainApp/ValueConverters.cs +++ b/CollapseLauncher/XAMLs/MainApp/ValueConverters.cs @@ -25,6 +25,9 @@ using System.Runtime.CompilerServices; using System.Threading; using Windows.Globalization.NumberFormatting; +using Windows.Media.Playback; +#pragma warning disable IDE0290 + // ReSharper disable IdentifierTypo // ReSharper disable PartialTypeWithSinglePart @@ -942,24 +945,6 @@ public object ConvertBack(object value, Type targetType, object parameter, strin } } - public partial class MediaAutoplayWindowOverrideConverter : IValueConverter - { - public object Convert(object value, Type targetType, object parameter, string language) - { - if (!WindowUtility.CurrentWindowIsVisible) - { - return false; - } - - return value is true; - } - - public object ConvertBack(object value, Type targetType, object parameter, string language) - { - throw new NotImplementedException(); - } - } - public partial class BackgroundParallaxPixelToComboBoxIndexConverter : IValueConverter { public object Convert(object value, Type targetType, object parameter, string language) @@ -1350,20 +1335,17 @@ private static unsafe TTo ConvertTo(object? value) { if (value is not TFrom asStructFrom) { - throw new - InvalidOperationException($"Type mismatch: expected {typeof(TFrom)}, but got {value?.GetType()}"); + throw new InvalidOperationException($"Type mismatch: expected {typeof(TFrom)}, but got {value?.GetType()}"); } int sizeOfFrom = sizeof(TFrom); - int sizeOfTo = sizeof(TTo); - + int sizeOfTo = sizeof(TTo); if (sizeOfFrom != sizeOfTo) { throw new InvalidOperationException($"Size mismatch: {typeof(TFrom)} ({sizeOfFrom} bytes) vs {typeof(TTo)} ({sizeOfTo} bytes)"); } - TTo result = default; - Unsafe.Copy(ref result, &asStructFrom); + TTo result = Unsafe.BitCast(asStructFrom); return result; } } @@ -1385,4 +1367,61 @@ public partial class PluginVerticalAlignmentConverter : public partial class PluginDoublePositionToThicknessConverter : StructToStructConverter; + + public partial class MediaPlayerStateToVisibilityConverter : IValueConverter + { + public object Convert(object value, Type targetType, object parameter, string language) + { + if (value is not MediaPlayerState state) + { + throw new InvalidCastException("Value is not a MediaPlayerState"); + } + + if (parameter is true || + (parameter is string paramAsStr && + bool.TryParse(paramAsStr, out bool result) && + result)) + { + return state is MediaPlayerState.Closed + or MediaPlayerState.Paused + or MediaPlayerState.Stopped + ? Visibility.Visible + : Visibility.Collapsed; + } + + return state is MediaPlayerState.Buffering + or MediaPlayerState.Opening + or MediaPlayerState.Playing + ? Visibility.Visible + : Visibility.Collapsed; + } + + public object ConvertBack(object value, Type targetType, object parameter, string language) + => throw new NotImplementedException(); + } + + public partial class MinMaxToPercentConverter : IValueConverter + where T : unmanaged, IBinaryNumber + { + public MinMaxToPercentConverter(T min, T max) + { + _min = min.TryGetDouble(); + _max = max.TryGetDouble(); + } + + private readonly double _min; + private readonly double _max; + + public object Convert(object value, Type targetType, object parameter, string language) + { + double valueD = value.TryGetDouble(); + return (valueD - _min) / _max - _min; + } + + public object ConvertBack(object value, Type targetType, object parameter, string language) + { + double valueD = value.TryGetDouble(); + return (_max - _min) * valueD + _min; + } + } } \ 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 2cc3d81725..0000000000 --- a/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Events.FrameInitializer.cs +++ /dev/null @@ -1,313 +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 -{ - private MediaPlayer? _videoFramePlayer; - - #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 bool InitializeRenderTargetSize(MediaPlaybackSession playbackSession) - { - try - { - int width = (int)playbackSession.NaturalVideoWidth; - int height = (int)playbackSession.NaturalVideoHeight; - - // 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 ((width <= 0 || height <= 0) && _videoFfmpegMediaSource?.CurrentVideoStream is { } videoStream) - { - width = videoStream.PixelWidth; - height = videoStream.PixelHeight; - } - - if (width <= 0 || height <= 0) return false; - - _canvasWidth = width; - _canvasHeight = height; - _canvasRenderSize = new Rect(0, 0, _canvasWidth, _canvasHeight); - return true; - } - catch (COMException ex) when ((uint)ex.HResult == 0xC00D3E85u) - { - // A queued MediaOpened callback can outlive the playback session. - return false; - } - catch (Exception ex) - { - Logger.LogWriteLine($"[LayeredBackgroundImage::InitializeRenderTargetSize] {ex}", - LogType.Error, - true); - return false; - } - } - - private unsafe bool InitializeRenderTarget() - { - bool initialized = false; - Interlocked.Exchange(ref _isBlockVideoFrameDraw, 1); - try - { - // Play() can arrive before MediaOpened. Read this player's size before creating any surfaces. - if (_videoPlayer == null || !InitializeRenderTargetSize(_videoPlayer.PlaybackSession)) return false; - - 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 initialized = true; - } - - _canvasImageSourceNativePtr = ((IWinRTObject)_canvasImageSource).NativeObject.ThisPtr; - - try - { - // DrawImageToRect requires ICanvasBitmap, not the render target's default ICanvasRenderTarget interface. - Guid canvasBitmapIid = new("C57532ED-709E-4AC2-86BE-A1EC3A7FA8FE"); - Marshal.ThrowExceptionForHR(((IWinRTObject)_canvasRenderTarget).NativeObject.TryAs(canvasBitmapIid, out _canvasRenderTargetNativePtr)); - Marshal.ThrowExceptionForHR(((IWinRTObject)_canvasRenderTarget).NativeObject.TryAs(typeof(IDirect3DSurface).GUID, out _canvasRenderTargetAsSurfacePtr)); - - if (_functionTableBeginDraw == null! || - _functionTableDrawImage == null! || - _functionTableCopyFrameToVideoSurface == null! || - _functionTableDispose == null!) - { - SwapChainPanelHelper.GetFuncForCanvasImageSource(_canvasImageSourceNativePtr, - _canvasRenderTargetNativePtr, - _videoPlayerPtr, - out _functionTableBeginDraw, - out _functionTableDrawImage, - out _functionTableCopyFrameToVideoSurface, - out _functionTableDispose, - in _canvasRenderSize); - } - } - catch (Exception e) - { - NullifyRenderTargetNativePointers(); - Interlocked.Exchange(ref _useSafeFrameRenderer, true); // Fallback - - _functionTableBeginDraw = null; - _functionTableDrawImage = null; - _functionTableCopyFrameToVideoSurface = 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); - } - return initialized = true; - } - catch (Exception e) - { - Logger.LogWriteLine($"[LayeredBackgroundImage::InitializeRenderTarget] FATAL: {e}", - LogType.Error, - true); - return false; - } - finally - { - if (initialized && _canvasImageSource != null) - { - SetRenderImageSource(_canvasImageSource.Source); - } - Interlocked.Exchange(ref _isBlockVideoFrameDraw, initialized ? 0 : 1); - } - } - - #endregion - - #region Disposers - - private void DetachVideoPlayerEvents(MediaPlayer player) - { - player.MediaOpened -= InitializeVideoFrameOnMediaOpened; - player.PlaybackSession.NaturalVideoSizeChanged -= InitializeVideoFrameOnSizeChanged; - player.VideoFrameAvailable -= NotifyVideoLoaded; - player.VideoFrameAvailable -= VideoPlayerUnsafe_OnVideoFrameAvailable; - player.VideoFrameAvailable -= VideoPlayerSafe_OnVideoFrameAvailable; - 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 - { - _videoFramePlayer = null; - 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); - - // Note from: @neon-nyan. - // -- Use atomic exchange instead of locking :) - if (Interlocked.Exchange(ref _isBlockVideoFrameDraw, 1) == 1) - return; - - NullifyRenderTargetNativePointers(); - // Drop the image source only after invalidating its borrowed native pointer. - Interlocked.Exchange(ref _canvasImageSource, null!); - InitializeRenderTarget(); - - 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/QueryInterface 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 queried the render target's native object for - // 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)); - - // -- Release the ICanvasBitmap reference acquired for DrawImageToRect. - if (_canvasRenderTargetNativePtr != nint.Zero) Marshal.Release(Interlocked.Exchange(ref _canvasRenderTargetNativePtr, nint.Zero)); - - // -- Nullify the borrowed IWinRTObject pointer. - 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 fa38a953ee..0000000000 --- a/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Events.FrameRenderer.cs +++ /dev/null @@ -1,547 +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] _functionTableCopyFrameToVideoSurface; - 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 VideoPlayerUnsafe_OnVideoFrameAvailable(MediaPlayer sender, object args) - { - if (_isBlockVideoFrameDraw == 1) - return; - - if (Interlocked.Exchange(ref _isVideoFrameDrawInProgress, 1) > 0) - { -#if DEBUG - Logger.LogWriteLine($@"Skipping frame at: {sender.Position:hh\:mm\:ss\.ffffff}"); -#endif - return; - } - - nint drawingSessionPpv = nint.Zero; - try - { - _functionTableCopyFrameToVideoSurface(_videoPlayerPtr, _canvasRenderTargetAsSurfacePtr); - drawingSessionPpv = SwapChainPanelHelper - .CanvasSessionDrawUnsafe(_canvasImageSourceNativePtr, - _canvasRenderTargetNativePtr, - _functionTableBeginDraw, - _functionTableDrawImage, - in _canvasRenderSize); - } - // Ignore DCOMPOSITION_ERROR_SURFACE_BEING_RENDERED - catch (COMException comEx) when (unchecked((uint)comEx.HResult) == 0x88980801u) - { - // ignored - } - // Trying to recreate the context instead of re-creating the entire canvas - catch (COMException comEx) when (unchecked((uint)comEx.HResult) == 0x802B0020u) - { - Logger.LogWriteLine($"[LayeredBackgroundImage::UnsafeOnVideoFrameAvailable] CanvasImageSource has lost its context. Re-creating the context...\r\n{comEx}", - LogType.Warning, - true); - - /* TODO: Re-create the context and start redrawing. - * Use it for CanvasImageSource later. Needs some fixes with how I can re-create the RenderTarget and the ImageSource without - * dealing with the lock and stuffs. - using CanvasLock? canvasLock = _canvasDevice?.Lock(); - _canvasImageSource?.Recreate(_canvasDevice); - Logger.LogWriteLine("[LayeredBackgroundImage::UnsafeOnVideoFrameAvailable] Trying to re-draw after re-creating the context...", - LogType.Warning, - true); - goto StartDraw; - */ - CanvasDevice_OnDeviceLost(); - } - // Device lost error. If happened, reinitialize render target - catch (COMException comEx) when (unchecked((uint)comEx.HResult) is 0x887A0005u or 0x8899000Cu) - { - Logger.LogWriteLine($"[LayeredBackgroundImage::UnsafeOnVideoFrameAvailable] Direct3D device is lost! Trying to reinitialize it...\r\n{comEx}", - LogType.Warning, - true); - CanvasDevice_OnDeviceLost(); - } - catch (Exception ex) - { - Logger.LogWriteLine($"[LayeredBackgroundImage::UnsafeOnVideoFrameAvailable]" + - $"\r\n_videoPlayerPtr: 0x{_videoPlayerPtr:x8}" + - $"\r\n_canvasRenderTargetAsSurfacePtr: 0x{_canvasRenderTargetAsSurfacePtr:x8}" + - $"\r\n_canvasImageSourceNativePtr: 0x{_canvasImageSourceNativePtr:x8}" + - $"\r\n_canvasRenderTargetNativePtr: 0x{_canvasRenderTargetNativePtr:x8}" + - $"\r\n{ex}", - LogType.Error, - true); - } - finally - { - DispatcherQueue.TryEnqueue(DispatcherQueuePriority.High, () => - { - if (drawingSessionPpv != nint.Zero) - SwapChainPanelHelper.DrawingDisposeUnsafe(drawingSessionPpv, _functionTableDispose); - - Interlocked.Exchange(ref _isVideoFrameDrawInProgress, 0); - }); - } - } - - private void VideoPlayerSafe_OnVideoFrameAvailable(MediaPlayer sender, object args) - { - if (_isBlockVideoFrameDraw == 1) - return; - - if (Interlocked.Exchange(ref _isVideoFrameDrawInProgress, 1) == 1) - { -#if DEBUG - Logger.LogWriteLine($@"Skipping frame at: {sender.Position:hh\:mm\:ss\.ffffff}"); -#endif - return; - } - - Unsafe.SkipInit(out CanvasDrawingSession? drawingSession); - try - { - _videoPlayer?.CopyFrameToVideoSurface(_canvasRenderTarget); - drawingSession = _canvasImageSource?.CreateDrawingSession(default, _canvasRenderSize); - } - // Ignore DCOMPOSITION_ERROR_SURFACE_BEING_RENDERED - catch (COMException comEx) when (unchecked((uint)comEx.HResult) == 0x88980801u) - { - // ignored - } - // Trying to recreate the context instead of re-creating the entire canvas - catch (COMException comEx) when (unchecked((uint)comEx.HResult) == 0x802B0020u) - { - Logger.LogWriteLine($"[LayeredBackgroundImage::SafeOnVideoFrameAvailable] CanvasImageSource has lost its context. Re-creating the context...\r\n{comEx}", - LogType.Warning, - true); - - /* TODO: Re-create the context and start redrawing. - * Use it for CanvasImageSource later. Needs some fixes with how I can re-create the RenderTarget and the ImageSource without - * dealing with the lock and stuffs. - using CanvasLock? canvasLock = _canvasDevice?.Lock(); - _canvasImageSource?.Recreate(_canvasDevice); - Logger.LogWriteLine("[LayeredBackgroundImage::SafeOnVideoFrameAvailable] Trying to re-draw after re-creating the context...", - LogType.Warning, - true); - goto StartDraw; - */ - CanvasDevice_OnDeviceLost(); - } - // Device lost error. If happened, reinitialize render target - catch (COMException comEx) when (unchecked((uint)comEx.HResult) is 0x887A0005u or 0x8899000Cu) - { - Logger.LogWriteLine($"[LayeredBackgroundImage::SafeOnVideoFrameAvailable] Direct3D device is lost! Trying to reinitialize it...\r\n{comEx}", - LogType.Warning, - true); - CanvasDevice_OnDeviceLost(); - } - catch (Exception ex) - { - Logger.LogWriteLine($"[LayeredBackgroundImage::SafeOnVideoFrameAvailable] {ex}", - LogType.Error, - true); - } - finally - { - DispatcherQueue.TryEnqueue(DispatcherQueuePriority.High, () => - { - drawingSession?.Dispose(); - 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) - { - if (!InitializeRenderTarget()) - { - Interlocked.Exchange(ref _isVideoInitialized, 0); - return; - } - - // 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 - ? VideoPlayerUnsafe_OnVideoFrameAvailable - : VideoPlayerSafe_OnVideoFrameAvailable; - } - } - 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 bd16915d42..0000000000 --- a/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Events.Loaders.cs +++ /dev/null @@ -1,740 +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 -// ReSharper disable StringLiteralTypo -// ReSharper disable CommentTypo -#pragma warning disable IDE0051 -#pragma warning disable CS8321 // Local function is declared but never used -#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; - // Static artwork already includes the foreground used by the video. - element._foregroundGrid.Visibility = Visibility.Collapsed; - - 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._foregroundGrid.Visibility = Visibility.Visible; - 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; - } - - if (player == null || !ReferenceEquals(player, instance._videoPlayer) || player.IsObjectDisposed()) - { - ffmpegMediaSource.Dispose(); - isError = true; - return false; - } - - Interlocked.Exchange(ref instance._videoFfmpegMediaSource, ffmpegMediaSource); - await ffmpegMediaSource.OpenWithMediaPlayerAsync(player); - - // 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.VideoPlayerUnsafe_OnVideoFrameAvailable - : instance.VideoPlayerSafe_OnVideoFrameAvailable; - - 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 || !ReferenceEquals(sender, _videoPlayer) || sender.IsObjectDisposed()) - { - return; - } - - if (ReferenceEquals(_videoFramePlayer, sender)) return; - - MediaPlaybackSession session = sender.PlaybackSession; - session.NaturalVideoSizeChanged -= InitializeVideoFrameOnSizeChanged; - session.NaturalVideoSizeChanged += InitializeVideoFrameOnSizeChanged; - if (!InitializeRenderTargetSize(session)) return; - session.NaturalVideoSizeChanged -= InitializeVideoFrameOnSizeChanged; - - // 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); - - // Register events - image.Loaded += Image_VideoFrameOnLoaded; - image.Unloaded += Image_VideoFrameOnUnloaded; - - // Add to children - image.Transitions.Add(new ContentThemeTransition()); - _videoFramePlayer = sender; - _backgroundGrid.Children.Add(image); - } - } - - private void InitializeVideoFrameOnSizeChanged(MediaPlaybackSession sender, object args) - { - DispatcherQueue.TryEnqueue(() => - { - if (_videoPlayer is { } player && !player.IsObjectDisposed() && - ReferenceEquals(sender, player.PlaybackSession)) - { - InitializeVideoFrameOnMediaOpened(player, args); - } - }); - } - - private static void Image_VideoFrameOnLoaded(object sender, RoutedEventArgs e) - { - if (sender is not Image { Tag: ValueTuple parentGrid }) - { - return; - } - - if (!parentGrid.Item2.IsVideoAutoplay && - parentGrid.Item2._videoState is not (VideoState.RequestedPlay or VideoState.Playing)) 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 6330312954..b16b84cd85 100644 --- a/CollapseLauncher/XAMLs/Theme/CustomControls/PanelSlideshow.Converters.cs +++ b/CollapseLauncher/XAMLs/Theme/CustomControls/PanelSlideshow.Converters.cs @@ -1,7 +1,6 @@ #nullable enable using Hi3Helper.Data; using Microsoft.UI.Xaml; -using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Data; using System; diff --git a/CollapseLauncher/XAMLs/Theme/CustomControls/VideoPresenter/IVideoFramePresenter.cs b/CollapseLauncher/XAMLs/Theme/CustomControls/VideoPresenter/IVideoFramePresenter.cs new file mode 100644 index 0000000000..9c772d9c9d --- /dev/null +++ b/CollapseLauncher/XAMLs/Theme/CustomControls/VideoPresenter/IVideoFramePresenter.cs @@ -0,0 +1,21 @@ +using Microsoft.UI.Xaml; +using System; +using Windows.Graphics.DirectX.Direct3D11; + +namespace CollapseLauncher.XAMLs.Theme.CustomControls.VideoPresenter; + +public delegate void Direct3DSurfaceConsumer(IDirect3DSurface surface); +public delegate void Direct3DSurfaceConsumerUnsafe(nint surfaceAbi); + +public interface IVideoFramePresenter : IDisposable +{ + Visibility Visibility { get; set; } + + void Draw(Direct3DSurfaceConsumer surfaceConsumer, + int canvasWidth, + int canvasHeight); + + void DrawUnsafe(Direct3DSurfaceConsumerUnsafe surfaceConsumerUnsafe, + int canvasWidth, + int canvasHeight); +} diff --git a/CollapseLauncher/XAMLs/Theme/CustomControls/VideoPresenter/MediaFoundationPresenter.cs b/CollapseLauncher/XAMLs/Theme/CustomControls/VideoPresenter/MediaFoundationPresenter.cs new file mode 100644 index 0000000000..0e6dbbe7c5 --- /dev/null +++ b/CollapseLauncher/XAMLs/Theme/CustomControls/VideoPresenter/MediaFoundationPresenter.cs @@ -0,0 +1,927 @@ +using Hi3Helper.Win32.ManagedTools; +using Hi3Helper.Win32.Native.Enums.D2D; +using Hi3Helper.Win32.Native.Enums.D3D; +using Hi3Helper.Win32.Native.Enums.DXGI; +using Hi3Helper.Win32.Native.Interfaces.CompositorInterop; +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.D3D; +using Hi3Helper.Win32.Native.Structs.DXGI; +using Microsoft.Extensions.Logging; +using Microsoft.Graphics.Canvas; +using Microsoft.Graphics.DirectX; +using Microsoft.UI.Composition; +using Microsoft.UI.Dispatching; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Hosting; +using System; +using System.Collections.Concurrent; +using System.Numerics; +using System.Runtime.InteropServices; +using System.Runtime.InteropServices.Marshalling; +using System.Threading; +using Windows.Foundation; +using Windows.Graphics.DirectX.Direct3D11; +using WinRT; +using Rect = Windows.Foundation.Rect; + +// ReSharper disable IdentifierTypo +// ReSharper disable InconsistentNaming + +#nullable enable +namespace CollapseLauncher.XAMLs.Theme.CustomControls.VideoPresenter; + +/// +/// A XAML element that presents SDR frames through a drawing surface and FP16 HDR +/// frames through a linear scRGB composition swap chain. +/// Owns GPU resources only while loaded, visible, and intersecting its effective viewport. +/// +public sealed partial class MediaFoundationPresenter : FrameworkElement, IVideoFramePresenter +{ + private static ConcurrentDictionary LoadedPresenters = []; + + private static readonly Guid IID_IDXGISurface = typeof(IDXGISurface).GUID; + private static readonly Guid IID_ID3D11Texture2D = typeof(ID3D11Texture2D).GUID; + private static readonly Guid IID_IDirect3DSurface = typeof(IDirect3DSurface).GUID; + private static readonly Guid IID_ICompositionDrawingSurfaceInterop = typeof(ICompositionDrawingSurfaceInterop).GUID; + private static readonly Guid IID_IDXGIFactory2 = typeof(IDXGIFactory2).GUID; + + private int RenderWidth; + private int RenderHeight; + + private readonly Lock _renderLock = new(); + + // Frame callbacks must not wait for the UI thread while holding _renderLock. + // Keep the dependency properties' values here, protected by that same lock. + private DirectXPixelFormat _pixelFormat = DirectXPixelFormat.B8G8R8A8UIntNormalized; + private DirectXAlphaMode _alphaMode = DirectXAlphaMode.Premultiplied; + private CompositionStretch _stretch = CompositionStretch.UniformToFill; + private Vector2 _layoutSize; + + private readonly DispatcherQueue _dispatcherQueue; + + private Compositor? _compositor; + private SpriteVisual? _videoVisual; + private CompositionSurfaceBrush? _videoBrush; + private CompositionDrawingSurface? _compositionSurface; + private CompositionGraphicsDevice? _compositionGraphicsDevice; + private ICompositionDrawingSurfaceInterop? _drawingSurfaceInterop; + private nint _drawingSurfaceInteropAbi; + private IDXGISwapChain3? _swapChain; + private ICompositionSurface? _swapChainSurface; + + private CanvasDevice? _canvasDevice; + private ID3D11Device? _d3dDevice; + private ID3D11DeviceContext? _d3dContext; + private nint _d3dContextAbi; + private nint _frameTexture; + private IDirect3DSurface? _frameSurface; + private nint _frameSurfaceAbi; + + private bool _disposed; + private bool _recreating; + private bool _isLoaded; + private bool _hasViewport; + private Rect _effectiveViewport; + private readonly long _visibilityCallbackToken; + + public static readonly DependencyProperty StretchProperty = DependencyProperty.Register( + nameof(Stretch), + typeof(CompositionStretch), + typeof(MediaFoundationPresenter), + new PropertyMetadata(CompositionStretch.UniformToFill, OnStretchChanged)); + + public static readonly DependencyProperty PixelFormatProperty = DependencyProperty.Register( + nameof(PixelFormat), + typeof(DirectXPixelFormat), + typeof(MediaFoundationPresenter), + new PropertyMetadata(DirectXPixelFormat.B8G8R8A8UIntNormalized, OnPixelFormatOrAlphaModeChanged)); + + public static readonly DependencyProperty AlphaModeProperty = DependencyProperty.Register( + nameof(AlphaMode), + typeof(DirectXAlphaMode), + typeof(MediaFoundationPresenter), + new PropertyMetadata(DirectXAlphaMode.Premultiplied, OnPixelFormatOrAlphaModeChanged)); + + /// + /// The Scale of the video frame to be displayed. Defaults to: + /// + public CompositionStretch Stretch + { + get => (CompositionStretch)GetValue(StretchProperty); + set => SetValue(StretchProperty, value); + } + + /// + /// Format shared by the video frame texture and its presentation buffers. + /// Use for sources that supply HDR frames as linear scRGB. + /// FP16 uses a linear scRGB swap chain to avoid flattening HDR into an SDR drawing surface. + /// Defaults to: + /// + public DirectXPixelFormat PixelFormat + { + get => (DirectXPixelFormat)GetValue(PixelFormatProperty); + set => SetValue(PixelFormatProperty, value); + } + + /// + /// Alpha mode used by the video frame texture and its presentation buffers. + /// To ignore the Alpha channel, use . + /// Defaults to: + /// + public DirectXAlphaMode AlphaMode + { + get => (DirectXAlphaMode)GetValue(AlphaModeProperty); + set => SetValue(AlphaModeProperty, value); + } + + /// + /// An instance to the Logger for trace or debug purposes. + /// + public ILogger? Logger { get; set; } + + /// + /// while this element intersects its effective viewport and owns rendering resources. + /// + public bool IsPresentationActive { get; private set; } + + /// + /// Raised on the UI thread when viewport visibility changes resource ownership. + /// + public event EventHandler? PresentationStateChanged; + + /// + /// Creates a new instance of . + /// + public MediaFoundationPresenter() + { + _dispatcherQueue = DispatcherQueue; + Loaded += OnLoaded; + Unloaded += OnUnloaded; + EffectiveViewportChanged += OnEffectiveViewportChanged; + SizeChanged += OnSizeChanged; + _visibilityCallbackToken = RegisterPropertyChangedCallback(VisibilityProperty, OnVisibilityChanged); + + LoadedPresenters.TryAdd(this, true); + } + + private void OnLoaded(object sender, RoutedEventArgs args) + { + _isLoaded = true; + using (_renderLock.EnterScope()) + _layoutSize = new Vector2((float)ActualWidth, (float)ActualHeight); + // Wait for viewport information before allocating, including on the first load offscreen. + UpdatePresentationState(); + } + + private void OnUnloaded(object sender, RoutedEventArgs args) + { + _isLoaded = false; + // Keep the last viewport: WinUI only reports changes, so reloading at the same + // bounds may not produce another notification. _isLoaded still gates allocation. + UpdatePresentationState(); + } + + private static void OnStretchChanged(DependencyObject sender, DependencyPropertyChangedEventArgs args) + { + MediaFoundationPresenter presenter = (MediaFoundationPresenter)sender; + using (presenter._renderLock.EnterScope()) + { + presenter._stretch = (CompositionStretch)args.NewValue; + presenter.UpdateSurfaceBrushTransform(); + } + } + + private static void OnPixelFormatOrAlphaModeChanged(DependencyObject sender, DependencyPropertyChangedEventArgs args) + { + MediaFoundationPresenter presenter = (MediaFoundationPresenter)sender; + using (presenter._renderLock.EnterScope()) + { + presenter._pixelFormat = presenter.PixelFormat; + presenter._alphaMode = presenter.AlphaMode; + + // Defer allocation until viewport reentry if currently inactive. The next + // frame supplies the dimensions; never allocate a zero-sized frame texture. + if (presenter is { _disposed: false, IsPresentationActive: true }) + presenter.RecreateDeviceResources(); + } + } + + private void OnEffectiveViewportChanged(FrameworkElement sender, EffectiveViewportChangedEventArgs args) + { + _effectiveViewport = args.EffectiveViewport; + _hasViewport = true; + UpdatePresentationState(); + } + + private void OnSizeChanged(object sender, SizeChangedEventArgs args) + { + using (_renderLock.EnterScope()) + { + _layoutSize = new Vector2((float)args.NewSize.Width, (float)args.NewSize.Height); + UpdateSurfaceBrushTransform(); + } + UpdatePresentationState(); + } + + private void OnVisibilityChanged(DependencyObject sender, DependencyProperty property) => UpdatePresentationState(); + + // Fill the offered layout slot. In an unconstrained panel the caller can set Width/Height. + protected override Size MeasureOverride(Size availableSize) => new( + double.IsPositiveInfinity(availableSize.Width) ? 0 : availableSize.Width, + double.IsPositiveInfinity(availableSize.Height) ? 0 : availableSize.Height); + + protected override Size ArrangeOverride(Size finalSize) => finalSize; + + private unsafe void UpdateSurfaceBrushTransform() + { + if (_videoBrush is null) + return; + + if (_pixelFormat != DirectXPixelFormat.R16G16B16A16Float) + { + _videoBrush.Stretch = _stretch; + _videoBrush.TransformMatrix = Matrix3x2.Identity; + return; + } + + // Swap-chain surfaces do not participate in automatic surface-brush stretching. + // Apply sizing on the swap chain itself, before the composition surface clips it. + _videoBrush.Stretch = CompositionStretch.None; + _videoBrush.TransformMatrix = Matrix3x2.Identity; + if (RenderWidth <= 0 || RenderHeight <= 0 || _swapChain == null || + _layoutSize.X <= 0 || _layoutSize.Y <= 0) + return; + + Vector2 frameSize = new(RenderWidth, RenderHeight); + Vector2 scale = _layoutSize / frameSize; + scale = _stretch switch + { + CompositionStretch.None => Vector2.One, + CompositionStretch.Uniform => new Vector2(Math.Min(scale.X, scale.Y)), + CompositionStretch.UniformToFill => new Vector2(Math.Max(scale.X, scale.Y)), + _ => scale + }; + + Vector2 offset = (_layoutSize - frameSize * scale) * 0.5f; + Matrix3x2 transform = Matrix3x2.CreateScale(scale) * Matrix3x2.CreateTranslation(offset); + // IDXGISwapChain2::SetMatrixTransform; Matrix3x2 matches DXGI_MATRIX_3X2_F. + _swapChain.SetMatrixTransform(in transform); + } + + private void UpdatePresentationState() + { + Rect visibleBounds = _effectiveViewport; + visibleBounds.Intersect(new Rect(0, 0, ActualWidth, ActualHeight)); + + bool active = !_disposed && _isLoaded && _hasViewport && Visibility == Visibility.Visible && + visibleBounds is { IsEmpty: false, Width: > 0, Height: > 0 }; + + using (_renderLock.EnterScope()) + { + if (IsPresentationActive == active) + return; + + if (active) + CreateCompositionResources(); + else + ReleaseCompositionResources(); + + IsPresentationActive = active; + } + + // Playback owners may wait for their own frame callbacks; never notify under the render lock. + PresentationStateChanged?.Invoke(this, EventArgs.Empty); + } + + private void CreateCompositionResources() + { + using (_renderLock.EnterScope()) + { + if (_disposed || _videoVisual != null) + return; + + try + { + _compositor = ElementCompositionPreview.GetElementVisual(this).Compositor; + _videoBrush = _compositor.CreateSurfaceBrush(); + _videoBrush.Stretch = Stretch; + _videoBrush.HorizontalAlignmentRatio = 0.5f; + _videoBrush.VerticalAlignmentRatio = 0.5f; + + _videoVisual = _compositor.CreateSpriteVisual(); + _videoVisual.Brush = _videoBrush; + _videoVisual.RelativeSizeAdjustment = Vector2.One; + + CreateDeviceResources(); + + // This element owns the visual; no other XAML element's visual is replaced. + ElementCompositionPreview.SetElementChildVisual(this, _videoVisual); + } + catch + { + ReleaseCompositionResources(); + throw; + } + } + } + + private void CalculateFrameSize(int requestedWidth, int requestedHeight) + { + if (requestedWidth <= 0 || requestedHeight <= 0) + return; + + if (_frameSurface != null && requestedWidth == RenderWidth && requestedHeight == RenderHeight) + return; + + ResizeDrawingSurface(requestedWidth, requestedHeight); + } + + /// Permanently releases this presenter. Call on the UI thread. + public void Dispose() + { + using (_renderLock.EnterScope()) + { + if (_disposed) + return; + + _disposed = true; + Loaded -= OnLoaded; + Unloaded -= OnUnloaded; + EffectiveViewportChanged -= OnEffectiveViewportChanged; + SizeChanged -= OnSizeChanged; + UnregisterPropertyChangedCallback(VisibilityProperty, _visibilityCallbackToken); + } + + LoadedPresenters.TryRemove(this, out _); + Console.WriteLine($"Total Remained MediaFoundationPresenter instances: {LoadedPresenters.Count} instances"); + + UpdatePresentationState(); + } + + private void ReleaseCompositionResources() + { + ElementCompositionPreview.SetElementChildVisual(this, null); + ReleaseDeviceResources(); + _videoVisual?.Dispose(); + _videoBrush?.Dispose(); + _videoVisual = null; + _videoBrush = null; + _compositor = null; + _recreating = false; + } + + private unsafe void CreateDeviceResources() + { + using (_renderLock.EnterScope()) + { + _canvasDevice = CanvasDevice.GetSharedDevice(); + _d3dDevice = CreateD3DDevice(_canvasDevice, Logger) + ?? throw new InvalidOperationException("Could not obtain the native D3D11 device from the CanvasDevice."); + + if (_d3dContext == null) + _d3dDevice.GetImmediateContext(out _d3dContext); + + _d3dContextAbi = (nint)ComInterfaceMarshaller.ConvertToUnmanaged(_d3dContext); + + // MediaPlayer and the compositor also use this immediate context. + if (ComMarshal.TryCastComObjectAs(_d3dContext, + out ID3D11Multithread? d3d11Mt, + out _, + useUnique: true)) + { + try + { + d3d11Mt.SetMultithreadProtected(1); + } + finally + { + ComMarshal.FinalRelease(d3d11Mt); + } + } + + if (_pixelFormat == DirectXPixelFormat.R16G16B16A16Float) + { + // Create the swap chain with the first frame's actual dimensions. + return; + } + + _compositionGraphicsDevice = CreateCompositionGraphicsDevice(_compositor!, _d3dDevice, Logger); + _compositionSurface = _compositionGraphicsDevice + .CreateDrawingSurface(new Size(0, 0), + _pixelFormat, + _alphaMode); + + // This reference is borrowed from the projected surface. + nint surfaceP = ((IWinRTObject)_compositionSurface).NativeObject.ThisPtr; + if (!ComMarshal + .TryCreateComObjectFromReference(surfaceP, + out _drawingSurfaceInterop, + out Exception? ex, + releaseReference: false, + useUnique: true)) + { + throw ex; + } + + // Query borrowed reference from drawing surface interop (+1 ref). + int hr = Marshal.QueryInterface(surfaceP, in IID_ICompositionDrawingSurfaceInterop, out _drawingSurfaceInteropAbi); + Marshal.ThrowExceptionForHR(hr); + _videoBrush!.Surface = _compositionSurface; + } + } + + private static unsafe ID3D11Device? CreateD3DDevice(CanvasDevice canvasDevice, ILogger? logger) + { + nint canvasDeviceP = ((IWinRTObject)canvasDevice).NativeObject.ThisPtr; + nint accessP = nint.Zero; + IDirect3DDxgiInterfaceAccess? access = null; + + try + { + int hr = Marshal.QueryInterface(canvasDeviceP, + typeof(IDirect3DDxgiInterfaceAccess).GUID, + out accessP); + + if (hr < 0) + return null; + + if (!ComMarshal + .TryCreateComObjectFromReference( + accessP, + out access, + out _, + releaseReference: false, + useUnique: true)) + { + return null; + } + + hr = access.GetInterface(typeof(ID3D11Device).GUID, out nint nativeDeviceP); + if (hr < 0) + return null; + + if (!ComMarshal + .TryCreateComObjectFromReference(nativeDeviceP, + out ID3D11Device? nativeDevice, + out _, + releaseReference: true, + useUnique: true)) + { + return null; + } + + logger?.LogDebug("D3D11 Device Created!"); + return nativeDevice; + } + finally + { + ComMarshal.FinalRelease(access); + + if (accessP != nint.Zero) + Marshal.Release(accessP); + } + } + + private static unsafe CompositionGraphicsDevice CreateCompositionGraphicsDevice( + Compositor compositor, + ID3D11Device device, + ILogger? logger) + { + nint compositorAbi = ((IWinRTObject)compositor).NativeObject.ThisPtr; + if (!ComMarshal + .TryCreateComObjectFromReference(compositorAbi, + out ICompositorInterop? compositorInterop, + out Exception? ex, + releaseReference: false, + useUnique: true)) + { + throw ex; + } + + nint graphicsDeviceP = nint.Zero; + nint d3dDeviceP = nint.Zero; + try + { + d3dDeviceP = (nint)ComInterfaceMarshaller.ConvertToUnmanaged(device); + compositorInterop.CreateGraphicsDevice(d3dDeviceP, out graphicsDeviceP); + + logger?.LogDebug("D3D11 Graphics Created from the Compositor!"); + return MarshalInterface.FromAbi(graphicsDeviceP); + } + finally + { + if (graphicsDeviceP != nint.Zero) Marshal.Release(graphicsDeviceP); + if (d3dDeviceP != nint.Zero) Marshal.Release(d3dDeviceP); + ComMarshal.FinalRelease(compositorInterop); + } + } + + public void Draw(Direct3DSurfaceConsumer surfaceConsumer, + int canvasWidth, + int canvasHeight) + { + using Lock.Scope renderScope = _renderLock.EnterScope(); + try + { + if (_disposed || _recreating || _d3dDevice is null || + canvasWidth <= 0 || canvasHeight <= 0) + return; + + CalculateFrameSize(canvasWidth, canvasHeight); + if (_frameSurface == null) + return; + + surfaceConsumer(_frameSurface); + if (_swapChain != null) + { + PresentHdrFrame(); + return; + } + + int hr = _drawingSurfaceInterop!.BeginDraw(nint.Zero, + in IID_IDXGISurface, + out nint updateP, + out POINTL offset); + Marshal.ThrowExceptionForHR(hr); + + nint updateTextureP = nint.Zero; + try + { + hr = Marshal.QueryInterface(updateP, in IID_ID3D11Texture2D, out updateTextureP); + Marshal.ThrowExceptionForHR(hr); + + _d3dContext!.CopySubresourceRegion(updateTextureP, + 0, + (uint)offset.x, + (uint)offset.y, + 0, + _frameTexture, + 0, + nint.Zero); + + _d3dContext.Flush(); + } + finally + { + // The update texture belongs to this BeginDraw/EndDraw pair only. + if (updateTextureP != nint.Zero) Marshal.Release(updateTextureP); + if (updateP != nint.Zero) Marshal.Release(updateP); + Marshal.ThrowExceptionForHR(_drawingSurfaceInterop.EndDraw()); + } + } + catch (Exception ex) when (IsDeviceLost(ex.HResult)) + { + QueueDeviceRecreation(); + } + } + + public unsafe void DrawUnsafe( + Direct3DSurfaceConsumerUnsafe surfaceConsumerUnsafe, + int canvasWidth, + int canvasHeight) + { + using Lock.Scope renderScope = _renderLock.EnterScope(); + try + { + if (_disposed || _recreating || _d3dDevice is null || + canvasWidth <= 0 || canvasHeight <= 0) + return; + + CalculateFrameSize(canvasWidth, canvasHeight); + if (_frameSurfaceAbi == nint.Zero) + return; + + surfaceConsumerUnsafe(_frameSurfaceAbi); + if (_swapChain != null) + { + PresentHdrFrame(); + return; + } + int hr = ((delegate* unmanaged[MemberFunction])(*(*(void***)_drawingSurfaceInteropAbi + 3))) + (_drawingSurfaceInteropAbi, nint.Zero, in IID_IDXGISurface, out nint updateP, out POINTL offset); + Marshal.ThrowExceptionForHR(hr); + + nint updateTextureP = nint.Zero; + try + { + hr = Marshal.QueryInterface(updateP, in IID_ID3D11Texture2D, out updateTextureP); + Marshal.ThrowExceptionForHR(hr); + ((delegate* unmanaged[MemberFunction])(*(*(void***)_d3dContextAbi + 46))) + (_d3dContextAbi, updateTextureP, 0, (uint)offset.x, (uint)offset.y, 0, _frameTexture, 0, nint.Zero); + ((delegate* unmanaged[MemberFunction])(*(*(void***)_d3dContextAbi + 111))) + (_d3dContextAbi); + } + finally + { + // The update texture belongs to this BeginDraw/EndDraw pair only. + if (updateTextureP != nint.Zero) Marshal.Release(updateTextureP); + if (updateP != nint.Zero) Marshal.Release(updateP); + hr = ((delegate* unmanaged[MemberFunction])(*(*(void***)_drawingSurfaceInteropAbi + 4))) + (_drawingSurfaceInteropAbi); + Marshal.ThrowExceptionForHR(hr); + } + } + catch (Exception ex) when (IsDeviceLost(ex.HResult)) + { + QueueDeviceRecreation(); + } + } + + public CanvasRenderTarget? CopyCurrentFrame() + { + using Lock.Scope renderScope = _renderLock.EnterScope(); + if (_canvasDevice == null) + return null; + + Windows.Graphics.DirectX.DirectXPixelFormat pixelFormat = (Windows.Graphics.DirectX.DirectXPixelFormat)PixelFormat; + CanvasAlphaMode alphaMode = _alphaMode switch + { + DirectXAlphaMode.Ignore => CanvasAlphaMode.Ignore, + DirectXAlphaMode.Premultiplied => CanvasAlphaMode.Premultiplied, + DirectXAlphaMode.Straight => throw new NotSupportedException(), + + _ => CanvasAlphaMode.Premultiplied + }; + + CanvasRenderTarget renderTarget = new(_canvasDevice, RenderWidth, RenderHeight, 96f, pixelFormat, alphaMode); + using CanvasBitmap source = CanvasBitmap.CreateFromDirect3D11Surface(_canvasDevice, _frameSurface, 96, alphaMode); + renderTarget.CopyPixelsFromBitmap(source); + return renderTarget; + } + + private static bool IsDeviceLost(int hr) + { + const int DXGI_ERROR_DEVICE_REMOVED = unchecked((int)0x887A0005); + const int DXGI_ERROR_DEVICE_HUNG = unchecked((int)0x887A0006); + const int DXGI_ERROR_DEVICE_RESET = unchecked((int)0x887A0007); + + return hr is DXGI_ERROR_DEVICE_REMOVED + or DXGI_ERROR_DEVICE_HUNG + or DXGI_ERROR_DEVICE_RESET; + } + + private void QueueDeviceRecreation() + { + if (_recreating || _disposed) + return; + + _recreating = true; + + // Ignore a queued recovery if the element was unloaded (or reloaded) meanwhile. + CompositionSurfaceBrush? brush = _videoBrush; + if (!_dispatcherQueue.TryEnqueue(() => + { + using (_renderLock.EnterScope()) + { + if (!_disposed && brush != null && ReferenceEquals(brush, _videoBrush)) + RecreateDeviceResources(); + } + })) + _recreating = false; + } + + private unsafe void CreateHdrSwapChain(int width, int height) + { + // Linear scRGB (BT.709 primaries), not PQ: MediaPlayer has already converted + // the source transfer function when copying into the FP16 frame texture. + const DXGI_COLOR_SPACE_TYPE scRgb = DXGI_COLOR_SPACE_TYPE.DXGI_COLOR_SPACE_RGB_FULL_G10_NONE_P709; + + // An FP16 drawing surface can be flattened into WinUI's SDR composition target. + // A flip-model swap chain carries its own scRGB color space through presentation. + IDXGIFactory2? factory = null; + IDXGISwapChain1? swapChain1 = null; + ICompositorSwapChainInterop? interop = null; + nint surfaceAbi = 0; + + try + { + Marshal.ThrowExceptionForHR(PInvoke.CreateDXGIFactory2(0, in IID_IDXGIFactory2, out factory)); + DXGI_SWAP_CHAIN_DESC1 desc = new() + { + Width = (uint)width, + Height = (uint)height, + Format = DXGI_FORMAT.DXGI_FORMAT_R16G16B16A16_FLOAT, + SampleDesc = new DXGI_SAMPLE_DESC { Count = 1 }, + BufferUsage = DXGI_USAGE.DXGI_USAGE_RENDER_TARGET_OUTPUT, + BufferCount = 2, + Scaling = DXGI_SCALING.DXGI_SCALING_STRETCH, + SwapEffect = DXGI_SWAP_EFFECT.DXGI_SWAP_EFFECT_FLIP_SEQUENTIAL, + AlphaMode = (DXGI_ALPHA_MODE)_alphaMode + }; + + factory!.CreateSwapChainForComposition(_d3dDevice!, in desc, null, out swapChain1); + if (!ComMarshal.TryCastComObjectAs(swapChain1, + out _swapChain, + out Exception? ex, + useUnique: true)) + { + throw ex; + } + + _swapChain.CheckColorSpaceSupport(scRgb, out DXGI_SWAP_CHAIN_COLOR_SPACE_SUPPORT_FLAG support); + if (!support.HasFlag(DXGI_SWAP_CHAIN_COLOR_SPACE_SUPPORT_FLAG.PRESENT)) + throw new NotSupportedException("The graphics device cannot present a linear scRGB swap chain."); + + _swapChain.SetColorSpace1(scRgb); + nint compositor = ((IWinRTObject)_compositor!).NativeObject.ThisPtr; + if (!ComMarshal + .TryCreateComObjectFromReference(compositor, + out interop, + out ex, + releaseReference: false, + useUnique: true)) + { + throw ex; + } + + interop.CreateCompositionSurfaceForSwapChain(_swapChain, out surfaceAbi); + _swapChainSurface = MarshalInterface.FromAbi(surfaceAbi); + _videoBrush!.Surface = _swapChainSurface; + + Logger?.LogDebug("D3D11 Swap Chain Created! {width}x{height} with format: {format}", RenderWidth, RenderHeight, desc.Format); + } + catch + { + ReleaseHdrSwapChain(); + throw; + } + finally + { + if (surfaceAbi != nint.Zero) Marshal.Release(surfaceAbi); + + ComMarshal.FinalRelease(interop); + ComMarshal.FinalRelease(swapChain1); + ComMarshal.FinalRelease(factory); + } + } + + private unsafe void PresentHdrFrame() + { + nint backBuffer = 0; + try + { + // Release before Present/ResizeBuffers. + _swapChain!.GetBuffer(0, in IID_ID3D11Texture2D, out backBuffer); + _d3dContext!.CopySubresourceRegion(backBuffer, 0, 0, 0, 0, _frameTexture, 0, 0); + } + finally + { + if (backBuffer != 0) Marshal.Release(backBuffer); + } + + // Present flushes the copy and submits the FP16 buffer without an SDR conversion. + _swapChain.Present(0, 0); + } + + private void ReleaseHdrSwapChain() + { + if (_swapChainSurface != null) + { + _videoBrush?.Surface = null; + // This wrapper is exclusively owned by the presenter. Release its native + // reference after detaching so swap-chain buffers don't wait for a GC. + ((IWinRTObject)_swapChainSurface).NativeObject.Dispose(); + _swapChainSurface = null; + } + if (_swapChain != null) ComMarshal.FinalRelease(Interlocked.Exchange(ref _swapChain, null)); + } + + private unsafe void CreateFrameSurface() + { + DXGI_FORMAT format = (DXGI_FORMAT)_pixelFormat; + D3D11_TEXTURE2D_DESC desc = new() + { + Width = (uint)RenderWidth, + Height = (uint)RenderHeight, + MipLevels = 1, + ArraySize = 1, + Format = format, + SampleDesc = new DXGI_SAMPLE_DESC { Count = 1 }, + Usage = D3D11_USAGE.D3D11_USAGE_DEFAULT, + BindFlags = D3D11_BIND_FLAG.D3D11_BIND_RENDER_TARGET | D3D11_BIND_FLAG.D3D11_BIND_SHADER_RESOURCE + }; + + nint textureP = nint.Zero; + nint dxgiSurfaceP = nint.Zero; + nint graphicsSurfaceP = nint.Zero; + try + { + _d3dDevice!.CreateTexture2D(in desc, nint.Zero, out textureP); + + int hr = Marshal.QueryInterface(textureP, in IID_IDXGISurface, out dxgiSurfaceP); + Marshal.ThrowExceptionForHR(hr); + + hr = PInvoke.CreateDirect3D11SurfaceFromDXGISurface(dxgiSurfaceP, out graphicsSurfaceP); + Marshal.ThrowExceptionForHR(hr); + + hr = Marshal.QueryInterface(textureP, in IID_ID3D11Texture2D, out _frameTexture); + Marshal.ThrowExceptionForHR(hr); + + _frameSurface = MarshalInterface.FromAbi(graphicsSurfaceP); + Marshal.QueryInterface(graphicsSurfaceP, in IID_IDirect3DSurface, out _frameSurfaceAbi); + + Logger?.LogDebug("D3D11 Texture Created! {width}x{height} with format: {format}", RenderWidth, RenderHeight, desc.Format); + } + catch + { + ReleaseFrameSurface(); + throw; + } + finally + { + if (graphicsSurfaceP != nint.Zero) Marshal.Release(graphicsSurfaceP); + if (dxgiSurfaceP != nint.Zero) Marshal.Release(dxgiSurfaceP); + if (textureP != nint.Zero) Marshal.Release(textureP); + } + } + + private void ReleaseFrameSurface() + { + if (_frameTexture != nint.Zero) Marshal.Release(Interlocked.Exchange(ref _frameTexture, nint.Zero)); + if (_frameSurfaceAbi != nint.Zero) Marshal.Release(Interlocked.Exchange(ref _frameSurfaceAbi, nint.Zero)); + + _frameSurface?.Dispose(); + _frameSurface = null; + _d3dContext?.Flush(); + } + + private void RecreateDeviceResources() + { + using (_renderLock.EnterScope()) + { + if (_disposed || !IsPresentationActive || _videoBrush == null) + return; + + try + { + ReleaseDeviceResources(); + CreateDeviceResources(); + } + catch + { + ReleaseDeviceResources(); + throw; + } + finally + { + _recreating = false; + } + } + } + + private unsafe void ResizeDrawingSurface( + int width, + int height) + { + ReleaseFrameSurface(); + if (_pixelFormat == DirectXPixelFormat.R16G16B16A16Float && _swapChain == null) + { + CreateHdrSwapChain(width, height); + RenderWidth = width; + RenderHeight = height; + UpdateSurfaceBrushTransform(); + CreateFrameSurface(); + return; + } + + (_swapChain?.ResizeBuffers(0, (uint)width, (uint)height, (DXGI_FORMAT)_pixelFormat, 0) ?? + _drawingSurfaceInterop!.Resize(new SIZEL { Width = width, Height = height })).ThrowOnFailure(); + + RenderWidth = width; + RenderHeight = height; + UpdateSurfaceBrushTransform(); + CreateFrameSurface(); + + Logger?.LogDebug("Video Surface Resized: {width}x{height}", width, height); + } + + private void ReleaseDeviceResources() + { + _videoBrush?.Surface = null; + ReleaseFrameSurface(); + ReleaseHdrSwapChain(); + ComMarshal.FinalRelease(_drawingSurfaceInterop); + _drawingSurfaceInterop = null; + + // As we performed QueryInterface from the origin ABI. We release this one (but not with the origin too). + if (_drawingSurfaceInteropAbi != nint.Zero) Marshal.Release(Interlocked.Exchange(ref _drawingSurfaceInteropAbi, nint.Zero)); + + _compositionSurface?.Dispose(); + _compositionSurface = null; + _compositionGraphicsDevice?.Dispose(); + _compositionGraphicsDevice = null; + + // Release the queried ID3D11DeviceContext + if (_d3dContextAbi != nint.Zero) Marshal.Release(Interlocked.Exchange(ref _d3dContextAbi, nint.Zero)); + ComMarshal.FinalRelease(_d3dContext); + ComMarshal.FinalRelease(_d3dDevice); + _d3dContext = null; + _d3dDevice = null; + _canvasDevice = null; + + RenderWidth = RenderHeight = 0; + } +} diff --git a/CollapseLauncher/XAMLs/Theme/StylesGeneric_ThemeResource.xaml b/CollapseLauncher/XAMLs/Theme/StylesGeneric_ThemeResource.xaml index 197e550df4..18aea5c13b 100644 --- a/CollapseLauncher/XAMLs/Theme/StylesGeneric_ThemeResource.xaml +++ b/CollapseLauncher/XAMLs/Theme/StylesGeneric_ThemeResource.xaml @@ -4,7 +4,6 @@ - ms-appx:///Assets/CollapseLauncherLogo.png ms-appx:///Assets/CollapseLauncherLogoOutlineLight.png #ffd52a @@ -367,7 +366,6 @@ - ms-appx:///Assets/CollapseLauncherLogo.png ms-appx:///Assets/CollapseLauncherLogoOutlineDark.png #693758 @@ -721,7 +719,6 @@ - ms-appx:///Assets/CollapseLauncherLogo.png ms-appx:///Assets/CollapseLauncherLogoOutlineLight.png (Unsafe.AsPointer(ref WriteOneByte(ref bufferP, SquareBracketOpen)), 16), out int dateTimeFormatWritten, - DateTimeFormat)) + DateTimeFormat, CultureInfo.InvariantCulture)) { bufferP = ref Unsafe.Add(ref bufferP, dateTimeFormatWritten); } diff --git a/Hi3Helper.MagicScaler.LibheifCL/HeifCodec.cs b/Hi3Helper.MagicScaler.LibheifCL/HeifCodec.cs new file mode 100644 index 0000000000..648e2b4e59 --- /dev/null +++ b/Hi3Helper.MagicScaler.LibheifCL/HeifCodec.cs @@ -0,0 +1,63 @@ +// Copyright © Clinton Ingram and Contributors +// SPDX-License-Identifier: MIT + +using System; +using System.Linq; + +using PhotoSauce.MagicScaler; +using static Hi3Helper.MagicScaler.LibheifCL.Libheif; +// ReSharper disable IdentifierTypo +// ReSharper disable InconsistentNaming + +namespace Hi3Helper.MagicScaler.LibheifCL; + +internal static unsafe class HeifFactory +{ + public const string DisplayName = $"{Libheif} {LIBHEIF_VERSION}"; + public const string Libheif = nameof(Libheif); + public const uint Libver = LIBHEIF_NUMERIC_VERSION; + + private static readonly Lazy DependencyValid = new(() => { + uint ver = heif_get_version_number(); + if (ver != Libver) + throw new NotSupportedException($"Incorrect {Libheif} version was loaded. Expected 0x{Libver:x8}, found 0x{ver:x8}."); + + return true; + }); + + public static void* CreateContext() => DependencyValid.Value ? heif_context_alloc() : null; + + public static string GetMimeType(byte* data, int len) => DependencyValid.Value ? new string(heif_get_file_mime_type(data, len)) : "image/heif"; +} + +/// +public static class CodecCollectionExtensions +{ + /// + public static void UseLibheifCL(this CodecCollection codecs, bool removeExisting = true) + { + ArgumentNullException.ThrowIfNull(codecs); + + if (removeExisting) + { + foreach (IImageDecoderInfo codec in codecs.OfType().Where(c => c.MimeTypes.Any(m => m is ImageMimeTypes.Heic or ImageMimeTypes.Avif)).ToList()) + codecs.Remove(codec); + } + + codecs.Add(new DecoderInfo( + HeifFactory.DisplayName, + [ ImageMimeTypes.Heic, ImageMimeTypes.Avif ], + [ ImageFileExtensions.Heic, ImageFileExtensions.Avif ], + [ + new ContainerPattern(0, [.. "\0\0\0\0ftypheic"u8], [ 0xff, 0xff, 0xff, 0, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff ]), + new ContainerPattern(0, [.. "\0\0\0\0ftyphevc"u8], [ 0xff, 0xff, 0xff, 0, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff ]), + new ContainerPattern(0, [.. "\0\0\0\0ftypavif"u8], [ 0xff, 0xff, 0xff, 0, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff ]), + new ContainerPattern(0, [.. "\0\0\0\0ftypmif1"u8], [ 0xff, 0xff, 0xff, 0, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff ]), + new ContainerPattern(0, [.. "\0\0\0\0ftypmiaf"u8], [ 0xff, 0xff, 0xff, 0, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff ]), + new ContainerPattern(0, [.. "\0\0\0\0ftyp1pic"u8], [ 0xff, 0xff, 0xff, 0, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff ]) + ], + null, + HeifContainer.TryLoad + )); + } +} diff --git a/Hi3Helper.MagicScaler.LibheifCL/HeifDecoder.cs b/Hi3Helper.MagicScaler.LibheifCL/HeifDecoder.cs new file mode 100644 index 0000000000..ae54e8a371 --- /dev/null +++ b/Hi3Helper.MagicScaler.LibheifCL/HeifDecoder.cs @@ -0,0 +1,238 @@ +// Copyright © Clinton Ingram and Contributors +// SPDX-License-Identifier: MIT + +using System; +using System.IO; + +using PhotoSauce.MagicScaler; +using System.Diagnostics; +using System.Drawing; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using static Hi3Helper.MagicScaler.LibheifCL.Libheif; +// ReSharper disable IdentifierTypo +#pragma warning disable CA1816 + +namespace Hi3Helper.MagicScaler.LibheifCL; + +internal sealed unsafe class HeifContainer : IImageContainer +{ + private void* _handle; + private readonly StreamWrapper _stream; + + private HeifContainer(string mime, StreamWrapper stm, void* frm) + { + MimeType = mime; + _stream = stm; + _handle = frm; + } + + public string MimeType { get; } + + int IImageContainer.FrameCount => 1; + + IImageFrame IImageContainer.GetFrame(int index) + { + if (index != 0) + throw new ArgumentOutOfRangeException(nameof(index), "Invalid frame index."); + + return new HeifFrame(this); + } + + public static HeifContainer? TryLoad(Stream imgStream, IDecoderOptions? opts) + { + byte* buff = stackalloc byte[12]; + _ = imgStream.Read(new Span(buff, 12)); + imgStream.Seek(-12, SeekOrigin.Current); + + string mime = HeifFactory.GetMimeType(buff, 12); + + StreamWrapper stream = StreamWrapper.Read(imgStream); + void* ctx = heif_context_alloc(); + if (Succeeded(heif_context_read_from_memory_without_copy(ctx, stream.Buffer, stream.Length, 0))) + { + void* hfrm; + if (Succeeded(heif_context_get_primary_image_handle(ctx, &hfrm))) + { + heif_context_free(ctx); + return new HeifContainer(mime, stream, hfrm); + } + } + + heif_context_free(ctx); + stream.Dispose(); + imgStream.Position = 0; + return null; + } + + public void CheckResult(heif_error err) + { + if (err.code != heif_error_code.heif_error_Ok) + throw new InvalidOperationException(new string(err.message)); + } + + public static bool Succeeded(heif_error err) => err.code == heif_error_code.heif_error_Ok; + + private void EnsureHandle() + { + ObjectDisposedException.ThrowIf(_handle == null, typeof(HeifContainer)); + } + + ~HeifContainer() => Dispose(); + + public void Dispose() + { + if (_handle is null) + return; + + heif_image_handle_release(_handle); + _handle = null; + + _stream.Dispose(); + + GC.SuppressFinalize(this); + } + + private sealed class HeifFrame(HeifContainer cont) : IImageFrame + { + private HeifPixelSource? _pixsrc; + + public IPixelSource PixelSource => _pixsrc ??= new HeifPixelSource(cont); + + public void Dispose() + { + _pixsrc?.Dispose(); + cont.Dispose(); + } + } + + private sealed class HeifPixelSource : IPixelSource, IDisposable + { + private static ref readonly Guid GuidWicPixelFormat32BppRgba + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get + { + ReadOnlySpan data = [ + 0x2D, 0xAD, 0xC7, 0xF5, + 0x8D, 0x6A, + 0xDD, 0x43, + 0xA7, + 0xA8, + 0xA2, + 0x99, + 0x35, + 0x26, + 0x1A, + 0xE9 + ]; + + Debug.Assert(data.Length == Unsafe.SizeOf()); + return ref Unsafe.As(ref MemoryMarshal.GetReference(data)); + } + } + + private static ref readonly Guid GuidWicPixelFormat24BppRgb + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get + { + ReadOnlySpan data = [ + 0x24, 0xC3, 0xDD, 0x6F, + 0x03, 0x4E, + 0xFE, 0x4B, + 0xB1, + 0x85, + 0x3D, + 0x77, + 0x76, + 0x8D, + 0xC9, + 0x0D + ]; + + Debug.Assert(data.Length == Unsafe.SizeOf()); + return ref Unsafe.As(ref MemoryMarshal.GetReference(data)); + } + } + + private readonly HeifContainer _container; + private void* _handle; + private byte* _pixels; + private int _stride; + private readonly bool _hasAlpha; + private readonly int _bpp; + + public Guid Format { get; } + + public int Width { get; } + public int Height { get; } + + public HeifPixelSource(HeifContainer cont) + { + cont.EnsureHandle(); + + Width = heif_image_handle_get_width(cont._handle); + Height = heif_image_handle_get_height(cont._handle); + _hasAlpha = heif_image_handle_has_alpha_channel(cont._handle) != 0; + Format = _hasAlpha ? GuidWicPixelFormat32BppRgba : GuidWicPixelFormat24BppRgb; + _bpp = _hasAlpha ? 32 : 24; + + _container = cont; + } + + public void CopyPixels(Rectangle sourceArea, int cbStride, Span buffer) + { + if (_pixels is null) + DecodeImage(); + + int chan = _bpp / 8; + byte* pbBuffer = (byte*)Unsafe.AsPointer(ref MemoryMarshal.GetReference(buffer)); + for (int y = 0; y < sourceArea.Height; y++) + Buffer.MemoryCopy(_pixels + (sourceArea.Y + y) * _stride + sourceArea.X * chan, pbBuffer + y * cbStride, cbStride, sourceArea.Width * chan); + } + + private void DecodeImage() + { + const heif_channel chan = heif_channel.heif_channel_interleaved; + _container.EnsureHandle(); + + heif_decoding_options* options = heif_decoding_options_alloc(); + options->num_library_threads = 1; + options->num_codec_threads = 1; + + void* img; + heif_chroma decodeChroma = _hasAlpha ? heif_chroma.heif_chroma_interleaved_RGBA : heif_chroma.heif_chroma_interleaved_RGB; + heif_error res = heif_decode_image(_container._handle, &img, heif_colorspace.heif_colorspace_RGB, decodeChroma, options); + + heif_decoding_options_free(options); + _container.CheckResult(res); + + int hasChanHeif = heif_image_has_channel(img, chan); + int bppHeif = heif_image_get_bits_per_pixel(img, chan); + if (hasChanHeif == 0 || bppHeif != _bpp) + throw new InvalidDataException("Image did not decode to expected format"); + + // https://github.com/strukturag/libheif/issues/1131 + if (heif_image_get_width(img, chan) != Width || heif_image_get_height(img, chan) != Height) + throw new InvalidDataException("Image did not decode to expected size"); + + int strideout; + _pixels = heif_image_get_plane_readonly(img, chan, &strideout); + _stride = strideout; + _handle = img; + } + + public void Dispose() + { + if (_handle is null) + return; + + heif_image_release(_handle); + _handle = null; + _pixels = null; + } + + public override string ToString() => nameof(HeifPixelSource); + } +} diff --git a/Hi3Helper.MagicScaler.LibheifCL/Hi3Helper.MagicScaler.LibheifCL.csproj b/Hi3Helper.MagicScaler.LibheifCL/Hi3Helper.MagicScaler.LibheifCL.csproj new file mode 100644 index 0000000000..1a028d834f --- /dev/null +++ b/Hi3Helper.MagicScaler.LibheifCL/Hi3Helper.MagicScaler.LibheifCL.csproj @@ -0,0 +1,37 @@ + + + + net10.0 + x64 + Debug;Release + preview + true + enable + portable + true + true + + Hi3Helper.MagicScaler.LibheifCL + Hi3Helper.MagicScaler.LibheifCL + Hi3Helper.MagicScaler.LibheifCL + A temporary library replacing MagicScaler.Libheif's functionality to properly decode AVIF/HEIF images. + Collapse Launcher Team + $(Company). neon-nyan, Cry0, bagusnl, shatyuka, gablm. + Copyright 2022-2026 $(Company) + true + + + + True + + + + False + + + + + + + + diff --git a/Hi3Helper.MagicScaler.LibheifCL/Interop/.editorconfig b/Hi3Helper.MagicScaler.LibheifCL/Interop/.editorconfig new file mode 100644 index 0000000000..ff9e370c77 --- /dev/null +++ b/Hi3Helper.MagicScaler.LibheifCL/Interop/.editorconfig @@ -0,0 +1,8 @@ +[*] +end_of_line = lf +indent_style = space +indent_size = 4 + +[*.cs] +generated_code = true +dotnet_diagnostic.CS0649.severity = silent diff --git a/Hi3Helper.MagicScaler.LibheifCL/Interop/libheif/heif/Libheif.cs b/Hi3Helper.MagicScaler.LibheifCL/Interop/libheif/heif/Libheif.cs new file mode 100644 index 0000000000..451786d818 --- /dev/null +++ b/Hi3Helper.MagicScaler.LibheifCL/Interop/libheif/heif/Libheif.cs @@ -0,0 +1,76 @@ +// Copyright © Clinton Ingram and Contributors +// SPDX-License-Identifier: MIT + +// Ported from libheif headers (heif.h) +// Original source Copyright (c) struktur AG, Dirk Farin +// See third-party-notices in the repository root for more information. + +using System.Runtime.InteropServices; +using static Hi3Helper.MagicScaler.LibheifCL.heif_chroma; +using static Hi3Helper.MagicScaler.LibheifCL.heif_colorspace; + +namespace Hi3Helper.MagicScaler.LibheifCL; + +internal static unsafe partial class Libheif +{ + [DllImport("heif", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + public static extern uint heif_get_version_number(); + + [DllImport("heif", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + public static extern int heif_image_get_width(void* img, heif_channel channel); + + [DllImport("heif", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + public static extern int heif_image_get_height(void* img, heif_channel channel); + + [DllImport("heif", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + public static extern int heif_image_get_bits_per_pixel(void* param0, heif_channel channel); + + [DllImport("heif", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + public static extern int heif_image_has_channel(void* param0, heif_channel channel); + + [DllImport("heif", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + public static extern byte* heif_image_get_plane_readonly(void* param0, heif_channel channel, int* out_stride); + + [DllImport("heif", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + public static extern void heif_image_release(void* param0); + + [DllImport("heif", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + public static extern sbyte* heif_get_file_mime_type(byte* data, int len); + + [DllImport("heif", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + public static extern void* heif_context_alloc(); + + [DllImport("heif", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + public static extern void heif_context_free(void* param0); + + [DllImport("heif", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + public static extern heif_error heif_context_read_from_memory_without_copy(void* param0, nint data, nuint length, nint options); + + [DllImport("heif", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + public static extern heif_error heif_context_get_primary_image_handle(void* ctx, void** param1); + + [DllImport("heif", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + public static extern heif_decoding_options* heif_decoding_options_alloc(); + + [DllImport("heif", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + public static extern void heif_decoding_options_free(heif_decoding_options* param0); + + [DllImport("heif", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + public static extern heif_error heif_decode_image(void* in_handle, void** out_img, heif_colorspace colorspace, heif_chroma chroma, heif_decoding_options* options); + + [DllImport("heif", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + public static extern void heif_image_handle_release(void* param0); + + [DllImport("heif", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + public static extern int heif_image_handle_get_width(void* handle); + + [DllImport("heif", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + public static extern int heif_image_handle_get_height(void* handle); + + [DllImport("heif", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + public static extern int heif_image_handle_has_alpha_channel(void* param0); + + public const int LIBHEIF_NUMERIC_VERSION = ((1 << 24) | (23 << 16) | (5 << 8) | 0); + + public const string LIBHEIF_VERSION = "1.23.5"; +} diff --git a/Hi3Helper.MagicScaler.LibheifCL/Interop/libheif/heif/heif_alpha_composition_mode.cs b/Hi3Helper.MagicScaler.LibheifCL/Interop/libheif/heif/heif_alpha_composition_mode.cs new file mode 100644 index 0000000000..abe8257489 --- /dev/null +++ b/Hi3Helper.MagicScaler.LibheifCL/Interop/libheif/heif/heif_alpha_composition_mode.cs @@ -0,0 +1,15 @@ +// Copyright © Clinton Ingram and Contributors +// SPDX-License-Identifier: MIT + +// Ported from libheif headers (heif.h) +// Original source Copyright (c) struktur AG, Dirk Farin +// See third-party-notices in the repository root for more information. + +namespace Hi3Helper.MagicScaler.LibheifCL; + +internal enum heif_alpha_composition_mode +{ + heif_alpha_composition_mode_none, + heif_alpha_composition_mode_solid_color, + heif_alpha_composition_mode_checkerboard, +} diff --git a/Hi3Helper.MagicScaler.LibheifCL/Interop/libheif/heif/heif_channel.cs b/Hi3Helper.MagicScaler.LibheifCL/Interop/libheif/heif/heif_channel.cs new file mode 100644 index 0000000000..f4f3065b31 --- /dev/null +++ b/Hi3Helper.MagicScaler.LibheifCL/Interop/libheif/heif/heif_channel.cs @@ -0,0 +1,24 @@ +// Copyright © Clinton Ingram and Contributors +// SPDX-License-Identifier: MIT + +// Ported from libheif headers (heif.h) +// Original source Copyright (c) struktur AG, Dirk Farin +// See third-party-notices in the repository root for more information. + +namespace Hi3Helper.MagicScaler.LibheifCL; + +internal enum heif_channel +{ + heif_channel_Y = 0, + heif_channel_Cb = 1, + heif_channel_Cr = 2, + heif_channel_R = 3, + heif_channel_G = 4, + heif_channel_B = 5, + heif_channel_Alpha = 6, + heif_channel_interleaved = 10, + heif_channel_filter_array = 11, + heif_channel_depth = 12, + heif_channel_disparity = 13, + heif_channel_unknown = 65535, +} diff --git a/Hi3Helper.MagicScaler.LibheifCL/Interop/libheif/heif/heif_chroma.cs b/Hi3Helper.MagicScaler.LibheifCL/Interop/libheif/heif/heif_chroma.cs new file mode 100644 index 0000000000..13483d2f60 --- /dev/null +++ b/Hi3Helper.MagicScaler.LibheifCL/Interop/libheif/heif/heif_chroma.cs @@ -0,0 +1,23 @@ +// Copyright © Clinton Ingram and Contributors +// SPDX-License-Identifier: MIT + +// Ported from libheif headers (heif.h) +// Original source Copyright (c) struktur AG, Dirk Farin +// See third-party-notices in the repository root for more information. + +namespace Hi3Helper.MagicScaler.LibheifCL; + +internal enum heif_chroma +{ + heif_chroma_undefined = 99, + heif_chroma_planar = 0, + heif_chroma_420 = 1, + heif_chroma_422 = 2, + heif_chroma_444 = 3, + heif_chroma_interleaved_RGB = 10, + heif_chroma_interleaved_RGBA = 11, + heif_chroma_interleaved_RRGGBB_BE = 12, + heif_chroma_interleaved_RRGGBBAA_BE = 13, + heif_chroma_interleaved_RRGGBB_LE = 14, + heif_chroma_interleaved_RRGGBBAA_LE = 15, +} diff --git a/Hi3Helper.MagicScaler.LibheifCL/Interop/libheif/heif/heif_chroma_downsampling_algorithm.cs b/Hi3Helper.MagicScaler.LibheifCL/Interop/libheif/heif/heif_chroma_downsampling_algorithm.cs new file mode 100644 index 0000000000..edee652fee --- /dev/null +++ b/Hi3Helper.MagicScaler.LibheifCL/Interop/libheif/heif/heif_chroma_downsampling_algorithm.cs @@ -0,0 +1,15 @@ +// Copyright © Clinton Ingram and Contributors +// SPDX-License-Identifier: MIT + +// Ported from libheif headers (heif.h) +// Original source Copyright (c) struktur AG, Dirk Farin +// See third-party-notices in the repository root for more information. + +namespace Hi3Helper.MagicScaler.LibheifCL; + +internal enum heif_chroma_downsampling_algorithm +{ + heif_chroma_downsampling_nearest_neighbor = 1, + heif_chroma_downsampling_average = 2, + heif_chroma_downsampling_sharp_yuv = 3, +} diff --git a/Hi3Helper.MagicScaler.LibheifCL/Interop/libheif/heif/heif_chroma_upsampling_algorithm.cs b/Hi3Helper.MagicScaler.LibheifCL/Interop/libheif/heif/heif_chroma_upsampling_algorithm.cs new file mode 100644 index 0000000000..7fc4c599cb --- /dev/null +++ b/Hi3Helper.MagicScaler.LibheifCL/Interop/libheif/heif/heif_chroma_upsampling_algorithm.cs @@ -0,0 +1,14 @@ +// Copyright © Clinton Ingram and Contributors +// SPDX-License-Identifier: MIT + +// Ported from libheif headers (heif.h) +// Original source Copyright (c) struktur AG, Dirk Farin +// See third-party-notices in the repository root for more information. + +namespace Hi3Helper.MagicScaler.LibheifCL; + +internal enum heif_chroma_upsampling_algorithm +{ + heif_chroma_upsampling_nearest_neighbor = 1, + heif_chroma_upsampling_bilinear = 2, +} diff --git a/Hi3Helper.MagicScaler.LibheifCL/Interop/libheif/heif/heif_color_conversion_options.cs b/Hi3Helper.MagicScaler.LibheifCL/Interop/libheif/heif/heif_color_conversion_options.cs new file mode 100644 index 0000000000..ebfbad848c --- /dev/null +++ b/Hi3Helper.MagicScaler.LibheifCL/Interop/libheif/heif/heif_color_conversion_options.cs @@ -0,0 +1,19 @@ +// Copyright © Clinton Ingram and Contributors +// SPDX-License-Identifier: MIT + +// Ported from libheif headers (heif.h) +// Original source Copyright (c) struktur AG, Dirk Farin +// See third-party-notices in the repository root for more information. + +namespace Hi3Helper.MagicScaler.LibheifCL; + +internal partial struct heif_color_conversion_options +{ + public byte version; + + public heif_chroma_downsampling_algorithm preferred_chroma_downsampling_algorithm; + + public heif_chroma_upsampling_algorithm preferred_chroma_upsampling_algorithm; + + public byte only_use_preferred_chroma_algorithm; +} diff --git a/Hi3Helper.MagicScaler.LibheifCL/Interop/libheif/heif/heif_color_conversion_options_ext.cs b/Hi3Helper.MagicScaler.LibheifCL/Interop/libheif/heif/heif_color_conversion_options_ext.cs new file mode 100644 index 0000000000..2a4a406b3a --- /dev/null +++ b/Hi3Helper.MagicScaler.LibheifCL/Interop/libheif/heif/heif_color_conversion_options_ext.cs @@ -0,0 +1,29 @@ +// Copyright © Clinton Ingram and Contributors +// SPDX-License-Identifier: MIT + +// Ported from libheif headers (heif.h) +// Original source Copyright (c) struktur AG, Dirk Farin +// See third-party-notices in the repository root for more information. + +namespace Hi3Helper.MagicScaler.LibheifCL; + +internal partial struct heif_color_conversion_options_ext +{ + public byte version; + + public heif_alpha_composition_mode alpha_composition_mode; + + public ushort background_red; + + public ushort background_green; + + public ushort background_blue; + + public ushort secondary_background_red; + + public ushort secondary_background_green; + + public ushort secondary_background_blue; + + public ushort checkerboard_square_size; +} diff --git a/Hi3Helper.MagicScaler.LibheifCL/Interop/libheif/heif/heif_color_primaries.cs b/Hi3Helper.MagicScaler.LibheifCL/Interop/libheif/heif/heif_color_primaries.cs new file mode 100644 index 0000000000..806415c820 --- /dev/null +++ b/Hi3Helper.MagicScaler.LibheifCL/Interop/libheif/heif/heif_color_primaries.cs @@ -0,0 +1,24 @@ +// Copyright © Clinton Ingram and Contributors +// SPDX-License-Identifier: MIT + +// Ported from libheif headers (heif.h) +// Original source Copyright (c) struktur AG, Dirk Farin +// See third-party-notices in the repository root for more information. + +namespace Hi3Helper.MagicScaler.LibheifCL; + +internal enum heif_color_primaries +{ + heif_color_primaries_ITU_R_BT_709_5 = 1, + heif_color_primaries_unspecified = 2, + heif_color_primaries_ITU_R_BT_470_6_System_M = 4, + heif_color_primaries_ITU_R_BT_470_6_System_B_G = 5, + heif_color_primaries_ITU_R_BT_601_6 = 6, + heif_color_primaries_SMPTE_240M = 7, + heif_color_primaries_generic_film = 8, + heif_color_primaries_ITU_R_BT_2020_2_and_2100_0 = 9, + heif_color_primaries_SMPTE_ST_428_1 = 10, + heif_color_primaries_SMPTE_RP_431_2 = 11, + heif_color_primaries_SMPTE_EG_432_1 = 12, + heif_color_primaries_EBU_Tech_3213_E = 22, +} diff --git a/Hi3Helper.MagicScaler.LibheifCL/Interop/libheif/heif/heif_color_profile_nclx.cs b/Hi3Helper.MagicScaler.LibheifCL/Interop/libheif/heif/heif_color_profile_nclx.cs new file mode 100644 index 0000000000..68e832c3ac --- /dev/null +++ b/Hi3Helper.MagicScaler.LibheifCL/Interop/libheif/heif/heif_color_profile_nclx.cs @@ -0,0 +1,37 @@ +// Copyright © Clinton Ingram and Contributors +// SPDX-License-Identifier: MIT + +// Ported from libheif headers (heif.h) +// Original source Copyright (c) struktur AG, Dirk Farin +// See third-party-notices in the repository root for more information. + +namespace Hi3Helper.MagicScaler.LibheifCL; + +internal partial struct heif_color_profile_nclx +{ + public byte version; + + public heif_color_primaries color_primaries; + + public heif_transfer_characteristics transfer_characteristics; + + public heif_matrix_coefficients matrix_coefficients; + + public byte full_range_flag; + + public float color_primary_red_x; + + public float color_primary_red_y; + + public float color_primary_green_x; + + public float color_primary_green_y; + + public float color_primary_blue_x; + + public float color_primary_blue_y; + + public float color_primary_white_x; + + public float color_primary_white_y; +} diff --git a/Hi3Helper.MagicScaler.LibheifCL/Interop/libheif/heif/heif_colorspace.cs b/Hi3Helper.MagicScaler.LibheifCL/Interop/libheif/heif/heif_colorspace.cs new file mode 100644 index 0000000000..eb97f4d41e --- /dev/null +++ b/Hi3Helper.MagicScaler.LibheifCL/Interop/libheif/heif/heif_colorspace.cs @@ -0,0 +1,18 @@ +// Copyright © Clinton Ingram and Contributors +// SPDX-License-Identifier: MIT + +// Ported from libheif headers (heif.h) +// Original source Copyright (c) struktur AG, Dirk Farin +// See third-party-notices in the repository root for more information. + +namespace Hi3Helper.MagicScaler.LibheifCL; + +internal enum heif_colorspace +{ + heif_colorspace_undefined = 99, + heif_colorspace_YCbCr = 0, + heif_colorspace_RGB = 1, + heif_colorspace_monochrome = 2, + heif_colorspace_custom = 3, + heif_colorspace_filter_array = 4, +} diff --git a/Hi3Helper.MagicScaler.LibheifCL/Interop/libheif/heif/heif_decoding_options.cs b/Hi3Helper.MagicScaler.LibheifCL/Interop/libheif/heif/heif_decoding_options.cs new file mode 100644 index 0000000000..4863901576 --- /dev/null +++ b/Hi3Helper.MagicScaler.LibheifCL/Interop/libheif/heif/heif_decoding_options.cs @@ -0,0 +1,47 @@ +// Copyright © Clinton Ingram and Contributors +// SPDX-License-Identifier: MIT + +// Ported from libheif headers (heif.h) +// Original source Copyright (c) struktur AG, Dirk Farin +// See third-party-notices in the repository root for more information. + +namespace Hi3Helper.MagicScaler.LibheifCL; + +internal unsafe partial struct heif_decoding_options +{ + public byte version; + + public byte ignore_transformations; + + public delegate* unmanaged[Cdecl] start_progress; + + public delegate* unmanaged[Cdecl] on_progress; + + public delegate* unmanaged[Cdecl] end_progress; + + public void* progress_user_data; + + public byte convert_hdr_to_8bit; + + public byte strict_decoding; + + public sbyte* decoder_id; + + public heif_color_conversion_options color_conversion_options; + + public delegate* unmanaged[Cdecl] cancel_decoding; + + public heif_color_conversion_options_ext* color_conversion_options_ext; + + public int ignore_sequence_editlist; + + public heif_color_profile_nclx* output_image_nclx_profile; + + public int num_library_threads; + + public int num_codec_threads; + + public byte autocorrect_broken_input; + + public byte output_image_nclx_profile_passthrough; +} diff --git a/Hi3Helper.MagicScaler.LibheifCL/Interop/libheif/heif/heif_error.cs b/Hi3Helper.MagicScaler.LibheifCL/Interop/libheif/heif/heif_error.cs new file mode 100644 index 0000000000..864eb90fcb --- /dev/null +++ b/Hi3Helper.MagicScaler.LibheifCL/Interop/libheif/heif/heif_error.cs @@ -0,0 +1,17 @@ +// Copyright © Clinton Ingram and Contributors +// SPDX-License-Identifier: MIT + +// Ported from libheif headers (heif.h) +// Original source Copyright (c) struktur AG, Dirk Farin +// See third-party-notices in the repository root for more information. + +namespace Hi3Helper.MagicScaler.LibheifCL; + +internal unsafe partial struct heif_error +{ + public heif_error_code code; + + public heif_suberror_code subcode; + + public sbyte* message; +} diff --git a/Hi3Helper.MagicScaler.LibheifCL/Interop/libheif/heif/heif_error_code.cs b/Hi3Helper.MagicScaler.LibheifCL/Interop/libheif/heif/heif_error_code.cs new file mode 100644 index 0000000000..f7af62210d --- /dev/null +++ b/Hi3Helper.MagicScaler.LibheifCL/Interop/libheif/heif/heif_error_code.cs @@ -0,0 +1,26 @@ +// Copyright © Clinton Ingram and Contributors +// SPDX-License-Identifier: MIT + +// Ported from libheif headers (heif.h) +// Original source Copyright (c) struktur AG, Dirk Farin +// See third-party-notices in the repository root for more information. + +namespace Hi3Helper.MagicScaler.LibheifCL; + +internal enum heif_error_code +{ + heif_error_Ok = 0, + heif_error_Input_does_not_exist = 1, + heif_error_Invalid_input = 2, + heif_error_Unsupported_filetype = 3, + heif_error_Unsupported_feature = 4, + heif_error_Usage_error = 5, + heif_error_Memory_allocation_error = 6, + heif_error_Decoder_plugin_error = 7, + heif_error_Encoder_plugin_error = 8, + heif_error_Encoding_error = 9, + heif_error_Color_profile_does_not_exist = 10, + heif_error_Plugin_loading_error = 11, + heif_error_Canceled = 12, + heif_error_End_of_sequence = 13, +} diff --git a/Hi3Helper.MagicScaler.LibheifCL/Interop/libheif/heif/heif_matrix_coefficients.cs b/Hi3Helper.MagicScaler.LibheifCL/Interop/libheif/heif/heif_matrix_coefficients.cs new file mode 100644 index 0000000000..0109573edb --- /dev/null +++ b/Hi3Helper.MagicScaler.LibheifCL/Interop/libheif/heif/heif_matrix_coefficients.cs @@ -0,0 +1,26 @@ +// Copyright © Clinton Ingram and Contributors +// SPDX-License-Identifier: MIT + +// Ported from libheif headers (heif.h) +// Original source Copyright (c) struktur AG, Dirk Farin +// See third-party-notices in the repository root for more information. + +namespace Hi3Helper.MagicScaler.LibheifCL; + +internal enum heif_matrix_coefficients +{ + heif_matrix_coefficients_RGB_GBR = 0, + heif_matrix_coefficients_ITU_R_BT_709_5 = 1, + heif_matrix_coefficients_unspecified = 2, + heif_matrix_coefficients_US_FCC_T47 = 4, + heif_matrix_coefficients_ITU_R_BT_470_6_System_B_G = 5, + heif_matrix_coefficients_ITU_R_BT_601_6 = 6, + heif_matrix_coefficients_SMPTE_240M = 7, + heif_matrix_coefficients_YCgCo = 8, + heif_matrix_coefficients_ITU_R_BT_2020_2_non_constant_luminance = 9, + heif_matrix_coefficients_ITU_R_BT_2020_2_constant_luminance = 10, + heif_matrix_coefficients_SMPTE_ST_2085 = 11, + heif_matrix_coefficients_chromaticity_derived_non_constant_luminance = 12, + heif_matrix_coefficients_chromaticity_derived_constant_luminance = 13, + heif_matrix_coefficients_ICtCp = 14, +} diff --git a/Hi3Helper.MagicScaler.LibheifCL/Interop/libheif/heif/heif_orientation.cs b/Hi3Helper.MagicScaler.LibheifCL/Interop/libheif/heif/heif_orientation.cs new file mode 100644 index 0000000000..fe883d9c20 --- /dev/null +++ b/Hi3Helper.MagicScaler.LibheifCL/Interop/libheif/heif/heif_orientation.cs @@ -0,0 +1,20 @@ +// Copyright © Clinton Ingram and Contributors +// SPDX-License-Identifier: MIT + +// Ported from libheif headers (heif.h) +// Original source Copyright (c) struktur AG, Dirk Farin +// See third-party-notices in the repository root for more information. + +namespace Hi3Helper.MagicScaler.LibheifCL; + +internal enum heif_orientation +{ + heif_orientation_normal = 1, + heif_orientation_flip_horizontally = 2, + heif_orientation_rotate_180 = 3, + heif_orientation_flip_vertically = 4, + heif_orientation_rotate_90_cw_then_flip_horizontally = 5, + heif_orientation_rotate_90_cw = 6, + heif_orientation_rotate_90_cw_then_flip_vertically = 7, + heif_orientation_rotate_270_cw = 8, +} diff --git a/Hi3Helper.MagicScaler.LibheifCL/Interop/libheif/heif/heif_progress_step.cs b/Hi3Helper.MagicScaler.LibheifCL/Interop/libheif/heif/heif_progress_step.cs new file mode 100644 index 0000000000..02193bbddd --- /dev/null +++ b/Hi3Helper.MagicScaler.LibheifCL/Interop/libheif/heif/heif_progress_step.cs @@ -0,0 +1,14 @@ +// Copyright © Clinton Ingram and Contributors +// SPDX-License-Identifier: MIT + +// Ported from libheif headers (heif.h) +// Original source Copyright (c) struktur AG, Dirk Farin +// See third-party-notices in the repository root for more information. + +namespace Hi3Helper.MagicScaler.LibheifCL; + +internal enum heif_progress_step +{ + heif_progress_step_total = 0, + heif_progress_step_load_tile = 1, +} diff --git a/Hi3Helper.MagicScaler.LibheifCL/Interop/libheif/heif/heif_security_limits.cs b/Hi3Helper.MagicScaler.LibheifCL/Interop/libheif/heif/heif_security_limits.cs new file mode 100644 index 0000000000..804fe1ad77 --- /dev/null +++ b/Hi3Helper.MagicScaler.LibheifCL/Interop/libheif/heif/heif_security_limits.cs @@ -0,0 +1,49 @@ +// Copyright © Clinton Ingram and Contributors +// SPDX-License-Identifier: MIT + +// Ported from libheif headers (heif.h) +// Original source Copyright (c) struktur AG, Dirk Farin +// See third-party-notices in the repository root for more information. + +namespace Hi3Helper.MagicScaler.LibheifCL; + +internal partial struct heif_security_limits +{ + public byte version; + + public ulong max_image_size_pixels; + + public ulong max_number_of_tiles; + + public uint max_bayer_pattern_pixels; + + public uint max_items; + + public uint max_color_profile_size; + + public ulong max_memory_block_size; + + public uint max_components; + + public uint max_iloc_extents_per_item; + + public uint max_size_entity_group; + + public uint max_children_per_box; + + public ulong max_total_memory; + + public uint max_sample_description_box_entries; + + public uint max_sample_group_description_box_entries; + + public uint max_sequence_frames; + + public uint max_number_of_file_brands; + + public uint max_bad_pixels; + + public uint max_iso23001_17_pixel_size_bytes; + + public heif_security_limits* parent; +} diff --git a/Hi3Helper.MagicScaler.LibheifCL/Interop/libheif/heif/heif_suberror_code.cs b/Hi3Helper.MagicScaler.LibheifCL/Interop/libheif/heif/heif_suberror_code.cs new file mode 100644 index 0000000000..72e0b77062 --- /dev/null +++ b/Hi3Helper.MagicScaler.LibheifCL/Interop/libheif/heif/heif_suberror_code.cs @@ -0,0 +1,91 @@ +// Copyright © Clinton Ingram and Contributors +// SPDX-License-Identifier: MIT + +// Ported from libheif headers (heif.h) +// Original source Copyright (c) struktur AG, Dirk Farin +// See third-party-notices in the repository root for more information. + +namespace Hi3Helper.MagicScaler.LibheifCL; + +internal enum heif_suberror_code +{ + heif_suberror_Unspecified = 0, + heif_suberror_End_of_data = 100, + heif_suberror_Invalid_box_size = 101, + heif_suberror_No_ftyp_box = 102, + heif_suberror_No_idat_box = 103, + heif_suberror_No_meta_box = 104, + heif_suberror_No_hdlr_box = 105, + heif_suberror_No_hvcC_box = 106, + heif_suberror_No_pitm_box = 107, + heif_suberror_No_ipco_box = 108, + heif_suberror_No_ipma_box = 109, + heif_suberror_No_iloc_box = 110, + heif_suberror_No_iinf_box = 111, + heif_suberror_No_iprp_box = 112, + heif_suberror_No_iref_box = 113, + heif_suberror_No_pict_handler = 114, + heif_suberror_Ipma_box_references_nonexisting_property = 115, + heif_suberror_No_properties_assigned_to_item = 116, + heif_suberror_No_item_data = 117, + heif_suberror_Invalid_grid_data = 118, + heif_suberror_Missing_grid_images = 119, + heif_suberror_Invalid_clean_aperture = 120, + heif_suberror_Invalid_overlay_data = 121, + heif_suberror_Overlay_image_outside_of_canvas = 122, + heif_suberror_Auxiliary_image_type_unspecified = 123, + heif_suberror_No_or_invalid_primary_item = 124, + heif_suberror_No_infe_box = 125, + heif_suberror_Unknown_color_profile_type = 126, + heif_suberror_Wrong_tile_image_chroma_format = 127, + heif_suberror_Invalid_fractional_number = 128, + heif_suberror_Invalid_image_size = 129, + heif_suberror_Invalid_pixi_box = 130, + heif_suberror_No_av1C_box = 131, + heif_suberror_Wrong_tile_image_pixel_depth = 132, + heif_suberror_Unknown_NCLX_color_primaries = 133, + heif_suberror_Unknown_NCLX_transfer_characteristics = 134, + heif_suberror_Unknown_NCLX_matrix_coefficients = 135, + heif_suberror_Invalid_region_data = 136, + heif_suberror_No_ispe_property = 137, + heif_suberror_Camera_intrinsic_matrix_undefined = 138, + heif_suberror_Camera_extrinsic_matrix_undefined = 139, + heif_suberror_Invalid_J2K_codestream = 140, + heif_suberror_No_vvcC_box = 141, + heif_suberror_No_icbr_box = 142, + heif_suberror_No_avcC_box = 143, + heif_suberror_Invalid_mini_box = 149, + heif_suberror_Decompression_invalid_data = 150, + heif_suberror_No_moov_box = 151, + heif_suberror_NCLX_colr_VUI_mismatch = 152, + heif_suberror_Security_limit_exceeded = 1000, + heif_suberror_Compression_initialisation_error = 1001, + heif_suberror_Nonexisting_item_referenced = 2000, + heif_suberror_Null_pointer_argument = 2001, + heif_suberror_Nonexisting_image_channel_referenced = 2002, + heif_suberror_Unsupported_plugin_version = 2003, + heif_suberror_Unsupported_writer_version = 2004, + heif_suberror_Unsupported_parameter = 2005, + heif_suberror_Invalid_parameter_value = 2006, + heif_suberror_Invalid_property = 2007, + heif_suberror_Item_reference_cycle = 2008, + heif_suberror_Unsupported_codec = 3000, + heif_suberror_Unsupported_image_type = 3001, + heif_suberror_Unsupported_data_version = 3002, + heif_suberror_Unsupported_color_conversion = 3003, + heif_suberror_Unsupported_item_construction_method = 3004, + heif_suberror_Unsupported_header_compression_method = 3005, + heif_suberror_Unsupported_generic_compression_method = 3006, + heif_suberror_Unsupported_essential_property = 3007, + heif_suberror_Unsupported_track_type = 3008, + heif_suberror_Unsupported_bit_depth = 4000, + heif_suberror_Cannot_write_output_data = 5000, + heif_suberror_Encoder_initialization = 5001, + heif_suberror_Encoder_encoding = 5002, + heif_suberror_Encoder_cleanup = 5003, + heif_suberror_Too_many_regions = 5004, + heif_suberror_Plugin_loading_error = 6000, + heif_suberror_Plugin_is_not_loaded = 6001, + heif_suberror_Cannot_read_plugin_directory = 6002, + heif_suberror_No_matching_decoder_installed = 6003, +} diff --git a/Hi3Helper.MagicScaler.LibheifCL/Interop/libheif/heif/heif_transfer_characteristics.cs b/Hi3Helper.MagicScaler.LibheifCL/Interop/libheif/heif/heif_transfer_characteristics.cs new file mode 100644 index 0000000000..826e79f2a5 --- /dev/null +++ b/Hi3Helper.MagicScaler.LibheifCL/Interop/libheif/heif/heif_transfer_characteristics.cs @@ -0,0 +1,29 @@ +// Copyright © Clinton Ingram and Contributors +// SPDX-License-Identifier: MIT + +// Ported from libheif headers (heif.h) +// Original source Copyright (c) struktur AG, Dirk Farin +// See third-party-notices in the repository root for more information. + +namespace Hi3Helper.MagicScaler.LibheifCL; + +internal enum heif_transfer_characteristics +{ + heif_transfer_characteristic_ITU_R_BT_709_5 = 1, + heif_transfer_characteristic_unspecified = 2, + heif_transfer_characteristic_ITU_R_BT_470_6_System_M = 4, + heif_transfer_characteristic_ITU_R_BT_470_6_System_B_G = 5, + heif_transfer_characteristic_ITU_R_BT_601_6 = 6, + heif_transfer_characteristic_SMPTE_240M = 7, + heif_transfer_characteristic_linear = 8, + heif_transfer_characteristic_logarithmic_100 = 9, + heif_transfer_characteristic_logarithmic_100_sqrt10 = 10, + heif_transfer_characteristic_IEC_61966_2_4 = 11, + heif_transfer_characteristic_ITU_R_BT_1361 = 12, + heif_transfer_characteristic_IEC_61966_2_1 = 13, + heif_transfer_characteristic_ITU_R_BT_2020_2_10bit = 14, + heif_transfer_characteristic_ITU_R_BT_2020_2_12bit = 15, + heif_transfer_characteristic_ITU_R_BT_2100_0_PQ = 16, + heif_transfer_characteristic_SMPTE_ST_428_1 = 17, + heif_transfer_characteristic_ITU_R_BT_2100_0_HLG = 18, +} diff --git a/Hi3Helper.MagicScaler.LibheifCL/StreamWrapper.cs b/Hi3Helper.MagicScaler.LibheifCL/StreamWrapper.cs new file mode 100644 index 0000000000..7f98ddf4c8 --- /dev/null +++ b/Hi3Helper.MagicScaler.LibheifCL/StreamWrapper.cs @@ -0,0 +1,123 @@ +using System; +using System.IO; +using System.Runtime.InteropServices; +using System.Threading; + +namespace Hi3Helper.MagicScaler.LibheifCL; + +internal sealed unsafe class StreamWrapper : IDisposable +{ + public const int MaxEncodedBytes = 128 * 1024 * 1024; + private const int InitialCapacity = 64 * 1024; + + private nint _buffer; + + private StreamWrapper(nint buffer, uint length) + { + _buffer = buffer; + Length = length; + } + + public uint Length { get; } + + public nint Buffer => _buffer; + + public ReadOnlySpan Span + { + get + { + if (_buffer == nint.Zero) + throw new ObjectDisposedException(nameof(StreamWrapper)); + + return new ReadOnlySpan((void*)_buffer, checked((int)Length)); + } + } + + public static StreamWrapper Read(Stream stream) + { + ArgumentNullException.ThrowIfNull(stream); + + // Preserve the previous behavior for seekable streams. Non-seekable + // streams are read from their current position. + if (stream.CanSeek) + stream.Seek(0, SeekOrigin.Begin); + + nuint capacity = InitialCapacity; + byte* buffer = (byte*)NativeMemory.Alloc(capacity); + + if (buffer is null) + throw new OutOfMemoryException(); + + int length = 0; + + try + { + while (true) + { + if ((nuint)length == capacity) + { + if (capacity >= MaxEncodedBytes) + { + Span overflow = stackalloc byte[1]; + int overflowRead = stream.Read(overflow); + + if (overflowRead == 0) + break; + + if (overflowRead != 1) + { + throw new COMException("Invalid stream read count."); + } + + throw new COMException("Encoded image exceeds the sample limit."); + } + + nuint newCapacity = Math.Min(capacity * 2, MaxEncodedBytes); + byte* resized = (byte*)NativeMemory.Realloc(buffer, newCapacity); + if (resized is null) throw new OutOfMemoryException(); + + buffer = resized; + capacity = newCapacity; + } + + int available = checked((int)(capacity - (nuint)length)); + + Span destination = new(buffer + length, available); + int read = stream.Read(destination); + + // Protect against incorrectly implemented custom streams. + if ((uint)read > (uint)available) + { + throw new COMException("Invalid stream read count."); + } + + if (read == 0) + break; + + length = checked(length + read); + } + + return new StreamWrapper((nint)buffer, checked((uint)length)); + } + catch + { + NativeMemory.Free(buffer); + throw; + } + } + + public void Dispose() + { + nint buffer = Interlocked.Exchange(ref _buffer, nint.Zero); + + if (buffer != nint.Zero) NativeMemory.Free((void*)buffer); + GC.SuppressFinalize(this); + } + + ~StreamWrapper() + { + nint buffer = Interlocked.Exchange(ref _buffer, nint.Zero); + if (buffer != nint.Zero) + NativeMemory.Free((void*)buffer); + } +} \ No newline at end of file diff --git a/Hi3Helper.MagicScaler.LibheifCL/packages.lock.json b/Hi3Helper.MagicScaler.LibheifCL/packages.lock.json new file mode 100644 index 0000000000..6aec242ebb --- /dev/null +++ b/Hi3Helper.MagicScaler.LibheifCL/packages.lock.json @@ -0,0 +1,28 @@ +{ + "version": 1, + "dependencies": { + "net10.0": { + "Microsoft.NET.ILLink.Tasks": { + "type": "Direct", + "requested": "[10.0.13, )", + "resolved": "10.0.13", + "contentHash": "40yrBMsLEcjixBR4G+FiUaBPvxXIPSKJ2OgSrbOZcmSDBda4Sb0k7BWrfDpIvn3IvvDzwOL/tEPa6gDQfh6aRw==" + }, + "PhotoSauce.MagicScaler": { + "type": "Direct", + "requested": "[0.15.0, )", + "resolved": "0.15.0", + "contentHash": "ajPOdS5wGIZrHGT0NHvmmrQ7sJb0Vp+Ex3JauD7Ru+U+F+N7tTYRVDcvXW2ER56MLzC+VSqK3KjRn4Sntkf3vA==" + }, + "PhotoSauce.NativeCodecs.Libheif": { + "type": "Direct", + "requested": "[1.23.5-preview1, )", + "resolved": "1.23.5-preview1", + "contentHash": "hjIqlOeqX6mMeVs1eBNQi9xgvkZEtQBWC0nU8ytGtW3rzidQlnW2TFvFcU2ZWkTszgwZh8xq9yViLFHcAYW2Lg==", + "dependencies": { + "PhotoSauce.MagicScaler": "[0.15.0]" + } + } + } + } +} \ No newline at end of file diff --git a/Hi3Helper.MagicScaler.LibheifCL/readme.md b/Hi3Helper.MagicScaler.LibheifCL/readme.md new file mode 100644 index 0000000000..d9deb98f2c --- /dev/null +++ b/Hi3Helper.MagicScaler.LibheifCL/readme.md @@ -0,0 +1,35 @@ +PhotoSauce.NativeCodecs.Libheif +=============================== + +This MagicScaler plugin wraps the [libheif](https://github.com/strukturag/libheif) codec for [HEIC](https://en.wikipedia.org/wiki/High_Efficiency_Image_File_Format) images, such as those created by Apple devices running iOS 11 or later, and [AVIF](https://en.wikipedia.org/wiki/AVIF) images. This plugin supports only decode, using the [libde265](https://github.com/strukturag/libde265) HEVC decoder and the [dav1d](https://code.videolan.org/videolan/dav1d) AV1 decoder. + +Be aware that the HEVC video coding standard used in HEIC images is burdened by [numerous patents](https://en.wikipedia.org/wiki/High_Efficiency_Video_Coding#Patent_licensing) which may vary in validity by country. + +*By using this plugin, you acknowledge that your use of the codec is permitted royalty free or that you have purchased an appropriate license.* + +Windows 10 and 11 include a HEIF codec by default, but it may not function for HEIC or AVIF images without purchase/installation of a video codec extension from the Microsoft Store, and it may not function for all users or apps. This plugin is meant to be used when the Windows codec is not available or does not work. + +Requirements +------------ + +A compatible `libheif` binary must be present for this plugin to function. For convenience, the NuGet package includes native binaries for Windows (x86, x64, and ARM64) and Linux (glibc x64 and ARM64). + +Usage +----- + +### Codec Registration + +To register the codec, call the `UseLibheif` extension method from your `CodecManager.Configure` action at app startup. By default, the plugin will remove/replace the Windows HEIF codec if it is present. + +```C# +using PhotoSauce.MagicScaler; +using PhotoSauce.NativeCodecs.Libheif; + +CodecManager.Configure(codecs => { + codecs.UseLibheif(); +}); +``` + +### Using the Codec + +Once registered, the codec will automatically detect and decode compatible images.