diff --git a/BackgroundTest/App.xaml b/BackgroundTest/App.xaml new file mode 100644 index 000000000..49b3b0664 --- /dev/null +++ b/BackgroundTest/App.xaml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/BackgroundTest/App.xaml.cs b/BackgroundTest/App.xaml.cs new file mode 100644 index 000000000..2f23bab59 --- /dev/null +++ b/BackgroundTest/App.xaml.cs @@ -0,0 +1,23 @@ +using Microsoft.UI.Xaml; + +// ReSharper disable SwitchStatementMissingSomeEnumCasesNoDefault +// ReSharper disable CommentTypo +// ReSharper disable StringLiteralTypo + +namespace BackgroundTest; + +public partial class App +{ + private TestWindow? _window; + + public App() + { + InitializeComponent(); + } + + protected override void OnLaunched(LaunchActivatedEventArgs args) + { + _window = new TestWindow(); + _window.Activate(); + } +} diff --git a/BackgroundTest/BackgroundTest.csproj b/BackgroundTest/BackgroundTest.csproj new file mode 100644 index 000000000..f68974757 --- /dev/null +++ b/BackgroundTest/BackgroundTest.csproj @@ -0,0 +1,104 @@ + + + + WinExe + BackgroundTest.MainEntryPoint + Debug;Release + + BackgroundTest + Collapse + Collapse + Collapse + Collapse + Collapse Launcher Team + $(Company). neon-nyan, Cry0, bagusnl, shatyuka, gablm. + Copyright 2022-2025 $(Company) + + 1.83.12 + preview + enable + + x64 + net10.0-windows10.0.26100.0 + 10.0.17763.0 + win-x64 + true + + portable + false + + true + true + + true + true + false + false + + true + true + false + true + false + true + true + false + false + true + + + + Exe + + + DISABLE_XAML_GENERATED_MAIN;ENABLEUSERFEEDBACK;USEVELOPACK;USENEWZIPDECOMPRESS;ENABLEHTTPREPAIR;PREVIEW;DUMPGIJSON;SIMULATEGIHDR;GSPBYPASSGAMERUNNING;MHYPLUGINSUPPORT + full + + + + + DISABLE_XAML_GENERATED_MAIN;ENABLEUSERFEEDBACK;USEVELOPACK;USENEWZIPDECOMPRESS;ENABLEHTTPREPAIR;PREVIEW;MHYPLUGINSUPPORT + True + true + + + + + DISABLE_XAML_GENERATED_MAIN;ENABLEUSERFEEDBACK;USEVELOPACK;USENEWZIPDECOMPRESS;ENABLEHTTPREPAIR;MHYPLUGINSUPPORT + true + true + + + + + Always + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/BackgroundTest/CustomControl/BGTestImage1.jpg b/BackgroundTest/CustomControl/BGTestImage1.jpg new file mode 100644 index 000000000..72052793d Binary files /dev/null and b/BackgroundTest/CustomControl/BGTestImage1.jpg differ diff --git a/BackgroundTest/CustomControl/BGTestImage2.jpg b/BackgroundTest/CustomControl/BGTestImage2.jpg new file mode 100644 index 000000000..c261b608f Binary files /dev/null and b/BackgroundTest/CustomControl/BGTestImage2.jpg differ diff --git a/BackgroundTest/CustomControl/BGTestImage3.jpg b/BackgroundTest/CustomControl/BGTestImage3.jpg new file mode 100644 index 000000000..4d3c179f0 Binary files /dev/null and b/BackgroundTest/CustomControl/BGTestImage3.jpg differ diff --git a/BackgroundTest/CustomControl/BGTestImage4.jpg b/BackgroundTest/CustomControl/BGTestImage4.jpg new file mode 100644 index 000000000..0f04eeeb4 Binary files /dev/null and b/BackgroundTest/CustomControl/BGTestImage4.jpg differ diff --git a/BackgroundTest/CustomControl/ChangedStructItemArgs.cs b/BackgroundTest/CustomControl/ChangedStructItemArgs.cs new file mode 100644 index 000000000..714c9f579 --- /dev/null +++ b/BackgroundTest/CustomControl/ChangedStructItemArgs.cs @@ -0,0 +1,15 @@ +namespace BackgroundTest.CustomControl; + +public readonly struct ChangedStructItemArgs(TItem oldItem, TItem newItem) + where TItem : struct +{ + public TItem OldItem { get; private init; } = oldItem; + public TItem NewItem { get; private init; } = newItem; +} + +public class ChangedObjectItemArgs(TItem? oldItem, TItem? newItem) + where TItem : class +{ + public TItem? OldItem { get; private init; } = oldItem; + public TItem? NewItem { get; private init; } = newItem; +} \ No newline at end of file diff --git a/BackgroundTest/CustomControl/Extensions.cs b/BackgroundTest/CustomControl/Extensions.cs new file mode 100644 index 000000000..7c8055bc5 --- /dev/null +++ b/BackgroundTest/CustomControl/Extensions.cs @@ -0,0 +1,110 @@ +using Microsoft.UI.Input; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Data; +using System; +using System.IO; +using System.Runtime.CompilerServices; + +namespace BackgroundTest.CustomControl; + +public static class Extensions +{ + public static readonly DependencyProperty CursorTypeProperty = + DependencyProperty.RegisterAttached("CursorType", typeof(InputSystemCursorShape), + typeof(UIElement), new PropertyMetadata(InputSystemCursorShape.Arrow)); + + public static InputSystemCursorShape GetCursorType(DependencyObject obj) => (InputSystemCursorShape)obj.GetValue(CursorTypeProperty); + + public static void SetCursorType(DependencyObject obj, InputSystemCursorShape value) + { + InputSystemCursor? cursor = InputSystemCursor.Create(value); + if (cursor is null || + obj is not UIElement asElement) + { + return; + } + + asElement.SetCursor(cursor); + asElement.SetValue(CursorTypeProperty, value); + } + + extension(Control source) + { + [UnsafeAccessor(UnsafeAccessorKind.Method, Name = "GetTemplateChild")] + private extern DependencyObject GetTemplateChildAccessor(string name); + + internal T GetTemplateChild(string name) + where T : class + { + DependencyObject obj = source.GetTemplateChildAccessor(name); + if (obj is not T castObj) + { + throw new + InvalidCastException($"Cannot cast type to: {typeof(T).Name} as the object expects type: {obj.GetType().Name}"); + } + + return castObj; + } + } + + /// The member of an element + extension(UIElement element) + { + /// + /// Set the cursor for the element. + /// + /// The cursor you want to set. Use to choose the cursor you want to set. + [UnsafeAccessor(UnsafeAccessorKind.Method, Name = "set_ProtectedCursor")] + private extern void SetCursor(InputCursor inputCursor); + } + + internal static double TryGetDouble(this object? obj) + { + return obj switch + { + sbyte asSbyte => asSbyte, + byte asByte => asByte, + ushort asUshort => asUshort, + short asShort => asShort, + uint asUint => asUint, + int asInt => asInt, + ulong asUlong => asUlong, + long asLong => asLong, + float asFloat => asFloat, + double asDouble => asDouble, + _ => double.NaN + }; + } + + internal static void BindProperty(this FrameworkElement element, + FrameworkElement source, + string propertyName, + DependencyProperty dependencyProperty, + BindingMode bindingMode) + { + element.SetBinding(dependencyProperty, new Binding + { + Mode = bindingMode, + Source = source, + Path = new PropertyPath(propertyName) + }); + } + + internal static Uri GetStringAsUri(this string asStringSource) + { + // Try to create URL with absolute path. + // If not (assume it's a relative local path), then try to get the fully qualified local path. + if (Uri.TryCreate(asStringSource, UriKind.Absolute, out Uri? sourceUri) || + Path.IsPathFullyQualified(asStringSource)) + { + return sourceUri ?? new Uri(asStringSource); + } + + string currentWorkingDir = Directory.GetCurrentDirectory(); + ReadOnlySpan asStringSourceSpan = asStringSource.Trim("/\\"); + asStringSource = Path.Join(currentWorkingDir, asStringSourceSpan); + + return sourceUri ?? new Uri(asStringSource); + } +} diff --git a/BackgroundTest/CustomControl/LayeredBackgroundImage/IMediaCacheHandler.cs b/BackgroundTest/CustomControl/LayeredBackgroundImage/IMediaCacheHandler.cs new file mode 100644 index 000000000..29ba317bf --- /dev/null +++ b/BackgroundTest/CustomControl/LayeredBackgroundImage/IMediaCacheHandler.cs @@ -0,0 +1,39 @@ +using System; +using System.IO; +using System.Threading.Tasks; + +namespace BackgroundTest.CustomControl.LayeredBackgroundImage; + +/// +/// An interface which implements media cache handler for loading cached media sources. +/// +public interface IMediaCacheHandler +{ + /// + /// Loads the media source from cached local path or URL or Seekable and Readable . + /// + /// The input source to be cached. + /// An instance of which defines the cached source and its properties. + Task LoadCachedSource(object? sourceObject); +} + +/// +/// Contains the result and properties of the cached media source. +/// +public class MediaCacheResult +{ + /// + /// Indicates whether to force using the WIC (Windows Imaging Component) decoder for image source or not. + /// + public bool ForceUseInternalDecoder { get; set; } + + /// + /// The source of the media. Only or for local path and URL, or Seekable and Readable types are supported. + /// + public object? CachedSource { get; set; } + + /// + /// Whether to perform Dispose if is a type. + /// + public bool DisposeStream { get; set; } +} diff --git a/BackgroundTest/CustomControl/LayeredBackgroundImage/LayeredBackgroundImage.Events.FrameRenderer.cs b/BackgroundTest/CustomControl/LayeredBackgroundImage/LayeredBackgroundImage.Events.FrameRenderer.cs new file mode 100644 index 000000000..a1af4b223 --- /dev/null +++ b/BackgroundTest/CustomControl/LayeredBackgroundImage/LayeredBackgroundImage.Events.FrameRenderer.cs @@ -0,0 +1,283 @@ +using Hi3Helper.Win32.ManagedTools; +using Hi3Helper.Win32.Native.Interfaces.DXGI; +using Hi3Helper.Win32.Native.Structs; +using Hi3Helper.Win32.WinRT.SwapChainPanelHelper; +using Microsoft.Graphics.Canvas; +using Microsoft.UI.Dispatching; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Media.Imaging; +using System; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Threading; +using Windows.Media.Playback; +using WinRT; + +namespace BackgroundTest.CustomControl.LayeredBackgroundImage; + +public partial class LayeredBackgroundImage +{ + #region Properties + + // ReSharper disable once InconsistentNaming + private static ref readonly Guid IMediaPlayer_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)); + } + } + + #endregion + + #region Fields + + private volatile int _isBlockVideoFrameDraw = 1; + private int _isVideoFrameDrawInProgress; + private TimeSpan _lastVideoPlayerPosition; + + #endregion + + #region Video Frame Drawing + + private void VideoPlayer_VideoFrameAvailable(MediaPlayer sender, object args) + { + if (_canvasSurfaceImageSourceNative == null) + { + return; + } + + if (_isBlockVideoFrameDraw == 1 || + Interlocked.Exchange(ref _isVideoFrameDrawInProgress, 1) == 1) + { + return; + } + + DispatcherQueue.TryEnqueue(DispatcherQueuePriority.High, DrawFrame); + return; + + void DrawFrame() + { + try + { + SwapChainPanelHelper.NativeSurfaceImageSource_BeginDrawUnsafe(_canvasSurfaceImageSourceNativePtr, in _canvasRenderArea, out nint surfacePpv); + SwapChainPanelHelper.MediaPlayer_CopyFrameToVideoSurfaceUnsafe(_videoPlayerPtr, surfacePpv); + SwapChainPanelHelper.NativeSurfaceImageSource_EndDrawUnsafe(_canvasSurfaceImageSourceNativePtr); + Marshal.Release(surfacePpv); + } + catch (Exception ex) + { + Console.WriteLine(ex); + } + finally + { + Volatile.Write(ref _isVideoFrameDrawInProgress, 0); + } + } + } + + #endregion + + #region Video Frame Initialization / Disposal + + private void InitializeVideoPlayer() + { + if (_videoPlayer != null!) + { + return; + } + + _videoPlayer = new MediaPlayer + { + AutoPlay = false, + IsLoopingEnabled = true, + IsVideoFrameServerEnabled = true, + Volume = AudioVolume.GetClampedVolume(), + IsMuted = !IsAudioEnabled + }; + + ComMarshal.TryGetComInterfaceReference(_videoPlayer, + in IMediaPlayer_IID, + out _videoPlayerPtr, + out _, + CreateComInterfaceFlags.None, + true); + _videoPlayer.MediaOpened += InitializeVideoFrameOnMediaOpened; + } + + private void DisposeVideoPlayer() + { + if (_videoPlayer == null!) + { + return; + } + + // Save last video player duration for later + if (_videoPlayer.CanSeek) + { + _lastVideoPlayerPosition = _videoPlayer.Position; + } + + _videoPlayer.Dispose(); + _videoPlayer.VideoFrameAvailable -= VideoPlayer_VideoFrameAvailable; + _videoPlayer.MediaOpened -= InitializeVideoFrameOnMediaOpened; + Interlocked.Exchange(ref _videoPlayer!, null); + _videoPlayerPtr = nint.Zero; + } + + private void InitializeRenderTargetSize(MediaPlaybackSession playbackSession) + { + double currentCanvasWidth = playbackSession.NaturalVideoWidth; + double currentCanvasHeight = playbackSession.NaturalVideoHeight; + + _canvasWidth = (int)(currentCanvasWidth * XamlRoot.RasterizationScale * 2d); + _canvasHeight = (int)(currentCanvasHeight * XamlRoot.RasterizationScale * 2d); + _canvasRenderArea = new Rect(0, 0, _canvasWidth, _canvasHeight); + } + + private void InitializeRenderTarget() + { + DisposeRenderTarget(); // Always ensure the previous render target has been disposed + + _canvasDevice = CanvasDevice.GetSharedDevice(); + _canvasSurfaceImageSource = new SurfaceImageSource(_canvasWidth, _canvasHeight, true); + SwapChainPanelHelper.GetNativeSurfaceImageSource(_canvasSurfaceImageSource, + out _canvasSurfaceImageSourceNative, + out _canvasD3DDeviceContext); + + ComMarshal.TryGetComInterfaceReference(_canvasSurfaceImageSourceNative!, + out _canvasSurfaceImageSourceNativePtr, + out _, + CreateComInterfaceFlags.None, + true); + + if (FindRenderImage() is { } image) + { + image.Source = _canvasSurfaceImageSource; + } + } + + private void DisposeRenderTarget() + { + // Dispose D3DDeviceContext and its dependencies + _canvasD3DDeviceContext?.Dispose(); + _canvasSurfaceImageSourceNative?.SetDevice(nint.Zero); + ComMarshal.TryReleaseComObject(_canvasSurfaceImageSourceNative, out _); + + _canvasDevice?.Dispose(); + if (FindRenderImage() is { } image) + { + image.Source = null; + } + + _canvasSurfaceImageSourceNativePtr = nint.Zero; + + Interlocked.Exchange(ref _canvasDevice, null); + Interlocked.Exchange(ref _canvasD3DDeviceContext, null); + Interlocked.Exchange(ref _canvasSurfaceImageSourceNative, null); + Interlocked.Exchange(ref _canvasSurfaceImageSource, null); + } + + private Image? FindRenderImage() => _backgroundGrid.Children + .OfType() + .LastOrDefault(x => x.Name == "VideoRenderFrame"); + + #endregion + + #region Video Player Events + + private static void IsAudioEnabled_OnChange(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + LayeredBackgroundImage instance = (LayeredBackgroundImage)d; + if (instance._videoPlayer is not { } videoPlayer) + { + return; + } + + videoPlayer.IsMuted = !(bool)e.NewValue; + } + + private static void AudioVolume_OnChange(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + LayeredBackgroundImage instance = (LayeredBackgroundImage)d; + if (instance._videoPlayer is not { } videoPlayer) + { + return; + } + + double volume = e.NewValue.TryGetDouble(); + videoPlayer.Volume = volume.GetClampedVolume(); + } + + public void Play() + { + try + { + if (_videoPlayer != null!) + { + InitializeRenderTarget(); + _videoPlayer.VideoFrameAvailable += VideoPlayer_VideoFrameAvailable; + _videoPlayer.Play(); + _isBlockVideoFrameDraw = 0; + } + else if (_lastBackgroundSourceType == MediaSourceType.Video && + BackgroundSource != null) + { + // Try loading last media + LoadFromSourceAsyncDetached(BackgroundSourceProperty, + _lastBackgroundSource, + nameof(BackgroundStretch), + nameof(BackgroundHorizontalAlignment), + nameof(BackgroundVerticalAlignment), + _backgroundGrid, + true, + ref _lastBackgroundSourceType); + } + } + catch (Exception e) + { + Console.WriteLine(e); + } + finally + { + GC.Collect(); + GC.WaitForPendingFinalizers(); + } + } + + public void Pause() + { + try + { + if (_videoPlayer != null!) + { + _isBlockVideoFrameDraw = 1; + _videoPlayer.Pause(); + _videoPlayer.VideoFrameAvailable -= VideoPlayer_VideoFrameAvailable; + DisposeRenderTarget(); + DisposeVideoPlayer(); + + GC.Collect(); + GC.WaitForPendingFinalizers(); + } + } + catch (Exception e) + { + Console.WriteLine(e); + } + finally + { + GC.Collect(); + GC.WaitForPendingFinalizers(); + } + } + + #endregion +} diff --git a/BackgroundTest/CustomControl/LayeredBackgroundImage/LayeredBackgroundImage.Events.Loaders.cs b/BackgroundTest/CustomControl/LayeredBackgroundImage/LayeredBackgroundImage.Events.Loaders.cs new file mode 100644 index 000000000..a4c5a4d26 --- /dev/null +++ b/BackgroundTest/CustomControl/LayeredBackgroundImage/LayeredBackgroundImage.Events.Loaders.cs @@ -0,0 +1,499 @@ +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 StringLiteralTypo +// ReSharper disable CommentTypo +#pragma warning disable CsWinRT1032 + +namespace BackgroundTest.CustomControl.LayeredBackgroundImage; + +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 + ///
+ private 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" + ".xbm" // "image/xbm" + ], StringComparer.OrdinalIgnoreCase); + internal static readonly HashSet.AlternateLookup> SupportedImageBitmapExtensionsLookup = + SupportedImageBitmapExtensions.GetAlternateLookup>(); + + private static readonly HashSet SupportedImageBitmapExternalCodecExtensions = new([ + ".jxr", // "image/jxr" (Requires additional codec) + ".avif", // "image/avif" (Requires additional codec) + ".webp" // "image/webp" (Requires additional codec) + ], StringComparer.OrdinalIgnoreCase); + internal static readonly HashSet.AlternateLookup> SupportedImageBitmapExternalCodecExtensionsLookup = + SupportedImageBitmapExternalCodecExtensions.GetAlternateLookup>(); + + private static readonly HashSet SupportedImageVectorExtensions = new([ + ".svg" // "image/svg" + ], StringComparer.OrdinalIgnoreCase); + internal static readonly HashSet.AlternateLookup> SupportedImageVectorExtensionsLookup = + SupportedImageVectorExtensions.GetAlternateLookup>(); + + private 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; + element.LoadFromSourceAsyncDetached(PlaceholderSourceProperty, + e.OldValue, + nameof(PlaceholderStretch), + nameof(PlaceholderHorizontalAlignment), + nameof(PlaceholderVerticalAlignment), + grid, + false, + ref element._lastPlaceholderSourceType); + } + + private static void BackgroundSource_OnChange(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + LayeredBackgroundImage element = (LayeredBackgroundImage)d; + if (!element.IsLoaded) + { + return; + } + + Grid grid = element._backgroundGrid; + element.LoadFromSourceAsyncDetached(BackgroundSourceProperty, + e.OldValue, + nameof(BackgroundStretch), + nameof(BackgroundHorizontalAlignment), + nameof(BackgroundVerticalAlignment), + grid, + true, + ref element._lastBackgroundSourceType); + } + + private static void ForegroundSource_OnChange(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + LayeredBackgroundImage element = (LayeredBackgroundImage)d; + if (!element.IsLoaded) + { + return; + } + + Grid grid = element._foregroundGrid; + element.LoadFromSourceAsyncDetached(ForegroundSourceProperty, + e.OldValue, + nameof(ForegroundStretch), + nameof(ForegroundHorizontalAlignment), + nameof(ForegroundVerticalAlignment), + grid, + false, + ref element._lastForegroundSourceType); + } + + #endregion + + #region + + private void LoadFromSourceAsyncDetached( + DependencyProperty sourceProperty, + object? lastSource, + string stretchProperty, + string horizontalAlignmentProperty, + string verticalAlignmentProperty, + Grid grid, + bool canReceiveVideo, + ref MediaSourceType lastMediaType) + { + try + { + object? source = GetValue(sourceProperty); + + 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) + { + // Pause and Invalidate Video Player + Pause(); + } + + InnerLoadDetached(); + lastMediaType = mediaType; + return; + + async void InnerLoadDetached() + { + try + { + // 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, + lastSource, + 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(); + + // Bind property + image.BindProperty(instance, stretchProperty, Image.StretchProperty, BindingMode.OneWay); + image.BindProperty(instance, horizontalAlignmentProperty, HorizontalAlignmentProperty, BindingMode.OneWay); + image.BindProperty(instance, verticalAlignmentProperty, VerticalAlignmentProperty, BindingMode.OneWay); + + image.Transitions.Add(new ContentThemeTransition()); + grid.Children.Add(image); + + image.Tag = (grid, instance); + 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); + } + catch (Exception e) + { + Console.WriteLine(e); + return false; + } + } + + private static async Task LoadVideoFromSourceAsync( + object? source, + object? lastSource, + LayeredBackgroundImage instance) + { + bool isLastStreamSame = IsSourceKindEquals(source, lastSource); + + 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(); + + // Assign media source + if (sourceStream != null) + { + IRandomAccessStream? sourceStreamRandom = sourceStream.AsRandomAccessStream(); + instance._videoPlayer.SetStreamSource(sourceStreamRandom); + } + else if (sourceUri != null) + { + instance._videoPlayer.SetUriSource(sourceUri); + } + else + { + return false; + } + } + catch (Exception e) + { + Console.WriteLine(e); + return false; + } + + // Seek to last position if source was the same + if (isLastStreamSame && + instance._videoPlayer.CanSeek) + { + instance._videoPlayer.Position = instance._lastVideoPlayerPosition; + } + + return true; + } + + private void InitializeVideoFrameOnMediaOpened(MediaPlayer sender, object args) + { + DispatcherQueue.TryEnqueue(Impl); + return; + + void Impl() + { + // Create instance + Image image = new() + { + Tag = (_backgroundGrid, this), + Name = "VideoRenderFrame" + }; + + // Bind property + image.BindProperty(this, + nameof(BackgroundStretch), + Image.StretchProperty, + BindingMode.OneWay); + image.BindProperty(this, + nameof(BackgroundHorizontalAlignment), + HorizontalAlignmentProperty, + BindingMode.OneWay); + image.BindProperty(this, + nameof(BackgroundVerticalAlignment), + VerticalAlignmentProperty, + BindingMode.OneWay); + + InitializeRenderTargetSize(sender.PlaybackSession); + + // Register events + image.Loaded += Image_VideoFrameOnLoaded; + image.Unloaded += Image_VideoFrameOnUnloaded; + + // Add to children + image.Transitions.Add(new ContentThemeTransition()); + _backgroundGrid.Children.Add(image); + } + } + + private static void Image_VideoFrameOnLoaded(object sender, RoutedEventArgs e) + { + if (sender is not Image { Tag: ValueTuple parentGrid }) + { + return; + } + + parentGrid.Item2.Play(); + Image_ImageOpened(sender, e); + } + + private static void Image_VideoFrameOnUnloaded(object sender, RoutedEventArgs e) + { + if (sender is not Image { Tag: ValueTuple parentGrid }) + { + return; + } + + parentGrid.Item2.Pause(); + } + + 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); + } + + // 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; + } + + if (SupportedVideoExtensionsLookup.Contains(extension)) + { + return MediaSourceType.Video; + } + + return 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 +} diff --git a/BackgroundTest/CustomControl/LayeredBackgroundImage/LayeredBackgroundImage.Events.cs b/BackgroundTest/CustomControl/LayeredBackgroundImage/LayeredBackgroundImage.Events.cs new file mode 100644 index 000000000..4d7f70bc2 --- /dev/null +++ b/BackgroundTest/CustomControl/LayeredBackgroundImage/LayeredBackgroundImage.Events.cs @@ -0,0 +1,277 @@ +using Microsoft.UI.Composition; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Input; +using System; +using System.Numerics; +using System.Threading; +using Windows.Foundation; + +namespace BackgroundTest.CustomControl.LayeredBackgroundImage; + +public partial class LayeredBackgroundImage +{ + #region Fields + + private object? _lastPlaceholderSource; + private object? _lastBackgroundSource; + private object? _lastForegroundSource; + + private MediaSourceType _lastPlaceholderSourceType; + private MediaSourceType _lastBackgroundSourceType; + private MediaSourceType _lastForegroundSourceType; + + private bool _isPlaceholderHidden; + + #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 LayeredBackgroundImage_OnLoaded(object sender, RoutedEventArgs e) + { + Interlocked.Exchange(ref _isLoaded, true); + ParallaxView_ToggleEnable(IsParallaxEnabled); + ParallaxGrid_OnUpdateCenterPoint(); + + if (!_isPlaceholderHidden && + !IsSourceKindEquals(_lastPlaceholderSource, PlaceholderSource)) + { + LoadFromSourceAsyncDetached(PlaceholderSourceProperty, + _lastPlaceholderSource, + nameof(PlaceholderStretch), + nameof(PlaceholderHorizontalAlignment), + nameof(PlaceholderVerticalAlignment), + _placeholderGrid, + false, + ref _lastPlaceholderSourceType); + _lastPlaceholderSource = PlaceholderSource; + } + + if (!IsSourceKindEquals(_lastBackgroundSource, BackgroundSource)) + { + LoadFromSourceAsyncDetached(BackgroundSourceProperty, + _lastBackgroundSource, + nameof(BackgroundStretch), + nameof(BackgroundHorizontalAlignment), + nameof(BackgroundVerticalAlignment), + _backgroundGrid, + true, + ref _lastBackgroundSourceType); + _lastBackgroundSource = BackgroundSource; + } + + // ReSharper disable once InvertIf + if (!IsSourceKindEquals(_lastForegroundSource, ForegroundSource)) + { + LoadFromSourceAsyncDetached(ForegroundSourceProperty, + _lastForegroundSource, + nameof(ForegroundStretch), + nameof(ForegroundHorizontalAlignment), + nameof(ForegroundVerticalAlignment), + _foregroundGrid, + false, + ref _lastForegroundSourceType); + _lastForegroundSource = ForegroundSource; + } + + GC.Collect(); + GC.WaitForPendingFinalizers(); + } + + private void LayeredBackgroundImage_OnUnloaded(object sender, RoutedEventArgs e) + { + Interlocked.Exchange(ref _isLoaded, false); + ParallaxGrid_UnregisterEffect(); + _lastParallaxHoverSource = null; + + GC.Collect(); + GC.WaitForPendingFinalizers(); + } + + #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(); + + _parallaxGrid.SizeChanged += ParallaxGrid_OnSizeChanged; + } + + private void ParallaxGrid_UnregisterEffect() + { + ParallaxGrid_UnregisterPointerEvents(); + ParallaxGrid_OffsetReset(); + + _parallaxGrid.SizeChanged -= ParallaxGrid_OnSizeChanged; + } + + private void ParallaxGrid_RegisterPointerEvents() + { + UIElement eventSource = ParallaxGrid_UnregisterPointerEvents(); + + 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; + } + + 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) + { + return; + } + + _parallaxGridVisual.CenterPoint = new Vector3((float)_parallaxGrid.RenderSize.Width / 2, + (float)_parallaxGrid.RenderSize.Height / 2, + 0); + } + + private void ParallaxGrid_OnPointerExited(object sender, PointerRoutedEventArgs e) + { + ParallaxGrid_OffsetReset(); + } + + private void ParallaxGrid_OnPointerEntered(object sender, PointerRoutedEventArgs e) + { + ParallaxGrid_OnPointerMoved(sender, e); + } + + private void ParallaxGrid_OffsetReset() + { + ParallaxGrid_StartAnimation(Vector3.Zero, Vector3.One, 250d); + } + + private void ParallaxGrid_OnPointerMoved(object sender, PointerRoutedEventArgs e) + { + FrameworkElement element = (FrameworkElement)sender; + + double offsetX = ParallaxHorizontalShift; + double offsetY = ParallaxVerticalShift; + + // Move + Point pos = e.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; + + // Move opposite to pointer + float tx = (float)(-nx * offsetX); + float ty = (float)(-ny * offsetY); + + // Scale with x2 as it counts with each side of axis (Left, Right) (Top, Bottom) + Vector2 size = _parallaxGrid.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); + + ParallaxGrid_StartAnimation(Vector3.Zero with { X = tx, Y = ty }, + Vector3.One with { X = factorScale, Y = factorScale }, + 40); + } + + private void ParallaxGrid_StartAnimation(Vector3 offset, Vector3 scale, double duration) + { + const string targetTranslation = "Translation"; + const string targetScale = "Scale"; + + CompositionAnimationGroup? animGroup = _parallaxGridCompositor.CreateAnimationGroup(); + + // Move + Vector3KeyFrameAnimation? anim = _parallaxGridCompositor.CreateVector3KeyFrameAnimation(); + anim.Duration = TimeSpan.FromMilliseconds(duration); + anim.InsertKeyFrame(1f, offset); + anim.Target = targetTranslation; + + // Scale + Vector3KeyFrameAnimation? scaleAnim = _parallaxGridCompositor.CreateVector3KeyFrameAnimation(); + scaleAnim.Duration = TimeSpan.FromMilliseconds(duration); + scaleAnim.InsertKeyFrame(1f, scale); + scaleAnim.Target = targetScale; + + animGroup.Add(anim); + animGroup.Add(scaleAnim); + + _parallaxGridVisual.StartAnimationGroup(animGroup); + } + + #endregion +} + diff --git a/BackgroundTest/CustomControl/LayeredBackgroundImage/LayeredBackgroundImage.Properties.cs b/BackgroundTest/CustomControl/LayeredBackgroundImage/LayeredBackgroundImage.Properties.cs new file mode 100644 index 000000000..4879a97f2 --- /dev/null +++ b/BackgroundTest/CustomControl/LayeredBackgroundImage/LayeredBackgroundImage.Properties.cs @@ -0,0 +1,249 @@ +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Media; + +namespace BackgroundTest.CustomControl.LayeredBackgroundImage; + +public partial class LayeredBackgroundImage +{ + #region Properties + + public IMediaCacheHandler? MediaCacheHandler + { + get => (IMediaCacheHandler?)GetValue(MediaCacheHandlerProperty); + set => SetValue(MediaCacheHandlerProperty, value); + } + + public bool IsAudioEnabled + { + get => (bool)GetValue(IsAudioEnabledProperty); + set => SetValue(IsAudioEnabledProperty, value); + } + + public double AudioVolume + { + get => GetValue(AudioVolumeProperty).TryGetDouble(); + set => SetValue(AudioVolumeProperty, value); + } + + public bool IsParallaxEnabled + { + get => (bool)GetValue(IsParallaxEnabledProperty); + set => SetValue(IsParallaxEnabledProperty, value); + } + + public double ParallaxHorizontalShift + { + get => (double)GetValue(ParallaxHorizontalShiftProperty); + set => SetValue(ParallaxHorizontalShiftProperty, value); + } + + public double ParallaxVerticalShift + { + get => (double)GetValue(ParallaxVerticalShiftProperty); + set => SetValue(ParallaxVerticalShiftProperty, value); + } + + public UIElement? ParallaxHoverSource + { + get => (UIElement?)GetValue(ParallaxHoverSourceProperty); + set => SetValue(ParallaxHoverSourceProperty, 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 Stretch BackgroundStretch + { + get => (Stretch)GetValue(BackgroundStretchProperty); + set => SetValue(BackgroundStretchProperty, value); + } + + public HorizontalAlignment BackgroundHorizontalAlignment + { + get => (HorizontalAlignment)GetValue(BackgroundHorizontalAlignmentProperty); + set => SetValue(BackgroundHorizontalAlignmentProperty, value); + } + + public VerticalAlignment BackgroundVerticalAlignment + { + get => (VerticalAlignment)GetValue(BackgroundVerticalAlignmentProperty); + set => SetValue(BackgroundVerticalAlignmentProperty, value); + } + + public object? ForegroundSource + { + get => (object?)GetValue(ForegroundSourceProperty); + set => SetValue(ForegroundSourceProperty, value); + } + + public Stretch ForegroundStretch + { + get => (Stretch)GetValue(ForegroundStretchProperty); + set => SetValue(ForegroundStretchProperty, value); + } + + public HorizontalAlignment ForegroundHorizontalAlignment + { + get => (HorizontalAlignment)GetValue(ForegroundHorizontalAlignmentProperty); + set => SetValue(ForegroundHorizontalAlignmentProperty, value); + } + + public VerticalAlignment ForegroundVerticalAlignment + { + get => (VerticalAlignment)GetValue(ForegroundVerticalAlignmentProperty); + set => SetValue(ForegroundVerticalAlignmentProperty, value); + } + + #endregion + + #region Fields + + private UIElement? _lastParallaxHoverSource; + + #endregion + + #region Dependency Properties + + public static readonly DependencyProperty MediaCacheHandlerProperty = + DependencyProperty.Register(nameof(MediaCacheHandler), + typeof(IMediaCacheHandler), + typeof(LayeredBackgroundImage), + new PropertyMetadata(null!)); + + public static readonly DependencyProperty IsAudioEnabledProperty = + DependencyProperty.Register(nameof(IsAudioEnabled), + typeof(bool), + typeof(LayeredBackgroundImage), + new PropertyMetadata(false, IsAudioEnabled_OnChange)); + + public static readonly DependencyProperty AudioVolumeProperty = + DependencyProperty.Register(nameof(AudioVolume), + typeof(double), + typeof(LayeredBackgroundImage), + new PropertyMetadata(50d, AudioVolume_OnChange)); + + public static readonly DependencyProperty IsParallaxEnabledProperty = + DependencyProperty.Register(nameof(IsParallaxEnabled), + typeof(bool), + typeof(LayeredBackgroundImage), + new PropertyMetadata(false, IsParallaxEnabled_OnChange)); + + public static readonly DependencyProperty ParallaxHorizontalShiftProperty = + DependencyProperty.Register(nameof(ParallaxHorizontalShift), + typeof(double), + typeof(LayeredBackgroundImage), + new PropertyMetadata(32d)); + + public static readonly DependencyProperty ParallaxVerticalShiftProperty = + DependencyProperty.Register(nameof(ParallaxVerticalShift), + typeof(double), + typeof(LayeredBackgroundImage), + new PropertyMetadata(32d)); + + public static readonly DependencyProperty ParallaxHoverSourceProperty = + DependencyProperty.Register(nameof(ParallaxHoverSource), + typeof(UIElement), + typeof(LayeredBackgroundImage), + new PropertyMetadata(null!, ParallaxHover_OnChange)); + + 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 BackgroundStretchProperty = + DependencyProperty.Register(nameof(BackgroundStretch), + typeof(Stretch), + typeof(LayeredBackgroundImage), + new PropertyMetadata(Stretch.UniformToFill)); + + public static readonly DependencyProperty BackgroundHorizontalAlignmentProperty = + DependencyProperty.Register(nameof(BackgroundHorizontalAlignment), + typeof(HorizontalAlignment), + typeof(LayeredBackgroundImage), + new PropertyMetadata(HorizontalAlignment.Center)); + + public static readonly DependencyProperty BackgroundVerticalAlignmentProperty = + DependencyProperty.Register(nameof(BackgroundVerticalAlignment), + typeof(VerticalAlignment), + typeof(LayeredBackgroundImage), + new PropertyMetadata(VerticalAlignment.Center)); + + public static readonly DependencyProperty ForegroundSourceProperty = + DependencyProperty.Register(nameof(ForegroundSource), + typeof(object), + typeof(LayeredBackgroundImage), + new PropertyMetadata(null!, ForegroundSource_OnChange)); + + public static readonly DependencyProperty ForegroundStretchProperty = + DependencyProperty.Register(nameof(ForegroundStretch), + typeof(Stretch), + typeof(LayeredBackgroundImage), + new PropertyMetadata(Stretch.UniformToFill)); + + public static readonly DependencyProperty ForegroundHorizontalAlignmentProperty = + DependencyProperty.Register(nameof(ForegroundHorizontalAlignment), + typeof(HorizontalAlignment), + typeof(LayeredBackgroundImage), + new PropertyMetadata(HorizontalAlignment.Center)); + + public static readonly DependencyProperty ForegroundVerticalAlignmentProperty = + DependencyProperty.Register(nameof(ForegroundVerticalAlignment), + typeof(VerticalAlignment), + typeof(LayeredBackgroundImage), + new PropertyMetadata(VerticalAlignment.Center)); + + #endregion +} diff --git a/BackgroundTest/CustomControl/LayeredBackgroundImage/LayeredBackgroundImage.Templates.cs b/BackgroundTest/CustomControl/LayeredBackgroundImage/LayeredBackgroundImage.Templates.cs new file mode 100644 index 000000000..e09ae086f --- /dev/null +++ b/BackgroundTest/CustomControl/LayeredBackgroundImage/LayeredBackgroundImage.Templates.cs @@ -0,0 +1,75 @@ +using Hi3Helper.Win32.Native.Interfaces.DXGI; +using Hi3Helper.Win32.WinRT.SwapChainPanelHelper; +using Microsoft.Graphics.Canvas; +using Microsoft.UI.Composition; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Hosting; +using Microsoft.UI.Xaml.Media.Imaging; +using System.Threading; +using Windows.Media.Playback; + +using CanvasRect = Hi3Helper.Win32.Native.Structs.Rect; + +namespace BackgroundTest.CustomControl.LayeredBackgroundImage; +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 StateNamePlaceholderStateHidden = "PlaceholderStateHidden"; + + #endregion + + #region Fields + + private Grid _parallaxGrid = null!; + private Grid _placeholderGrid = null!; + private Grid _backgroundGrid = null!; + private Grid _foregroundGrid = null!; + + private Visual _parallaxGridVisual = null!; + private Compositor _parallaxGridCompositor = null!; + + private D3DDeviceContext? _canvasD3DDeviceContext; + private ISurfaceImageSourceNativeWithD2D? _canvasSurfaceImageSourceNative; + private nint _canvasSurfaceImageSourceNativePtr = nint.Zero; + private SurfaceImageSource? _canvasSurfaceImageSource; + + private CanvasDevice? _canvasDevice = null!; + private int _canvasWidth; + private int _canvasHeight; + private CanvasRect _canvasRenderArea; + + private MediaPlayer _videoPlayer = null!; + private nint _videoPlayerPtr = nint.Zero; + + private bool _isTemplateLoaded; + + #endregion + + #region Apply Template Methods + + protected override void OnApplyTemplate() + { + _parallaxGrid = this.GetTemplateChild(TemplateNameParallaxGrid); + _placeholderGrid = this.GetTemplateChild(TemplateNamePlaceholderGrid); + _backgroundGrid = this.GetTemplateChild(TemplateNameBackgroundGrid); + _foregroundGrid = this.GetTemplateChild(TemplateNameForegroundGrid); + + ElementCompositionPreview.SetIsTranslationEnabled(_parallaxGrid, true); + + _parallaxGridVisual = ElementCompositionPreview.GetElementVisual(_parallaxGrid); + _parallaxGridCompositor = _parallaxGridVisual.Compositor; + + Interlocked.Exchange(ref _isTemplateLoaded, true); + + base.OnApplyTemplate(); + } + + #endregion +} + diff --git a/BackgroundTest/CustomControl/LayeredBackgroundImage/LayeredBackgroundImage.cs b/BackgroundTest/CustomControl/LayeredBackgroundImage/LayeredBackgroundImage.cs new file mode 100644 index 000000000..99aefe0f6 --- /dev/null +++ b/BackgroundTest/CustomControl/LayeredBackgroundImage/LayeredBackgroundImage.cs @@ -0,0 +1,23 @@ +using Microsoft.UI.Xaml.Controls; + +namespace BackgroundTest.CustomControl.LayeredBackgroundImage; + +public partial class LayeredBackgroundImage : Control +{ + public LayeredBackgroundImage() + { + Loaded += LayeredBackgroundImage_OnLoaded; + Unloaded += LayeredBackgroundImage_OnUnloaded; + + DefaultStyleKey = typeof(LayeredBackgroundImage); + } + + ~LayeredBackgroundImage() + { + Loaded -= LayeredBackgroundImage_OnLoaded; + Unloaded -= LayeredBackgroundImage_OnUnloaded; + + DisposeRenderTarget(); + DisposeVideoPlayer(); + } +} diff --git a/BackgroundTest/CustomControl/LayeredBackgroundImage/LayeredBackgroundImage.xaml b/BackgroundTest/CustomControl/LayeredBackgroundImage/LayeredBackgroundImage.xaml new file mode 100644 index 000000000..83e31a0a9 --- /dev/null +++ b/BackgroundTest/CustomControl/LayeredBackgroundImage/LayeredBackgroundImage.xaml @@ -0,0 +1,75 @@ + + + + + + \ No newline at end of file diff --git a/BackgroundTest/CustomControl/LayeredBackgroundImage/LayeredBackgroundImageExtensions.cs b/BackgroundTest/CustomControl/LayeredBackgroundImage/LayeredBackgroundImageExtensions.cs new file mode 100644 index 000000000..fafdfe679 --- /dev/null +++ b/BackgroundTest/CustomControl/LayeredBackgroundImage/LayeredBackgroundImageExtensions.cs @@ -0,0 +1,300 @@ +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Media.Imaging; +using PhotoSauce.MagicScaler; +using PhotoSauce.MagicScaler.Transforms; +using PhotoSauce.NativeCodecs.Libheif; +using PhotoSauce.NativeCodecs.Libjxl; +using PhotoSauce.NativeCodecs.Libwebp; +using System; +using System.IO; +using System.Net.Http; +using System.Threading.Tasks; +using Windows.Storage.Streams; +// ReSharper disable SwitchStatementHandlesSomeKnownEnumValuesWithDefault +// ReSharper disable ConvertIfStatementToSwitchStatement + +namespace BackgroundTest.CustomControl.LayeredBackgroundImage; + +internal static class LayeredBackgroundImageExtensions +{ + private static readonly HttpClient Client; + + static LayeredBackgroundImageExtensions() + { + // Initialize support for MagicScaler's WebP decoding + CodecManager.Configure(InitializeMagicScalerCodecs); + Client = CreateHttpClient(); + } + + private static void InitializeMagicScalerCodecs(CodecCollection codecs) + { + codecs.UseWicCodecs(WicCodecPolicy.All); + codecs.UseLibwebp(); + codecs.UseLibheif(); + codecs.UseLibjxl(); + } + + private static HttpClient CreateHttpClient() + { + SocketsHttpHandler handler = new() + { + AllowAutoRedirect = true, + MaxConnectionsPerServer = Environment.ProcessorCount * 2 + }; + return new HttpClient(handler, false); + } + + extension(Image image) + { + internal async ValueTask LoadImageAsync(Uri? sourceFromPath, + Stream? sourceFromStream, + LayeredBackgroundImage instance, + IPixelTransform? pixelTransform = null) + { + IMediaCacheHandler? cacheHandler = instance.MediaCacheHandler; + bool isDisposeStream = sourceFromStream == null; + + if (cacheHandler != null) + { + object? source = sourceFromPath; + source ??= sourceFromStream; + MediaCacheResult cacheResult = await cacheHandler.LoadCachedSource(source); + + if (cacheResult == null!) + { + return false; + } + + Uri? cachedUrlSource = cacheResult.CachedSource as Uri; + Stream? cachedStreamSource = null; + + if (cacheResult.CachedSource is string asString) + { + cachedUrlSource = asString.GetStringAsUri(); + } + + if (cacheResult.CachedSource is Stream asStream) + { + cachedStreamSource = asStream; + } + + isDisposeStream = cacheResult.DisposeStream; + + if (cacheResult.ForceUseInternalDecoder && + await image.LoadImageWithInternalDecoderAsync(sourceFromPath, + sourceFromStream, + true, + isDisposeStream)) + { + return true; + } + + sourceFromPath = cachedUrlSource; + sourceFromStream = cachedStreamSource; + } + + if (pixelTransform == null && + await image.LoadImageWithInternalDecoderAsync(sourceFromPath, + sourceFromStream)) + { + return true; + } + + Stream? sourceStream = sourceFromStream ?? await TryGetStreamFromPathAsync(sourceFromPath); + if (sourceStream is not { CanSeek: true, CanRead: true }) + { + return false; + } + + try + { + string temporaryFilePath = Path.Combine(Path.GetTempPath(), Path.GetTempFileName()); + await using FileStream decodedBitmapStream = + File.Open(temporaryFilePath, + new FileStreamOptions + { + Mode = FileMode.Create, + Access = FileAccess.ReadWrite, + Share = FileShare.ReadWrite, + Options = FileOptions.DeleteOnClose + }); + + await Task.Run(() => + { + using ProcessingPipeline pipeline = + MagicImageProcessor.BuildPipeline(sourceStream, ProcessImageSettings.Default); + + // For adding Waifu2X transform support later. + if (pixelTransform != null) + { + pipeline.AddTransform(pixelTransform); + } + + pipeline.WriteOutput(decodedBitmapStream); + }); + using IRandomAccessStream randomStream = decodedBitmapStream.AsRandomAccessStream(); + + decodedBitmapStream.Position = 0; + BitmapImage bitmapImage = new(); + image.Source = bitmapImage; + + await bitmapImage.SetSourceAsync(randomStream); + return true; + } + catch (Exception ex) + { + Console.WriteLine(ex); + return false; + } + finally + { + if (isDisposeStream) + { + await sourceStream.DisposeAsync(); + } + } + } + + private async ValueTask LoadImageWithInternalDecoderAsync(Uri? sourceFromPath, + Stream? sourceFromStream, + bool force = false, + bool disposeStream = false) + { + string? filePath = sourceFromPath?.AbsolutePath; + filePath ??= (sourceFromStream as FileStream)?.Name; + + if (string.IsNullOrEmpty(filePath)) + { + return false; + } + + string extension = Path.GetExtension(filePath); + if (!force && + LayeredBackgroundImage.SupportedImageBitmapExternalCodecExtensionsLookup + .Contains(extension)) + { + return false; + } + + bool isDisposeStream = disposeStream; + Stream? sourceStream = sourceFromStream ?? await TryGetStreamFromPathAsync(sourceFromPath); + if (sourceStream is not { CanSeek: true, CanRead: true }) + { + return false; + } + + try + { + IRandomAccessStream randomStream = sourceStream.AsRandomAccessStream(); + BitmapImage bitmapImage = new(); + image.Source = bitmapImage; + + await bitmapImage.SetSourceAsync(randomStream); + if (isDisposeStream) + { + randomStream.Dispose(); + } + return true; + } + catch (Exception ex) + { + Console.WriteLine(ex); + return false; + } + finally + { + if (isDisposeStream) + { + await sourceStream.DisposeAsync(); + } + } + } + } + + private static async Task TryGetStreamFromPathAsync(Uri? url, long? minLengthRequested = null) + { + 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; + } + + minLengthRequested ??= fileLength; + + try + { + int writtenLength = (int)minLengthRequested; + byte[] requestedBuffer = GC.AllocateUninitializedArray(writtenLength); + await using Stream networkStream = await responseMessage.Content.ReadAsStreamAsync(); + + Memory buffer = requestedBuffer; + while (writtenLength > 0) + { + int read = await networkStream.ReadAsync(buffer); + if (read == 0) + { + break; + } + writtenLength -= read; + buffer = buffer[read..]; + } + + return new MemoryStream(requestedBuffer); + } + catch (Exception e) + { + Console.WriteLine(e); + } + + 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; + } +} diff --git a/BackgroundTest/CustomControl/ManagedUIElementList.cs b/BackgroundTest/CustomControl/ManagedUIElementList.cs new file mode 100644 index 000000000..acda8c9a0 --- /dev/null +++ b/BackgroundTest/CustomControl/ManagedUIElementList.cs @@ -0,0 +1,215 @@ +using Microsoft.UI.Xaml; +using System.Collections; +using System.Collections.Generic; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Runtime.CompilerServices; +using WinRT; + +// ReSharper disable StaticMemberInGenericType + +namespace BackgroundTest.CustomControl; + +[ProjectedRuntimeClass(typeof(IList))] +[GeneratedBindableCustomProperty] +public partial class ManagedUIElementList : IList, INotifyCollectionChanged, INotifyPropertyChanged +{ + private readonly List _backedList; + + #region Cached Property Changes Arguments + + private static readonly PropertyChangedEventArgs CountPropertyChanged = new(nameof(Count)); + private static readonly PropertyChangedEventArgs IndexerPropertyChanged = new("Item[]"); + + #endregion + + #region Events + + /// + public event NotifyCollectionChangedEventHandler? CollectionChanged; + + /// + public event PropertyChangedEventHandler? PropertyChanged; + + #endregion + + /// + /// Creates a new empty Observable List + /// + public ManagedUIElementList() => _backedList = []; + + /// + /// Creates a new Observable List from the enumerable. + /// + /// + /// Borrow the current instance of instead of allocating new backed list if possible. + public ManagedUIElementList(IEnumerable enumerable, bool useBorrow = false) + { + if (enumerable is List borrowedList && + useBorrow) + { + _backedList = borrowedList; + return; + } + + _backedList = [.. enumerable]; + } + + /// + public IEnumerator GetEnumerator() => _backedList.GetEnumerator(); + + /// + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + /// + public void Add(UIElement item) => Add(item, true); + + /// + /// Adds an item to the backed List and notify the changes. + /// + /// The object to add to the backed List. + /// Whether to notify the changes or not. + public void Add(UIElement item, bool notifyChanges) + { + _backedList.Add(item); + if (!notifyChanges) + { + return; + } + + NotifyCountPropertyChange(); + NotifyIndexerPropertyChange(); + CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Add, item)); + } + + /// + public void Clear() => Clear(true); + + /// + /// Removes all items from the backed List and notify the changes. + /// + /// Whether to notify the changes or not. + public void Clear(bool notifyChanges) + { + _backedList.Clear(); + if (!notifyChanges) + { + return; + } + + NotifyCountPropertyChange(); + NotifyIndexerPropertyChange(); + CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset)); + } + + /// + public bool Contains(UIElement item) => _backedList.Contains(item); + + /// + public void CopyTo(UIElement[] array, int arrayIndex) => _backedList.CopyTo(array, arrayIndex); + + /// + public int Count => _backedList.Count; + + /// + public bool IsReadOnly => false; + + /// + public int IndexOf(UIElement item) => _backedList.IndexOf(item); + + /// + public bool Remove(UIElement item) => Remove(item, true); + + /// + /// Removes the first occurrence of a specific object from the backed List. + /// + /// The object to remove from the backed List. + /// Whether to notify the changes or not. + /// + /// if was successfully removed from the backed List; otherwise, . + /// This method also returns if is not found from the backed List. + /// + public bool Remove(UIElement item, bool notifyChanges) + { + bool isSuccess = _backedList.Remove(item); + + if (!isSuccess) + { + return false; + } + + if (!notifyChanges) + { + return true; + } + + NotifyCountPropertyChange(); + NotifyIndexerPropertyChange(); + CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Remove, item)); + return true; + } + + /// + public void Insert(int index, UIElement item) => Insert(index, item, true); + + /// + /// Inserts an item to the backed List at the specified index. + /// + /// The zero-based index at which should be inserted. + /// The object to insert into the backed List. + /// Whether to notify the changes or not. + public void Insert(int index, UIElement item, bool notifyChanges) + { + _backedList.Insert(index, item); + + if (!notifyChanges) + { + return; + } + + NotifyCountPropertyChange(); + NotifyIndexerPropertyChange(); + CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Add, item, index)); + } + + /// + public void RemoveAt(int index) => RemoveAt(index, true); + + /// + /// Removes the backed List item at the specified index. + /// + /// The zero-based index of the item to remove. + /// Whether to notify the changes or not. + public void RemoveAt(int index, bool notifyChanges) + { + UIElement item = _backedList[index]; + _backedList.Remove(item); + + if (!notifyChanges) + { + return; + } + + NotifyCountPropertyChange(); + NotifyIndexerPropertyChange(); + CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Remove, item, index)); + } + + /// + [IndexerName("Item")] + public UIElement this[int index] + { + get => _backedList[index]; + set + { + UIElement oldItem = _backedList[index]; + UIElement newItem = _backedList[index] = value; + + NotifyIndexerPropertyChange(); + CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Replace, newItem, oldItem, index)); + } + } + + private void NotifyCountPropertyChange() => PropertyChanged?.Invoke(this, CountPropertyChanged); + private void NotifyIndexerPropertyChange() => PropertyChanged?.Invoke(this, IndexerPropertyChanged); +} diff --git a/BackgroundTest/CustomControl/NewPipsPager/NewPipsPager.Converters.cs b/BackgroundTest/CustomControl/NewPipsPager/NewPipsPager.Converters.cs new file mode 100644 index 000000000..16c182960 --- /dev/null +++ b/BackgroundTest/CustomControl/NewPipsPager/NewPipsPager.Converters.cs @@ -0,0 +1,22 @@ +using Microsoft.UI.Xaml.Data; +using System; + +namespace BackgroundTest.CustomControl.NewPipsPager; + +internal partial class IndexAddOneConverter : IValueConverter +{ + public object Convert(object value, Type targetType, object parameter, string language) + { + if (value is int asInt) + { + return asInt + 1; + } + + return value; + } + + public object ConvertBack(object value, Type targetType, object parameter, string language) + { + throw new NotImplementedException(); + } +} \ No newline at end of file diff --git a/BackgroundTest/CustomControl/NewPipsPager/NewPipsPager.Enums.cs b/BackgroundTest/CustomControl/NewPipsPager/NewPipsPager.Enums.cs new file mode 100644 index 000000000..4876940d7 --- /dev/null +++ b/BackgroundTest/CustomControl/NewPipsPager/NewPipsPager.Enums.cs @@ -0,0 +1,14 @@ +namespace BackgroundTest.CustomControl.NewPipsPager; + +public enum NewPipsPagerNavigationMode +{ + Auto, + Hidden, + Visible +} + +public enum NewPipsPagerSelectionMode +{ + Click, + Hover +} \ No newline at end of file diff --git a/BackgroundTest/CustomControl/NewPipsPager/NewPipsPager.Events.cs b/BackgroundTest/CustomControl/NewPipsPager/NewPipsPager.Events.cs new file mode 100644 index 000000000..614e4e201 --- /dev/null +++ b/BackgroundTest/CustomControl/NewPipsPager/NewPipsPager.Events.cs @@ -0,0 +1,379 @@ +using Microsoft.UI.Input; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Input; +using System; +using System.Linq; +using System.Numerics; +using Windows.Foundation; +using Windows.System; + +namespace BackgroundTest.CustomControl.NewPipsPager; + +public partial class NewPipsPager +{ + #region Size Measure Override + + protected override Size MeasureOverride(Size parentSize) + { + Vector2 containerSize = _pipsPagerItemsRepeater.ActualSize; + double containerTotalWidth = containerSize.X; + double containerTotalHeight = containerSize.Y; + + GetNavigationButtonTotalSize(PreviousNavigationButtonMode, + _previousPageButton, + out double buttonPrevSizeTotalWidth, + out double buttonPrevSizeTotalHeight); + + GetNavigationButtonTotalSize(NextNavigationButtonMode, + _nextPageButton, + out double buttonNextSizeTotalWidth, + out double buttonNextSizeTotalHeight); + + Vector2 scrollViewportSize = parentSize.ToVector2(); + double scrollViewportTotalWidth = scrollViewportSize.X; + double scrollViewportTotalHeight = scrollViewportSize.Y; + + Size selfSize = base.MeasureOverride(parentSize); + double totalCalculatedWidth = selfSize.Width; + double totalCalculatedHeight = selfSize.Height; + + if (Orientation == Orientation.Horizontal) + { + _pipsPagerScrollViewer.MaxWidth = + GetViewportSize(scrollViewportTotalWidth, + containerTotalWidth, + buttonPrevSizeTotalWidth, + buttonNextSizeTotalWidth, + _pipsButtonSize, + out bool isContainerLarger); + + totalCalculatedWidth = 0; + totalCalculatedWidth += !isContainerLarger + ? containerTotalWidth + : _pipsPagerScrollViewer.MaxWidth; + totalCalculatedWidth += buttonPrevSizeTotalWidth + + buttonNextSizeTotalWidth; + } + else + { + _pipsPagerScrollViewer.MaxHeight = + GetViewportSize(scrollViewportTotalHeight, + containerTotalHeight, + buttonPrevSizeTotalHeight, + buttonNextSizeTotalHeight, + _pipsButtonSize, + out bool isContainerLarger); + + totalCalculatedHeight = 0; + totalCalculatedHeight += !isContainerLarger + ? containerTotalHeight + : _pipsPagerScrollViewer.MaxHeight; + totalCalculatedHeight += buttonPrevSizeTotalHeight + + buttonNextSizeTotalHeight; + } + + return new Size(totalCalculatedWidth, totalCalculatedHeight); + } + + private static void GetNavigationButtonTotalSize( + NewPipsPagerNavigationMode mode, + Button element, + out double width, + out double height) + { + bool isVisible = mode != NewPipsPagerNavigationMode.Hidden; + Vector2 size = isVisible ? element.ActualSize : Vector2.Zero; + Thickness margin = isVisible ? element.Margin : default; + width = size.X + margin.Left + margin.Right; + height = size.Y + margin.Top + margin.Bottom; + } + + private static double GetViewportSize(double initialViewportSize, + double initialContainerSize, + double previousSideElementSize, + double nextSideElementSize, + double perButtonSize, + out bool isContainerLarger) + { + // Decrease viewport based on total width of navigation buttons + initialViewportSize -= previousSideElementSize + nextSideElementSize; + isContainerLarger = initialContainerSize > initialViewportSize; + + // Clamp to display only viewable pips + if (isContainerLarger) + { + double dividedPerButtonSize = Math.Floor(initialViewportSize / perButtonSize); + initialViewportSize = dividedPerButtonSize * perButtonSize; + int clampedNth = (int)dividedPerButtonSize; + if (clampedNth % 2 == 0) + { + initialViewportSize = (clampedNth - 1) * perButtonSize; + } + + return Math.Max(initialViewportSize, 0); + } + + return double.PositiveInfinity; + } + + #endregion + + #region UI Events - Navigation Buttons + + private void PreviousPageButton_OnClick(object sender, RoutedEventArgs e) => ItemIndex--; + + private void NextPageButton_OnClick(object sender, RoutedEventArgs e) => ItemIndex++; + + private void KeyboardKeys_Pressed(object sender, KeyRoutedEventArgs e) + { + switch (e.Key) + { + case VirtualKey.Left: + case VirtualKey.Up: + PreviousPageButton_OnClick(sender, e); + break; + case VirtualKey.Right: + case VirtualKey.Down: + NextPageButton_OnClick(sender, e); + break; + } + } + + #endregion + + #region UI Events - Orientation + + private static void Orientation_OnChange(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + NewPipsPager pager = (NewPipsPager)d; + Orientation orientation = (Orientation)e.NewValue; + + Orientation_OnChange(pager, orientation); + } + + private static void Orientation_OnChange(NewPipsPager pager, Orientation orientation) + { + string state = orientation == Orientation.Vertical + ? "VerticalOrientationView" + : "HorizontalOrientationView"; + VisualStateManager.GoToState(pager, state, true); + } + + #endregion + + #region Property Changes + + private static void ItemsCount_OnChange(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is not NewPipsPager pager) + { + return; + } + + object obj = e.NewValue; + int value = 0; + if (obj is int asInt) + { + value = asInt; + } + + pager.ItemsCount_OnChange(value); + } + + private void ItemsCount_OnChange(int value) + { + if (!_isTemplateLoaded) + { + return; + } + + if (value < 0) + { + throw new IndexOutOfRangeException("ItemsCount cannot be negative!"); + } + + int oldItemsCount = _itemsDummy.Length; + + using (_atomicLock.EnterScope()) + { + try + { + + if (value == 0) + { + _itemsDummy = []; + return; + } + + _itemsDummy = Enumerable.Range(0, value).ToArray(); + } + finally + { + // Update ItemsSource if already assigned + _pipsPagerItemsRepeater.ItemsSource = _itemsDummy; + _pipsPagerItemsRepeater.UpdateLayout(); + + // Update index only if the count is invalid + int currentIndex = ItemIndex; + if (currentIndex > value || + (value != 0 && currentIndex < 0)) + { + ItemIndex = 0; + } + + ItemsCountChanged?.Invoke(this, new ChangedStructItemArgs(oldItemsCount, value)); + } + } + } + + private static void ItemIndex_OnChange(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + // Ignore if the old and new index are equal + if (e is not { NewValue: int asNewIndex, OldValue: int asOldIndex } || + asNewIndex == asOldIndex) + { + return; + } + + NewPipsPager pager = (NewPipsPager)d; + + // Update navigation buttons state + UpdatePreviousButtonVisualState(pager); + UpdateNextButtonVisualState(pager); + + // Update pip buttons state + UpdateAndBringSelectedPipToView(pager, asNewIndex, asOldIndex); + } + + #endregion + + #region ItemsRepeater + + private void ItemsRepeater_ElementPrepared(ItemsRepeater sender, ItemsRepeaterElementPreparedEventArgs args) + { + if (args.Element is not Button asButton) + { + return; + } + + if (asButton.Tag is not int asIndex) + { + return; + } + + AssignPipButtonStyle(asButton, + asIndex != ItemIndex + ? PipButtonStyleNormal + : PipButtonStyleSelected); + + // Avoid redundant loaded + unloaded events assignment + if (asButton.IsLoaded) + { + return; + } + + asButton.Loaded += ItemsRepeaterPipButton_LoadedEvent; + asButton.Unloaded += ItemsRepeaterPipButton_UnloadedEvent; + } + + private void ItemsRepeater_OnSizeChanged(object sender, SizeChangedEventArgs e) => InvalidateMeasure(); + + private void ItemsRepeaterPipButton_UnloadedEvent(object sender, RoutedEventArgs e) + { + Button button = (Button)sender; + if (SelectionMode == NewPipsPagerSelectionMode.Click) + { + button.Click -= ItemsRepeaterPipButton_OnClick; + } + else + { + button.PointerEntered -= ItemsRepeaterPipButton_OnClick; + } + + button.Loaded -= ItemsRepeaterPipButton_LoadedEvent; + button.Unloaded -= ItemsRepeaterPipButton_UnloadedEvent; + } + + private void ItemsRepeaterPipButton_LoadedEvent(object sender, RoutedEventArgs e) + { + Button button = (Button)sender; + if (SelectionMode == NewPipsPagerSelectionMode.Click) + { + button.Click += ItemsRepeaterPipButton_OnClick; + } + else + { + button.PointerEntered += ItemsRepeaterPipButton_OnClick; + } + } + + private void ItemsRepeaterPipButton_OnClick(object sender, RoutedEventArgs args) + { + ItemIndex = (int)((Button)sender).Tag; + } + + #endregion + + #region ScrollViewer + + private void ScrollViewer_OnPointerWheelChanged(object sender, PointerRoutedEventArgs e) + { + if (!e.Pointer.IsInRange || + sender is not UIElement element) + { + return; + } + + PointerPoint pointer = e.GetCurrentPoint(element); + int orientation = pointer.Properties.MouseWheelDelta; + bool isHorizontal = Orientation == Orientation.Horizontal; + double delta = _pipsButtonSize * (orientation / 120d); + + double toOffset = (isHorizontal + ? _pipsPagerScrollViewer.HorizontalOffset + : _pipsPagerScrollViewer.VerticalOffset) + -delta; + toOffset = Math.Round(toOffset / _pipsButtonSize) * _pipsButtonSize; + + if (isHorizontal) + { + toOffset = Math.Clamp(toOffset, 0, _pipsPagerScrollViewer.ExtentWidth); + _pipsPagerScrollViewer.ChangeView(toOffset, _pipsPagerScrollViewer.VerticalOffset, _pipsPagerScrollViewer.ZoomFactor); + } + else + { + toOffset = Math.Clamp(toOffset, 0, _pipsPagerScrollViewer.ExtentHeight); + _pipsPagerScrollViewer.ChangeView(_pipsPagerScrollViewer.HorizontalOffset, toOffset, _pipsPagerScrollViewer.ZoomFactor); + } + } + + #endregion + + #region Loaded and Unloaded + + private void NewPipsPager_Unloaded(object sender, RoutedEventArgs e) + { + UnapplyNavigationButtonEvents(); + UnapplyKeyPressEvents(); + UnapplyItemsRepeaterEvents(); + + _pipsPagerItemsRepeater.ItemsSource = null; + } + + private void NewPipsPager_Loaded(object sender, RoutedEventArgs e) + { + ItemsCount_OnChange(ItemsCount); + UpdateAndBringSelectedPipToView(this, ItemIndex, -1); + + if (sender is not NewPipsPager pager) + { + return; + } + + // Update navigation buttons state + UpdatePreviousButtonVisualState(pager); + UpdateNextButtonVisualState(pager); + } + + #endregion +} diff --git a/BackgroundTest/CustomControl/NewPipsPager/NewPipsPager.Properties.cs b/BackgroundTest/CustomControl/NewPipsPager/NewPipsPager.Properties.cs new file mode 100644 index 000000000..3ecd540ff --- /dev/null +++ b/BackgroundTest/CustomControl/NewPipsPager/NewPipsPager.Properties.cs @@ -0,0 +1,264 @@ +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using System.Threading; +using Windows.Foundation; + +namespace BackgroundTest.CustomControl.NewPipsPager; + +public partial class NewPipsPager +{ + #region Events + + public event TypedEventHandler>? ItemsCountChanged; + public event TypedEventHandler>? ItemIndexChanged; + + #endregion + + #region Properties + + private static Style? PipButtonStyleNormal => field ??= TryGetStyle(PipButtonStyleNormalName); + private static Style? PipButtonStyleSelected => field ??= TryGetStyle(PipButtonStyleSelectedName); + + public Orientation Orientation + { + get => (Orientation)GetValue(OrientationProperty); + set => SetValue(OrientationProperty, value); + } + + public int ItemsCount + { + get => (int)GetValue(ItemsCountProperty); + set => SetValue(ItemsCountProperty, value); + } + + public int ItemIndex + { + get => (int)GetValue(ItemIndexProperty); + set + { + using (_atomicLock.EnterScope()) + { + int itemsCount = ItemsCount; + if (itemsCount == 0) + { + return; + } + + if (value < 0) + { + value = itemsCount - 1; + } + + if (value >= itemsCount) + { + value = 0; + } + + SetValue(ItemIndexProperty, value); + } + } + } + + public NewPipsPagerSelectionMode SelectionMode + { + get => (NewPipsPagerSelectionMode)GetValue(SelectionModeProperty); + set => SetValue(SelectionModeProperty, value); + } + + public NewPipsPagerNavigationMode PreviousNavigationButtonMode + { + get => (NewPipsPagerNavigationMode)GetValue(PreviousNavigationButtonModeProperty); + set => SetValue(PreviousNavigationButtonModeProperty, value); + } + + public NewPipsPagerNavigationMode NextNavigationButtonMode + { + get => (NewPipsPagerNavigationMode)GetValue(NextNavigationButtonModeProperty); + set => SetValue(NextNavigationButtonModeProperty, value); + } + + #endregion + + #region Fields + + private readonly Lock _atomicLock = new(); + + private int[] _itemsDummy = []; + + #endregion + + #region Dependency Change Methods + + private static void UpdatePreviousButtonVisualState(NewPipsPager pager) + { + if (!pager._isTemplateLoaded) + { + return; + } + + NewPipsPagerNavigationMode mode = pager.PreviousNavigationButtonMode; + if (mode == NewPipsPagerNavigationMode.Hidden) + { + VisualStateManager.GoToState(pager, NavButtonStatePreviousPageButtonCollapsed, true); + return; + } + + pager._previousPageButton.IsEnabled = true; + VisualStateManager.GoToState(pager, NavButtonStatePreviousPageButtonVisible, true); + if (mode == NewPipsPagerNavigationMode.Visible) + { + return; + } + + if (pager.ItemIndex > 0) + { + return; + } + + pager._previousPageButton.IsEnabled = false; + VisualStateManager.GoToState(pager, NavButtonStatePreviousPageButtonHidden, true); + } + + private static void UpdateNextButtonVisualState(NewPipsPager pager) + { + if (!pager._isTemplateLoaded) + { + return; + } + + NewPipsPagerNavigationMode mode = pager.NextNavigationButtonMode; + if (mode == NewPipsPagerNavigationMode.Hidden) + { + VisualStateManager.GoToState(pager, NavButtonStateNextPageButtonCollapsed, true); + return; + } + + pager._nextPageButton.IsEnabled = true; + VisualStateManager.GoToState(pager, NavButtonStateNextPageButtonVisible, true); + if (mode == NewPipsPagerNavigationMode.Visible) + { + return; + } + + if (pager.ItemIndex + 1 < pager.ItemsCount) + { + return; + } + + pager._nextPageButton.IsEnabled = false; + VisualStateManager.GoToState(pager, NavButtonStateNextPageButtonHidden, true); + } + + private static void UpdateAndBringSelectedPipToView(NewPipsPager pager, int newIndex, int oldIndex) + { + if (pager.UpdateSelectedPipStyle(newIndex, oldIndex) is not { } asButton) + { + return; + } + + BringIntoViewOptions options = new() + { + AnimationDesired = true + }; + if (pager.Orientation == Orientation.Horizontal) + { + options.HorizontalAlignmentRatio = 0.5d; + } + else + { + options.VerticalAlignmentRatio = 0.5d; + } + asButton.StartBringIntoView(options); + } + + private Button? UpdateSelectedPipStyle(int newIndex, int oldIndex) + { + if (!_isTemplateLoaded) + { + return null; + } + + ItemsRepeater repeater = _pipsPagerItemsRepeater; + + int childCount = repeater.ItemsSourceView.Count; + bool isUpdateNewChild = newIndex >= 0 && newIndex < childCount; + bool isUpdateOldChild = oldIndex >= 0 && oldIndex < childCount; + + if (ItemsCount == 0) + { + return null; + } + + Button? newIndexPipButton = repeater.GetOrCreateElement(newIndex) as Button; + Button? oldIndexPipButton = repeater.TryGetElement(oldIndex) as Button; + + try + { + if (isUpdateOldChild) + { + AssignPipButtonStyle(oldIndexPipButton, PipButtonStyleNormal); + } + + if (isUpdateNewChild) + { + return AssignPipButtonStyle(newIndexPipButton, PipButtonStyleSelected); + } + + return newIndexPipButton; + } + finally + { + if (newIndex != oldIndex) + { + ItemIndexChanged?.Invoke(this, new ChangedStructItemArgs(oldIndex, newIndex)); + } + } + } + + private static Button? AssignPipButtonStyle(Button? button, Style? style) + { + if (button is null) + { + return button; + } + + button.Style = style; + button.UpdateLayout(); + VisualStateManager.GoToState(button, PipButtonStateNormal, true); + return button; + } + + private static Style? TryGetStyle(string styleName) + { + if (Application.Current.Resources.TryGetValue(styleName, out object styleObj) && + styleObj is Style asStyle) + { + return asStyle; + } + + return null; + } + + private static void PreviousNavigationButtonMode_OnChange(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + UpdatePreviousButtonVisualState((NewPipsPager)d); + } + + private static void NextNavigationButtonMode_OnChange(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + UpdateNextButtonVisualState((NewPipsPager)d); + } + + #endregion + + #region Dependency Properties + + public static readonly DependencyProperty OrientationProperty = DependencyProperty.Register(nameof(Orientation), typeof(Orientation), typeof(NewPipsPager), new PropertyMetadata(Orientation.Vertical, Orientation_OnChange)); + public static readonly DependencyProperty ItemsCountProperty = DependencyProperty.Register(nameof(ItemsCount), typeof(int), typeof(NewPipsPager), new PropertyMetadata(0, ItemsCount_OnChange)); + public static readonly DependencyProperty ItemIndexProperty = DependencyProperty.Register(nameof(ItemIndex), typeof(int), typeof(NewPipsPager), new PropertyMetadata(-1, ItemIndex_OnChange)); + public static readonly DependencyProperty SelectionModeProperty = DependencyProperty.Register(nameof(SelectionMode), typeof(bool), typeof(NewPipsPager), new PropertyMetadata(NewPipsPagerSelectionMode.Click)); + public static readonly DependencyProperty PreviousNavigationButtonModeProperty = DependencyProperty.Register(nameof(PreviousNavigationButtonMode), typeof(NewPipsPagerNavigationMode), typeof(NewPipsPager), new PropertyMetadata(NewPipsPagerNavigationMode.Auto, PreviousNavigationButtonMode_OnChange)); + public static readonly DependencyProperty NextNavigationButtonModeProperty = DependencyProperty.Register(nameof(NextNavigationButtonMode), typeof(NewPipsPagerNavigationMode), typeof(NewPipsPager), new PropertyMetadata(NewPipsPagerNavigationMode.Auto, NextNavigationButtonMode_OnChange)); + + #endregion +} diff --git a/BackgroundTest/CustomControl/NewPipsPager/NewPipsPager.Templates.cs b/BackgroundTest/CustomControl/NewPipsPager/NewPipsPager.Templates.cs new file mode 100644 index 000000000..1d26d4135 --- /dev/null +++ b/BackgroundTest/CustomControl/NewPipsPager/NewPipsPager.Templates.cs @@ -0,0 +1,111 @@ +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using System.Threading; + +namespace BackgroundTest.CustomControl.NewPipsPager; + +[TemplatePart(Name = TemplateNamePreviousPageButton, Type = typeof(Button))] +[TemplatePart(Name = TemplateNameNextPageButton, Type = typeof(Button))] +[TemplatePart(Name = TemplateNamePipsPagerScrollViewer, Type = typeof(ScrollViewer))] +[TemplatePart(Name = TemplateNamePipsPagerItemsRepeater, Type = typeof(ItemsRepeater))] +public partial class NewPipsPager +{ + #region Constants + + private const string TemplateNamePreviousPageButton = "PreviousPageButton"; + private const string TemplateNameNextPageButton = "NextPageButton"; + private const string TemplateNamePipsPagerScrollViewer = "PipsPagerScrollViewer"; + private const string TemplateNamePipsPagerItemsRepeater = "PipsPagerItemsRepeater"; + + private const string ResourceNamePipsPagerButtonSize = "PipsPagerButtonSize"; + + private const string PipButtonStateNormal = "Normal"; + private const string PipButtonStyleNormalName = "NewPipsPagerButtonBaseStyle"; + private const string PipButtonStyleSelectedName = "NewPipsPagerButtonBaseSelectedStyle"; + + private const string NavButtonStatePreviousPageButtonCollapsed = "PreviousPageButtonCollapsed"; + private const string NavButtonStatePreviousPageButtonVisible = "PreviousPageButtonVisible"; + private const string NavButtonStatePreviousPageButtonHidden = "PreviousPageButtonHidden"; + private const string NavButtonStateNextPageButtonCollapsed = "NextPageButtonCollapsed"; + private const string NavButtonStateNextPageButtonVisible = "NextPageButtonVisible"; + private const string NavButtonStateNextPageButtonHidden = "NextPageButtonHidden"; + + #endregion + + #region Fields + + private Button _previousPageButton = null!; + private Button _nextPageButton = null!; + private ScrollViewer _pipsPagerScrollViewer = null!; + private ItemsRepeater _pipsPagerItemsRepeater = null!; + + private double _pipsButtonSize; + private bool _isTemplateLoaded; + + #endregion + + #region Apply Template Methods + + protected override void OnApplyTemplate() + { + _previousPageButton = this.GetTemplateChild