From f14ff61ca2228605f290a22eed6dc576aec7cf9a Mon Sep 17 00:00:00 2001 From: shatyuka Date: Wed, 8 Jul 2026 22:19:26 +0800 Subject: [PATCH 1/8] Make video player zero-copy --- .../ImageBackground/ClipboardUtility.cs | 14 +- ...BackgroundImage.Events.FrameInitializer.cs | 187 ++------ ...redBackgroundImage.Events.FrameRenderer.cs | 405 ++++-------------- .../LayeredBackgroundImage.Events.Loaders.cs | 112 +++-- .../LayeredBackgroundImage.Events.cs | 29 +- 5 files changed, 210 insertions(+), 537 deletions(-) diff --git a/CollapseLauncher/Classes/GameManagement/ImageBackground/ClipboardUtility.cs b/CollapseLauncher/Classes/GameManagement/ImageBackground/ClipboardUtility.cs index 40a7ac5db1..8b25ec6e68 100644 --- a/CollapseLauncher/Classes/GameManagement/ImageBackground/ClipboardUtility.cs +++ b/CollapseLauncher/Classes/GameManagement/ImageBackground/ClipboardUtility.cs @@ -32,8 +32,6 @@ public static async void CopyCurrentFrameToClipboard( ImageBackgroundManager? instance, FrameToCopyType frameToCopyType) { - bool isBackgroundVideo = false; - try { if (instance == null) @@ -56,7 +54,7 @@ public static async void CopyCurrentFrameToClipboard( .OriginBackgroundImagePath; CanvasRenderTarget? backgroundCanvas = hasBackground - ? isStaticBackgroundShowing ? null : currentElement?.LockCanvasRenderTarget() + ? isStaticBackgroundShowing ? null : await (currentElement?.CaptureCurrentVideoFrame()) : null; ICanvasResourceCreator? canvasDevice = backgroundCanvas; @@ -69,7 +67,8 @@ public static async void CopyCurrentFrameToClipboard( 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. + // Dispose the captured frame since we've copied it. + oldCanvas.Dispose(); } // If it's still null (which most likely an image), get the canvas. @@ -111,13 +110,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) diff --git a/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Events.FrameInitializer.cs b/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Events.FrameInitializer.cs index 74cbf9097d..a7beb002a5 100644 --- a/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Events.FrameInitializer.cs +++ b/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Events.FrameInitializer.cs @@ -1,15 +1,10 @@ using CollapseLauncher.Extension; using Hi3Helper; -using Hi3Helper.Win32.WinRT.SwapChainPanelHelper; -using Microsoft.Graphics.Canvas; -using Microsoft.Graphics.Canvas.UI.Xaml; +using Microsoft.UI.Xaml.Controls; 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 @@ -32,7 +27,6 @@ private void InitializeVideoPlayer() MediaPlayer player = new() { IsLoopingEnabled = true, - IsVideoFrameServerEnabled = true, Volume = AudioVolume.GetClampedVolume(), IsMuted = !IsAudioEnabled, CommandManager = { IsEnabled = false } @@ -40,11 +34,6 @@ private void InitializeVideoPlayer() Interlocked.Exchange(ref _videoPlayer, player); player.MediaOpened += InitializeVideoFrameOnMediaOpened; - - if (!_useSafeFrameRenderer) - { - ((IWinRTObject)player).NativeObject.TryAs(IMediaPlayer5_IID, out _videoPlayerPtr); - } } catch (Exception ex) { @@ -75,81 +64,10 @@ private void InitializeRenderTargetSize(MediaPlaybackSession playbackSession) } catch (Exception ex) { - Logger.LogWriteLine($"[LayeredBackgroundImage::DisposeVideoPlayer] {ex}", - LogType.Error, - true); - } - } - - private unsafe void InitializeRenderTarget() - { - try - { - Interlocked.Exchange(ref _isBlockVideoFrameDraw, 1); // Block frame drawing routine - DisposeRenderTarget(_canvasImageSource == null); // Always ensure the previous render target has been disposed - - _canvasDevice ??= CanvasDevice.GetSharedDevice(); - _canvasImageSource ??= new CanvasVirtualImageSource(_canvasDevice, - _canvasWidth, - _canvasHeight, - 96f); - - _canvasRenderTarget ??= new CanvasRenderTarget(_canvasDevice, - _canvasWidth, - _canvasHeight, - 96f); - - Interlocked.Exchange(ref _useSafeFrameRenderer, false); - if (_useSafeFrameRenderer) - { - return; - } - - _canvasRenderTargetNativePtr = ((IWinRTObject)_canvasRenderTarget).NativeObject.ThisPtr; - _canvasImageSourceNativePtr = ((IWinRTObject)_canvasImageSource).NativeObject.ThisPtr; - - ((IWinRTObject)_canvasRenderTarget).NativeObject.TryAs(typeof(IDirect3DSurface).GUID, out _canvasRenderTargetAsSurfacePtr); - - try - { - if (_functionTableBeginDraw == null! || - _functionTableDrawImage == null! || - _functionTableDispose == null!) - { - SwapChainPanelHelper.GetDirectNativeDelegateForDrawRoutine(_canvasImageSourceNativePtr, - _canvasRenderTargetNativePtr, - out _functionTableBeginDraw, - out _functionTableDrawImage, - out _functionTableDispose, - in _canvasRenderSize); - } - } - catch (Exception e) - { - Interlocked.Exchange(ref _useSafeFrameRenderer, true); // Fallback - - _functionTableBeginDraw = null; - _functionTableDrawImage = null; - _functionTableDispose = null; - Logger.LogWriteLine($"[LayeredBackgroundImage::InitializeRenderTarget] Failed to initialize fast-unsafe method for frame rendering. Fallback to safe renderer.\r\n{e}", - LogType.Error, - true); - } - } - catch (Exception e) - { - Logger.LogWriteLine($"[LayeredBackgroundImage::InitializeRenderTarget] FATAL: {e}", + Logger.LogWriteLine($"[LayeredBackgroundImage::InitializeRenderTargetSize] {ex}", LogType.Error, true); } - finally - { - if (_canvasImageSource != null) - { - SetRenderImageSource(_canvasImageSource.Source); - } - Interlocked.Exchange(ref _isBlockVideoFrameDraw, 0); // Unblock frame drawing routine - } } #endregion @@ -160,6 +78,11 @@ private void DisposeVideoPlayer(bool disposeRenderImageSource = true) { try { + if (_videoPlayer == null!) + { + return; + } + if (_videoPlayer.IsObjectDisposed()) { return; @@ -174,16 +97,16 @@ private void DisposeVideoPlayer(bool disposeRenderImageSource = true) _ = SaveMediaPosition(BackgroundSource, _videoPlayer.Position); } - if (!_useSafeFrameRenderer) - { - NullifyMediaPlayerNativePointers(); - } + DisposeVideoPlayerElement(); Interlocked.Exchange(ref _videoFfmpegMediaSource, null)?.Dispose(); // ReSharper disable once ConstantConditionalAccessQualifier Interlocked.Exchange(ref _videoPlayer, null!)?.Dispose(); - DisposeRenderTarget(disposeRenderImageSource); + if (disposeRenderImageSource) + { + SetRenderImageSource(null); + } } catch (Exception ex) { @@ -197,21 +120,43 @@ private void DisposeVideoPlayer(bool disposeRenderImageSource = true) } } - private void DisposeRenderTarget(bool disposeRenderImageSource = true) + private void DisposeVideoPlayerElement() { + MediaPlayerElement? element = Interlocked.Exchange(ref _videoPlayerElement, null); + if (element == null) + { + return; + } + try { - if (disposeRenderImageSource) + element.SetMediaPlayer(null); + element.Loaded -= MediaPlayerElement_VideoFrameOnLoaded; + element.Unloaded -= MediaPlayerElement_VideoFrameOnUnloaded; + _backgroundGrid?.DispatcherQueue?.TryEnqueue(() => { - SetRenderImageSource(null); - Interlocked.Exchange(ref _canvasImageSource, null); - Interlocked.Exchange(ref _canvasRenderTarget, null)?.Dispose(); - } + try + { + _backgroundGrid?.Children.Remove(element); + } + catch + { + // ignored + } + }); + } + catch + { + // ignored + } + } - if (!_useSafeFrameRenderer) - { - NullifyRenderTargetNativePointers(); - } + private void DisposeRenderTarget(bool disposeRenderImageSource = true) + { + if (!disposeRenderImageSource) return; + try + { + SetRenderImageSource(null); } catch (Exception e) { @@ -222,48 +167,4 @@ private void DisposeRenderTarget(bool disposeRenderImageSource = true) } #endregion - - #region On Device Lost Handler - - private void CanvasDevice_OnDeviceLost() - { - Logger.LogWriteLine("[LayeredBackgroundImage::CanvasDevice_OnDeviceLost] Render device has been lost! Re-initialize render device and textures...", - LogType.Warning, - true); - - // -- Nullify _canvasImageSource so CanvasDevice and other dependencies are reinitialized too - Interlocked.Exchange(ref _canvasImageSource, null!); - InitializeRenderTarget(); - - // Try to unlock video draw progress (if a throw happened inside frame drawing routine) - Interlocked.Exchange(ref _isVideoFrameDrawInProgress, 0); - } - - #endregion - - #region COM Native Pointer Nullifiers - - private void NullifyMediaPlayerNativePointers() - { - // -- Note to myself @neon-nyan: - // Release IMediaPlayer5 reference first, then dispose the whole MediaPlayer. - // This is necessary as we just cast the _videoPlayer object (as IWinRTObject, then took its direct pointer) into IMediaPlayer5. - // If not released, the reference on the IWinRTObject will not be zeroed, causing leak. - if (_videoPlayerPtr != nint.Zero) Marshal.Release(Interlocked.Exchange(ref _videoPlayerPtr, nint.Zero)); - } - - private void NullifyRenderTargetNativePointers() - { - // -- Note to myself @neon-nyan: - // Release IDirect3DSurface reference first, then dispose the whole CanvasRenderTarget. - // This is necessary as we just cast the _canvasRenderTargetNativePtr (which is obtained from IWinRTObject's direct pointer) into IDirect3DSurface. - // If not released, the reference on the IWinRTObject will not be zeroed, causing leak. - if (_canvasRenderTargetAsSurfacePtr != nint.Zero) Marshal.Release(Interlocked.Exchange(ref _canvasRenderTargetAsSurfacePtr, nint.Zero)); - - // -- Nullify IWinRTObject direct pointers. - Interlocked.Exchange(ref _canvasRenderTargetNativePtr, nint.Zero); - Interlocked.Exchange(ref _canvasImageSourceNativePtr, nint.Zero); - } - - #endregion } diff --git a/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Events.FrameRenderer.cs b/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Events.FrameRenderer.cs index 6a3c6394e2..346e409898 100644 --- a/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Events.FrameRenderer.cs +++ b/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Events.FrameRenderer.cs @@ -2,21 +2,20 @@ 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 Microsoft.UI.Xaml.Media.Imaging; using System; using System.Collections.Generic; -using System.Linq; -using System.Runtime.CompilerServices; -using System.Runtime.InteropServices; using System.Threading; +using System.Threading.Tasks; using Windows.Foundation; using Windows.Media.Playback; +using Windows.Storage.Streams; +using System.Runtime.InteropServices.WindowsRuntime; // ReSharper disable CommentTypo // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract // ReSharper disable AccessToModifiedClosure @@ -26,226 +25,20 @@ namespace CollapseLauncher.XAMLs.Theme.CustomControls; public partial class LayeredBackgroundImage { - #region Properties - - // ReSharper disable once InconsistentNaming - private static ref readonly Guid IMediaPlayer5_IID - { - [MethodImpl(MethodImplOptions.AggressiveInlining)] - get - { - ReadOnlySpan span = [ - 253, 55, 229, 207, 106, 248, 70, 68, 191, 77, - 200, 231, 146, 183, 180, 179 - ]; - return ref Unsafe.As(ref MemoryMarshal.GetReference(span)); - } - } - - private bool _useSafeFrameRenderer; - - #endregion - - #region Direct Function Table Call Delegates - - private static unsafe delegate* unmanaged[Stdcall] _functionTableBeginDraw; - private static unsafe delegate* unmanaged[Stdcall] _functionTableDrawImage; - private static unsafe delegate* unmanaged[Stdcall] _functionTableDispose; - - #endregion - #region Fields private static readonly Dictionary 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 MediaPlayerElement? _videoPlayerElement; private CancellationTokenSource? _videoPlayerFadeCts; private FFmpegMediaSource? _videoFfmpegMediaSource; - private long _videoToSkipFrames; - - private int _isFirstInitSkipFrame = 1; - - #endregion - - #region Video Frame Drawing - - private unsafe void VideoPlayer_VideoFrameAvailableUnsafe(MediaPlayer sender, object args) - { - nint drawingSessionPpv = nint.Zero; - try - { - if (Interlocked.Decrement(ref _videoToSkipFrames) > 0) - { - return; - } - - if (_isBlockVideoFrameDraw == 1 || - _canvasImageSourceNativePtr == nint.Zero || - _canvasRenderTargetNativePtr == nint.Zero || - Interlocked.Exchange(ref _isVideoFrameDrawInProgress, 1) == 1) - { -#if DEBUG - Logger.LogWriteLine($@"Skipping frame at: {sender.Position:hh\:mm\:ss\.ffffff}"); -#endif - return; - } - - SwapChainPanelHelper.MediaPlayerCopyFrameUnsafe(_videoPlayerPtr, - _canvasRenderTargetAsSurfacePtr); - - drawingSessionPpv = SwapChainPanelHelper - .CanvasSessionDrawUnsafe(_canvasImageSourceNativePtr, - _canvasRenderTargetNativePtr, - _functionTableBeginDraw, - _functionTableDrawImage, - in _canvasRenderSize); - } - // Device lost error. If happened, reinitialize render target - catch (COMException comEx) when ((uint)comEx.HResult is 0x887A0005u or 0x802B0020u or 0x8899000Cu) - { - DispatcherQueue.TryEnqueue(CanvasDevice_OnDeviceLost); - } - catch (COMException comEx) when ((uint)comEx.HResult is 0x88980801u) - { - // Try to unlock if any error caused by DCOMPOSITION_ERROR_SURFACE_NOT_BEING_RENDERED - Interlocked.Exchange(ref _isVideoFrameDrawInProgress, 0); - } - catch (Exception ex) - { - Logger.LogWriteLine($"[LayeredBackgroundImage::VideoPlayer_VideoFrameAvailableUnsafe|OtherThread] {ex}", - LogType.Error, - true); - } - finally - { - if (drawingSessionPpv != nint.Zero) - { - DispatcherQueue?.TryEnqueue(DispatcherQueuePriority.High, DrawFrame); - } - void DrawFrame() - { - try - { - SwapChainPanelHelper.DrawingDisposeUnsafe(drawingSessionPpv, _functionTableDispose); - } - // Device lost error. If happened, reinitialize render target - catch (COMException comEx) when ((uint)comEx.HResult is 0x887A0005u or 0x802B0020u or 0x8899000Cu) - { - DispatcherQueue.TryEnqueue(CanvasDevice_OnDeviceLost); - } - catch (COMException comEx) when ((uint)comEx.HResult is 0x88980801u) - { - // Try to unlock if any error caused by DCOMPOSITION_ERROR_SURFACE_NOT_BEING_RENDERED - Interlocked.Exchange(ref _isVideoFrameDrawInProgress, 0); - } - catch (Exception ex) - { - Logger.LogWriteLine($"[LayeredBackgroundImage::VideoPlayer_VideoFrameAvailableUnsafe|UIThread] {ex}", - LogType.Error, - true); - } - finally - { - Interlocked.Exchange(ref _isVideoFrameDrawInProgress, 0); - } - } - } - } - - private void VideoPlayer_VideoFrameAvailableSafe(MediaPlayer sender, object args) - { - Unsafe.SkipInit(out CanvasDrawingSession? ds); - - try - { - if (Interlocked.Decrement(ref _videoToSkipFrames) > 0) - { - return; - } - - if (_isBlockVideoFrameDraw == 1 || - _canvasImageSource == null! || - _canvasRenderTarget == null! || - Interlocked.Exchange(ref _isVideoFrameDrawInProgress, 1) == 1) - { -#if DEBUG - Logger.LogWriteLine($@"Skipping frame at: {sender.Position:hh\:mm\:ss\.ffffff}"); -#endif - return; - } - - _videoPlayer?.CopyFrameToVideoSurface(_canvasRenderTarget); - ds = _canvasImageSource?.CreateDrawingSession(default, _canvasRenderSize); - ds?.DrawImage(_canvasRenderTarget, _canvasRenderSize); - } - // Device lost error. If happened, reinitialize render target - catch (COMException comEx) when ((uint)comEx.HResult is 0x887A0005u or 0x802B0020u or 0x8899000Cu) - { - DispatcherQueue.TryEnqueue(CanvasDevice_OnDeviceLost); - } - catch (COMException comEx) when ((uint)comEx.HResult is 0x88980801u) - { - // Try to unlock if any error caused by DCOMPOSITION_ERROR_SURFACE_NOT_BEING_RENDERED - Interlocked.Exchange(ref _isVideoFrameDrawInProgress, 0); - } - catch (Exception ex) - { - Logger.LogWriteLine($"[LayeredBackgroundImage::VideoPlayer_VideoFrameAvailableSafe|OtherThread] {ex}", - LogType.Error, - true); - } - finally - { - DispatcherQueue?.TryEnqueue(DispatcherQueuePriority.High, DrawFrame); - } - - return; - - void DrawFrame() - { - try - { - ds?.Dispose(); - } - // Device lost error. If happened, reinitialize render target - catch (COMException comEx) when ((uint)comEx.HResult is 0x887A0005u or 0x802B0020u or 0x8899000Cu) - { - CanvasDevice_OnDeviceLost(); - } - catch (COMException comEx) when ((uint)comEx.HResult is 0x88980801u) - { - // Try to unlock if any error caused by DCOMPOSITION_ERROR_SURFACE_NOT_BEING_RENDERED - Interlocked.Exchange(ref _isVideoFrameDrawInProgress, 0); - } - catch (Exception ex) - { - Logger.LogWriteLine($"[LayeredBackgroundImage::VideoPlayer_VideoFrameAvailableSafe|UIThread] {ex}", - LogType.Error, - true); - } - finally - { - Interlocked.Exchange(ref _isVideoFrameDrawInProgress, 0); - } - } - } #endregion @@ -253,66 +46,14 @@ void DrawFrame() 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); - } + if (renderSource != null) return; + DisposeVideoPlayerElement(); } #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 @@ -355,73 +96,34 @@ private static void AudioVolume_OnChange(DependencyObject d, DependencyPropertyC } public void InitializeAndPlayVideoView(Action? actionOnPlay = null, - bool reinitializeImageSource = true, - double volumeFadeDurationMs = 1000d, - double volumeFadeResolutionMs = 10d, - CancellationToken token = default) + double volumeFadeDurationMs = 1000d, + double volumeFadeResolutionMs = 10d, + CancellationToken token = default) { try { - if (_videoPlayer != null!) + MediaPlayer? player = _videoPlayer; + if (player != null!) { // Only initialize once. if (Interlocked.Exchange(ref _isVideoInitialized, 1) == 0) { - InitializeRenderTarget(); - // Seek to last position if source was the same - if (_videoPlayer.CanSeek && + if (player.CanSeek && TryGetSourceHashCode(BackgroundSource, out int lastSourceHashCode) && SharedLastMediaPosition.TryGetValue(lastSourceHashCode, out TimeSpan lastPosition)) { - _videoPlayer.Position = lastPosition; + player.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 - } + player.PlaybackSession.PositionChanged += MediaDurationPosition_OnChangedBridge; + player.PlaybackSession.PlaybackStateChanged += NotifyVideoLoadedOnStateChanged; } - // 2026/04/19: Now the VideoFrameAvailable handle is used to notify NotifyVideoLoaded -> NotifyImageLoaded. - _videoPlayer.VideoFrameAvailable += NotifyVideoLoaded; - - PlayVideoView(ActionVideoAfterPlay, + PlayVideoView(null, volumeFadeDurationMs, volumeFadeResolutionMs, token); - - void ActionVideoAfterPlay() - { - _videoPlayer.VideoFrameAvailable += !_useSafeFrameRenderer - ? VideoPlayer_VideoFrameAvailableUnsafe - : VideoPlayer_VideoFrameAvailableSafe; - } } else if (BackgroundSource != null) { @@ -454,31 +156,23 @@ public void DisposeAndPauseVideoView(Action? actionOnPause 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(); - if (_videoPlayer == null!) + MediaPlayer? player = _videoPlayer; + if (player == null!) { actionAfterPause?.Invoke(); return; } - if (disposeVideoPlayer) + if (disposeVideoPlayer && player.PlaybackSession != null) { - // Unsubscribe early to avoid wasted skipped frames. - _videoPlayer.VideoFrameAvailable -= !_useSafeFrameRenderer - ? VideoPlayer_VideoFrameAvailableUnsafe - : VideoPlayer_VideoFrameAvailableSafe; - - _videoPlayer.PlaybackSession.PositionChanged -= MediaDurationPosition_OnChangedBridge; + player.PlaybackSession.PlaybackStateChanged -= NotifyVideoLoadedOnStateChanged; + player.PlaybackSession.PositionChanged -= MediaDurationPosition_OnChangedBridge; } - // Interlocked.Exchange(ref _isBlockVideoFrameDraw, 1); // Blocks early PauseVideoView(Impl, volumeFadeDurationMs, volumeFadeResolutionMs, token); return; @@ -551,4 +245,59 @@ void ActionAfterPauseInject() } #endregion + + #region Video Loaded Notification + + private void NotifyVideoLoadedOnStateChanged(MediaPlaybackSession sender, object args) + { + if (sender.PlaybackState != MediaPlaybackState.Playing) + { + return; + } + + sender.PlaybackStateChanged -= NotifyVideoLoadedOnStateChanged; + DispatcherQueue.TryEnqueue(DispatcherQueuePriority.Low, () => + { + if (!this.IsObjectDisposed()) + { + NotifyImageLoaded(); + } + }); + } + + #endregion + + #region Video Frame Capture + + public async Task CaptureCurrentVideoFrame() + { + MediaPlayerElement? element = _videoPlayerElement; + if (element == null || _canvasWidth <= 0 || _canvasHeight <= 0) + { + return null; + } + + try + { + RenderTargetBitmap rtb = new(); + await rtb.RenderAsync(element, _canvasWidth, _canvasHeight); + + IBuffer pixelBuffer = await rtb.GetPixelsAsync(); + byte[] pixels = pixelBuffer.ToArray(); + + CanvasDevice device = CanvasDevice.GetSharedDevice(); + CanvasRenderTarget renderTarget = new(device, _canvasWidth, _canvasHeight, 96f); + renderTarget.SetPixelBytes(pixels); + return renderTarget; + } + catch (Exception ex) + { + Logger.LogWriteLine($"[LayeredBackgroundImage::CaptureCurrentVideoFrame] {ex}", + LogType.Error, + true); + return null; + } + } + + #endregion } diff --git a/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Events.Loaders.cs b/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Events.Loaders.cs index bc32cf9094..55e1f3bb43 100644 --- a/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Events.Loaders.cs +++ b/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Events.Loaders.cs @@ -416,11 +416,12 @@ private static async Task LoadVideoFromSourceAsync( { await Task.Delay(150); - // Unsubscribe frame renderer event to avoid double call, and then mark deinitialization. Interlocked.Exchange(ref instance._isVideoInitialized, 0); - player?.VideoFrameAvailable -= !instance._useSafeFrameRenderer - ? instance.VideoPlayer_VideoFrameAvailableUnsafe - : instance.VideoPlayer_VideoFrameAvailableSafe; + if (instance._videoPlayer?.PlaybackSession != null) + { + instance._videoPlayer.PlaybackSession.PlaybackStateChanged -= instance.NotifyVideoLoadedOnStateChanged; + instance._videoPlayer.PlaybackSession.PositionChanged -= instance.MediaDurationPosition_OnChangedBridge; + } ffmpegMediaSource.Dispose(); @@ -510,42 +511,35 @@ void Impl() SetValue(MediaDurationProperty, sender.NaturalDuration); SetValue(IsCurrentMediaSeekableProperty, sender.CanSeek); - // Create instance - Image image = new() + DisposeVideoPlayerElement(); + + MediaPlayerElement playerElement = new() { Tag = (_backgroundGrid, this), - Name = "VideoRenderFrame" + Name = "VideoRenderFrame", + AreTransportControlsEnabled = false, + Stretch = BackgroundStretch, + HorizontalAlignment = BackgroundHorizontalAlignment, + VerticalAlignment = BackgroundVerticalAlignment }; - // 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; + playerElement.SetMediaPlayer(_videoPlayer); + + Interlocked.Exchange(ref _videoPlayerElement, playerElement); + + playerElement.Loaded += MediaPlayerElement_VideoFrameOnLoaded; + playerElement.Unloaded += MediaPlayerElement_VideoFrameOnUnloaded; - // Add to children - image.Transitions.Add(new ContentThemeTransition()); - _backgroundGrid.Children.Add(image); + playerElement.Transitions.Add(new ContentThemeTransition()); + _backgroundGrid.Children.Add(playerElement); } } - private static void Image_VideoFrameOnLoaded(object sender, RoutedEventArgs e) + private static void MediaPlayerElement_VideoFrameOnLoaded(object sender, RoutedEventArgs e) { - if (sender is not Image { Tag: ValueTuple parentGrid }) + if (sender is not MediaPlayerElement { Tag: ValueTuple parentGrid }) { return; } @@ -554,12 +548,12 @@ private static void Image_VideoFrameOnLoaded(object sender, RoutedEventArgs e) Interlocked.Exchange(ref parentGrid.Item2._videoState, VideoState.Playing); parentGrid.Item2.InitializeAndPlayVideoView(); - Image_ImageOpened(sender, e); + MediaPlayerElement_ImageOpened(sender, e); } - private static void Image_VideoFrameOnUnloaded(object sender, RoutedEventArgs e) + private static void MediaPlayerElement_VideoFrameOnUnloaded(object sender, RoutedEventArgs e) { - if (sender is not Image { Tag: ValueTuple parentGrid }) + if (sender is not MediaPlayerElement { Tag: ValueTuple parentGrid }) { return; } @@ -567,6 +561,37 @@ private static void Image_VideoFrameOnUnloaded(object sender, RoutedEventArgs e) parentGrid.Item2.DisposeAndPauseVideoView(); } + private static void MediaPlayerElement_ImageOpened(object sender, RoutedEventArgs e) + { + if (sender is not MediaPlayerElement { Tag: ValueTuple parentGrid } playerElement) + { + 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 playerElement to the grid + ClearMediaGrid(parentGrid.Item1, playerElement); + + // Remove transition once loaded + playerElement.Transitions.Clear(); + } + private static void Image_ImageOpened(object sender, RoutedEventArgs e) { if (sender is not Image { Tag: ValueTuple parentGrid } image) @@ -638,15 +663,22 @@ private static void ClearMediaGrid(Grid grid, UIElement? except = null) grid.Children.Where(x => x != except) .ToList(); - foreach (Image image in elementExcepted.OfType()) + foreach (UIElement element in elementExcepted) { - // 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; + switch (element) + { + case MediaPlayerElement playerElement: + playerElement.Loaded -= MediaPlayerElement_VideoFrameOnLoaded; + playerElement.Unloaded -= MediaPlayerElement_VideoFrameOnUnloaded; + playerElement.SetMediaPlayer(null); + break; + case Image image: + // This one is for Image. The source will always guarantee to call this event. + image.ImageOpened -= Image_ImageOpened; + // Clears the loaded ImageSource + image.Source = null; + break; + } } foreach (UIElement element in elementExcepted) diff --git a/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Events.cs b/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Events.cs index 717f31ee51..b89a8b40e8 100644 --- a/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Events.cs +++ b/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Events.cs @@ -392,28 +392,27 @@ private void NotifyImageLoaded() ?? _backgroundGrid.Children.LastOrDefault(); Size size = default; - if (canvasElement is Image image) + switch (canvasElement) { - if (image.Source is BitmapSource asBitmapSource) - { - size = new Size(asBitmapSource.PixelWidth, asBitmapSource.PixelHeight); - } - else - { - size = image.RenderSize; - } + case Image image: + if (image.Source is BitmapSource asBitmapSource) + { + size = new Size(asBitmapSource.PixelWidth, asBitmapSource.PixelHeight); + } + else + { + size = image.RenderSize; + } + break; + case MediaPlayerElement playerElement: + size = playerElement.RenderSize; + break; } 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 From f22b4fe6921f88470b8c28096c5f8c0b75e93ae9 Mon Sep 17 00:00:00 2001 From: shatyuka Date: Wed, 8 Jul 2026 23:40:32 +0800 Subject: [PATCH 2/8] Enable read-ahead buffer to reduce video playback stutter --- .../CustomControls/LayeredBackgroundImage.Events.Loaders.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Events.Loaders.cs b/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Events.Loaders.cs index 55e1f3bb43..1a3c329454 100644 --- a/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Events.Loaders.cs +++ b/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Events.Loaders.cs @@ -379,6 +379,11 @@ private static async Task LoadVideoFromSourceAsync( VideoOutputAllowBgra8 = true, VideoOutputAllowNv12 = true, VideoDecoderMode = instance.FfmpegDecoderMode + }, + General = + { + ReadAheadBufferEnabled = true, + ReadAheadBufferDuration = TimeSpan.FromSeconds(10) } }; From 451daa1b94de0af45d827a44444f884ae78e72f1 Mon Sep 17 00:00:00 2001 From: shatyuka Date: Thu, 9 Jul 2026 01:09:09 +0800 Subject: [PATCH 3/8] Make cache duration longer --- .../CustomControls/LayeredBackgroundImage.Events.Loaders.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Events.Loaders.cs b/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Events.Loaders.cs index 1a3c329454..fcb3b5057a 100644 --- a/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Events.Loaders.cs +++ b/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Events.Loaders.cs @@ -383,7 +383,7 @@ private static async Task LoadVideoFromSourceAsync( General = { ReadAheadBufferEnabled = true, - ReadAheadBufferDuration = TimeSpan.FromSeconds(10) + ReadAheadBufferDuration = TimeSpan.FromSeconds(15) } }; From 7790427e6bc6567859849aeb7b0d5a716e62ca34 Mon Sep 17 00:00:00 2001 From: shatyuka Date: Thu, 9 Jul 2026 03:40:42 +0800 Subject: [PATCH 4/8] Revert "Make video player zero-copy" This reverts commit f14ff61ca2228605f290a22eed6dc576aec7cf9a. --- .../ImageBackground/ClipboardUtility.cs | 14 +- ...BackgroundImage.Events.FrameInitializer.cs | 187 ++++++-- ...redBackgroundImage.Events.FrameRenderer.cs | 405 ++++++++++++++---- .../LayeredBackgroundImage.Events.Loaders.cs | 112 ++--- .../LayeredBackgroundImage.Events.cs | 29 +- 5 files changed, 537 insertions(+), 210 deletions(-) diff --git a/CollapseLauncher/Classes/GameManagement/ImageBackground/ClipboardUtility.cs b/CollapseLauncher/Classes/GameManagement/ImageBackground/ClipboardUtility.cs index 8b25ec6e68..40a7ac5db1 100644 --- a/CollapseLauncher/Classes/GameManagement/ImageBackground/ClipboardUtility.cs +++ b/CollapseLauncher/Classes/GameManagement/ImageBackground/ClipboardUtility.cs @@ -32,6 +32,8 @@ public static async void CopyCurrentFrameToClipboard( ImageBackgroundManager? instance, FrameToCopyType frameToCopyType) { + bool isBackgroundVideo = false; + try { if (instance == null) @@ -54,7 +56,7 @@ public static async void CopyCurrentFrameToClipboard( .OriginBackgroundImagePath; CanvasRenderTarget? backgroundCanvas = hasBackground - ? isStaticBackgroundShowing ? null : await (currentElement?.CaptureCurrentVideoFrame()) + ? isStaticBackgroundShowing ? null : currentElement?.LockCanvasRenderTarget() : null; ICanvasResourceCreator? canvasDevice = backgroundCanvas; @@ -67,8 +69,7 @@ public static async void CopyCurrentFrameToClipboard( backgroundCanvas = new CanvasRenderTarget(canvasDevice, backgroundCanvas.Size._width, backgroundCanvas.Size._height, 96f); backgroundCanvas.CopyPixelsFromBitmap(oldCanvas); - // Dispose the captured frame since we've copied it. - oldCanvas.Dispose(); + 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. @@ -110,6 +111,13 @@ public static async void CopyCurrentFrameToClipboard( LogType.Error, true); } + finally + { + if (isBackgroundVideo) + { + instance?.CurrentBackgroundElement?.UnlockCanvasRenderTarget(); + } + } } private static CanvasRenderTarget ResizeCanvasToSizeOf(CanvasRenderTarget canvasToResize, int width, int height) diff --git a/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Events.FrameInitializer.cs b/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Events.FrameInitializer.cs index a7beb002a5..74cbf9097d 100644 --- a/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Events.FrameInitializer.cs +++ b/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Events.FrameInitializer.cs @@ -1,10 +1,15 @@ using CollapseLauncher.Extension; using Hi3Helper; -using Microsoft.UI.Xaml.Controls; +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 @@ -27,6 +32,7 @@ private void InitializeVideoPlayer() MediaPlayer player = new() { IsLoopingEnabled = true, + IsVideoFrameServerEnabled = true, Volume = AudioVolume.GetClampedVolume(), IsMuted = !IsAudioEnabled, CommandManager = { IsEnabled = false } @@ -34,6 +40,11 @@ private void InitializeVideoPlayer() Interlocked.Exchange(ref _videoPlayer, player); player.MediaOpened += InitializeVideoFrameOnMediaOpened; + + if (!_useSafeFrameRenderer) + { + ((IWinRTObject)player).NativeObject.TryAs(IMediaPlayer5_IID, out _videoPlayerPtr); + } } catch (Exception ex) { @@ -64,10 +75,81 @@ private void InitializeRenderTargetSize(MediaPlaybackSession playbackSession) } catch (Exception ex) { - Logger.LogWriteLine($"[LayeredBackgroundImage::InitializeRenderTargetSize] {ex}", + Logger.LogWriteLine($"[LayeredBackgroundImage::DisposeVideoPlayer] {ex}", + LogType.Error, + true); + } + } + + private unsafe void InitializeRenderTarget() + { + try + { + Interlocked.Exchange(ref _isBlockVideoFrameDraw, 1); // Block frame drawing routine + DisposeRenderTarget(_canvasImageSource == null); // Always ensure the previous render target has been disposed + + _canvasDevice ??= CanvasDevice.GetSharedDevice(); + _canvasImageSource ??= new CanvasVirtualImageSource(_canvasDevice, + _canvasWidth, + _canvasHeight, + 96f); + + _canvasRenderTarget ??= new CanvasRenderTarget(_canvasDevice, + _canvasWidth, + _canvasHeight, + 96f); + + Interlocked.Exchange(ref _useSafeFrameRenderer, false); + if (_useSafeFrameRenderer) + { + return; + } + + _canvasRenderTargetNativePtr = ((IWinRTObject)_canvasRenderTarget).NativeObject.ThisPtr; + _canvasImageSourceNativePtr = ((IWinRTObject)_canvasImageSource).NativeObject.ThisPtr; + + ((IWinRTObject)_canvasRenderTarget).NativeObject.TryAs(typeof(IDirect3DSurface).GUID, out _canvasRenderTargetAsSurfacePtr); + + try + { + if (_functionTableBeginDraw == null! || + _functionTableDrawImage == null! || + _functionTableDispose == null!) + { + SwapChainPanelHelper.GetDirectNativeDelegateForDrawRoutine(_canvasImageSourceNativePtr, + _canvasRenderTargetNativePtr, + out _functionTableBeginDraw, + out _functionTableDrawImage, + out _functionTableDispose, + in _canvasRenderSize); + } + } + catch (Exception e) + { + Interlocked.Exchange(ref _useSafeFrameRenderer, true); // Fallback + + _functionTableBeginDraw = null; + _functionTableDrawImage = null; + _functionTableDispose = null; + Logger.LogWriteLine($"[LayeredBackgroundImage::InitializeRenderTarget] Failed to initialize fast-unsafe method for frame rendering. Fallback to safe renderer.\r\n{e}", + LogType.Error, + true); + } + } + catch (Exception e) + { + Logger.LogWriteLine($"[LayeredBackgroundImage::InitializeRenderTarget] FATAL: {e}", LogType.Error, true); } + finally + { + if (_canvasImageSource != null) + { + SetRenderImageSource(_canvasImageSource.Source); + } + Interlocked.Exchange(ref _isBlockVideoFrameDraw, 0); // Unblock frame drawing routine + } } #endregion @@ -78,11 +160,6 @@ private void DisposeVideoPlayer(bool disposeRenderImageSource = true) { try { - if (_videoPlayer == null!) - { - return; - } - if (_videoPlayer.IsObjectDisposed()) { return; @@ -97,16 +174,16 @@ private void DisposeVideoPlayer(bool disposeRenderImageSource = true) _ = SaveMediaPosition(BackgroundSource, _videoPlayer.Position); } - DisposeVideoPlayerElement(); + if (!_useSafeFrameRenderer) + { + NullifyMediaPlayerNativePointers(); + } Interlocked.Exchange(ref _videoFfmpegMediaSource, null)?.Dispose(); // ReSharper disable once ConstantConditionalAccessQualifier Interlocked.Exchange(ref _videoPlayer, null!)?.Dispose(); - if (disposeRenderImageSource) - { - SetRenderImageSource(null); - } + DisposeRenderTarget(disposeRenderImageSource); } catch (Exception ex) { @@ -120,43 +197,21 @@ private void DisposeVideoPlayer(bool disposeRenderImageSource = true) } } - private void DisposeVideoPlayerElement() + private void DisposeRenderTarget(bool disposeRenderImageSource = true) { - MediaPlayerElement? element = Interlocked.Exchange(ref _videoPlayerElement, null); - if (element == null) - { - return; - } - try { - element.SetMediaPlayer(null); - element.Loaded -= MediaPlayerElement_VideoFrameOnLoaded; - element.Unloaded -= MediaPlayerElement_VideoFrameOnUnloaded; - _backgroundGrid?.DispatcherQueue?.TryEnqueue(() => + if (disposeRenderImageSource) { - try - { - _backgroundGrid?.Children.Remove(element); - } - catch - { - // ignored - } - }); - } - catch - { - // ignored - } - } + SetRenderImageSource(null); + Interlocked.Exchange(ref _canvasImageSource, null); + Interlocked.Exchange(ref _canvasRenderTarget, null)?.Dispose(); + } - private void DisposeRenderTarget(bool disposeRenderImageSource = true) - { - if (!disposeRenderImageSource) return; - try - { - SetRenderImageSource(null); + if (!_useSafeFrameRenderer) + { + NullifyRenderTargetNativePointers(); + } } catch (Exception e) { @@ -167,4 +222,48 @@ private void DisposeRenderTarget(bool disposeRenderImageSource = true) } #endregion + + #region On Device Lost Handler + + private void CanvasDevice_OnDeviceLost() + { + Logger.LogWriteLine("[LayeredBackgroundImage::CanvasDevice_OnDeviceLost] Render device has been lost! Re-initialize render device and textures...", + LogType.Warning, + true); + + // -- Nullify _canvasImageSource so CanvasDevice and other dependencies are reinitialized too + Interlocked.Exchange(ref _canvasImageSource, null!); + InitializeRenderTarget(); + + // Try to unlock video draw progress (if a throw happened inside frame drawing routine) + Interlocked.Exchange(ref _isVideoFrameDrawInProgress, 0); + } + + #endregion + + #region COM Native Pointer Nullifiers + + private void NullifyMediaPlayerNativePointers() + { + // -- Note to myself @neon-nyan: + // Release IMediaPlayer5 reference first, then dispose the whole MediaPlayer. + // This is necessary as we just cast the _videoPlayer object (as IWinRTObject, then took its direct pointer) into IMediaPlayer5. + // If not released, the reference on the IWinRTObject will not be zeroed, causing leak. + if (_videoPlayerPtr != nint.Zero) Marshal.Release(Interlocked.Exchange(ref _videoPlayerPtr, nint.Zero)); + } + + private void NullifyRenderTargetNativePointers() + { + // -- Note to myself @neon-nyan: + // Release IDirect3DSurface reference first, then dispose the whole CanvasRenderTarget. + // This is necessary as we just cast the _canvasRenderTargetNativePtr (which is obtained from IWinRTObject's direct pointer) into IDirect3DSurface. + // If not released, the reference on the IWinRTObject will not be zeroed, causing leak. + if (_canvasRenderTargetAsSurfacePtr != nint.Zero) Marshal.Release(Interlocked.Exchange(ref _canvasRenderTargetAsSurfacePtr, nint.Zero)); + + // -- Nullify IWinRTObject direct pointers. + Interlocked.Exchange(ref _canvasRenderTargetNativePtr, nint.Zero); + Interlocked.Exchange(ref _canvasImageSourceNativePtr, nint.Zero); + } + + #endregion } diff --git a/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Events.FrameRenderer.cs b/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Events.FrameRenderer.cs index 346e409898..6a3c6394e2 100644 --- a/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Events.FrameRenderer.cs +++ b/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Events.FrameRenderer.cs @@ -2,20 +2,21 @@ 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 Microsoft.UI.Xaml.Media.Imaging; using System; using System.Collections.Generic; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; using System.Threading; -using System.Threading.Tasks; using Windows.Foundation; using Windows.Media.Playback; -using Windows.Storage.Streams; -using System.Runtime.InteropServices.WindowsRuntime; // ReSharper disable CommentTypo // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract // ReSharper disable AccessToModifiedClosure @@ -25,20 +26,226 @@ namespace CollapseLauncher.XAMLs.Theme.CustomControls; public partial class LayeredBackgroundImage { + #region Properties + + // ReSharper disable once InconsistentNaming + private static ref readonly Guid IMediaPlayer5_IID + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get + { + ReadOnlySpan span = [ + 253, 55, 229, 207, 106, 248, 70, 68, 191, 77, + 200, 231, 146, 183, 180, 179 + ]; + return ref Unsafe.As(ref MemoryMarshal.GetReference(span)); + } + } + + private bool _useSafeFrameRenderer; + + #endregion + + #region Direct Function Table Call Delegates + + private static unsafe delegate* unmanaged[Stdcall] _functionTableBeginDraw; + private static unsafe delegate* unmanaged[Stdcall] _functionTableDrawImage; + private static unsafe delegate* unmanaged[Stdcall] _functionTableDispose; + + #endregion + #region Fields private static readonly Dictionary 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 MediaPlayerElement? _videoPlayerElement; + private nint _videoPlayerPtr = nint.Zero; private CancellationTokenSource? _videoPlayerFadeCts; private FFmpegMediaSource? _videoFfmpegMediaSource; + private long _videoToSkipFrames; + + private int _isFirstInitSkipFrame = 1; + + #endregion + + #region Video Frame Drawing + + private unsafe void VideoPlayer_VideoFrameAvailableUnsafe(MediaPlayer sender, object args) + { + nint drawingSessionPpv = nint.Zero; + try + { + if (Interlocked.Decrement(ref _videoToSkipFrames) > 0) + { + return; + } + + if (_isBlockVideoFrameDraw == 1 || + _canvasImageSourceNativePtr == nint.Zero || + _canvasRenderTargetNativePtr == nint.Zero || + Interlocked.Exchange(ref _isVideoFrameDrawInProgress, 1) == 1) + { +#if DEBUG + Logger.LogWriteLine($@"Skipping frame at: {sender.Position:hh\:mm\:ss\.ffffff}"); +#endif + return; + } + + SwapChainPanelHelper.MediaPlayerCopyFrameUnsafe(_videoPlayerPtr, + _canvasRenderTargetAsSurfacePtr); + + drawingSessionPpv = SwapChainPanelHelper + .CanvasSessionDrawUnsafe(_canvasImageSourceNativePtr, + _canvasRenderTargetNativePtr, + _functionTableBeginDraw, + _functionTableDrawImage, + in _canvasRenderSize); + } + // Device lost error. If happened, reinitialize render target + catch (COMException comEx) when ((uint)comEx.HResult is 0x887A0005u or 0x802B0020u or 0x8899000Cu) + { + DispatcherQueue.TryEnqueue(CanvasDevice_OnDeviceLost); + } + catch (COMException comEx) when ((uint)comEx.HResult is 0x88980801u) + { + // Try to unlock if any error caused by DCOMPOSITION_ERROR_SURFACE_NOT_BEING_RENDERED + Interlocked.Exchange(ref _isVideoFrameDrawInProgress, 0); + } + catch (Exception ex) + { + Logger.LogWriteLine($"[LayeredBackgroundImage::VideoPlayer_VideoFrameAvailableUnsafe|OtherThread] {ex}", + LogType.Error, + true); + } + finally + { + if (drawingSessionPpv != nint.Zero) + { + DispatcherQueue?.TryEnqueue(DispatcherQueuePriority.High, DrawFrame); + } + void DrawFrame() + { + try + { + SwapChainPanelHelper.DrawingDisposeUnsafe(drawingSessionPpv, _functionTableDispose); + } + // Device lost error. If happened, reinitialize render target + catch (COMException comEx) when ((uint)comEx.HResult is 0x887A0005u or 0x802B0020u or 0x8899000Cu) + { + DispatcherQueue.TryEnqueue(CanvasDevice_OnDeviceLost); + } + catch (COMException comEx) when ((uint)comEx.HResult is 0x88980801u) + { + // Try to unlock if any error caused by DCOMPOSITION_ERROR_SURFACE_NOT_BEING_RENDERED + Interlocked.Exchange(ref _isVideoFrameDrawInProgress, 0); + } + catch (Exception ex) + { + Logger.LogWriteLine($"[LayeredBackgroundImage::VideoPlayer_VideoFrameAvailableUnsafe|UIThread] {ex}", + LogType.Error, + true); + } + finally + { + Interlocked.Exchange(ref _isVideoFrameDrawInProgress, 0); + } + } + } + } + + private void VideoPlayer_VideoFrameAvailableSafe(MediaPlayer sender, object args) + { + Unsafe.SkipInit(out CanvasDrawingSession? ds); + + try + { + if (Interlocked.Decrement(ref _videoToSkipFrames) > 0) + { + return; + } + + if (_isBlockVideoFrameDraw == 1 || + _canvasImageSource == null! || + _canvasRenderTarget == null! || + Interlocked.Exchange(ref _isVideoFrameDrawInProgress, 1) == 1) + { +#if DEBUG + Logger.LogWriteLine($@"Skipping frame at: {sender.Position:hh\:mm\:ss\.ffffff}"); +#endif + return; + } + + _videoPlayer?.CopyFrameToVideoSurface(_canvasRenderTarget); + ds = _canvasImageSource?.CreateDrawingSession(default, _canvasRenderSize); + ds?.DrawImage(_canvasRenderTarget, _canvasRenderSize); + } + // Device lost error. If happened, reinitialize render target + catch (COMException comEx) when ((uint)comEx.HResult is 0x887A0005u or 0x802B0020u or 0x8899000Cu) + { + DispatcherQueue.TryEnqueue(CanvasDevice_OnDeviceLost); + } + catch (COMException comEx) when ((uint)comEx.HResult is 0x88980801u) + { + // Try to unlock if any error caused by DCOMPOSITION_ERROR_SURFACE_NOT_BEING_RENDERED + Interlocked.Exchange(ref _isVideoFrameDrawInProgress, 0); + } + catch (Exception ex) + { + Logger.LogWriteLine($"[LayeredBackgroundImage::VideoPlayer_VideoFrameAvailableSafe|OtherThread] {ex}", + LogType.Error, + true); + } + finally + { + DispatcherQueue?.TryEnqueue(DispatcherQueuePriority.High, DrawFrame); + } + + return; + + void DrawFrame() + { + try + { + ds?.Dispose(); + } + // Device lost error. If happened, reinitialize render target + catch (COMException comEx) when ((uint)comEx.HResult is 0x887A0005u or 0x802B0020u or 0x8899000Cu) + { + CanvasDevice_OnDeviceLost(); + } + catch (COMException comEx) when ((uint)comEx.HResult is 0x88980801u) + { + // Try to unlock if any error caused by DCOMPOSITION_ERROR_SURFACE_NOT_BEING_RENDERED + Interlocked.Exchange(ref _isVideoFrameDrawInProgress, 0); + } + catch (Exception ex) + { + Logger.LogWriteLine($"[LayeredBackgroundImage::VideoPlayer_VideoFrameAvailableSafe|UIThread] {ex}", + LogType.Error, + true); + } + finally + { + Interlocked.Exchange(ref _isVideoFrameDrawInProgress, 0); + } + } + } #endregion @@ -46,14 +253,66 @@ public partial class LayeredBackgroundImage private void SetRenderImageSource(ImageSource? renderSource) { - if (renderSource != null) return; - DisposeVideoPlayerElement(); + 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 @@ -96,34 +355,73 @@ private static void AudioVolume_OnChange(DependencyObject d, DependencyPropertyC } public void InitializeAndPlayVideoView(Action? actionOnPlay = null, - double volumeFadeDurationMs = 1000d, - double volumeFadeResolutionMs = 10d, - CancellationToken token = default) + bool reinitializeImageSource = true, + double volumeFadeDurationMs = 1000d, + double volumeFadeResolutionMs = 10d, + CancellationToken token = default) { try { - MediaPlayer? player = _videoPlayer; - if (player != null!) + if (_videoPlayer != null!) { // Only initialize once. if (Interlocked.Exchange(ref _isVideoInitialized, 1) == 0) { + InitializeRenderTarget(); + // Seek to last position if source was the same - if (player.CanSeek && + if (_videoPlayer.CanSeek && TryGetSourceHashCode(BackgroundSource, out int lastSourceHashCode) && SharedLastMediaPosition.TryGetValue(lastSourceHashCode, out TimeSpan lastPosition)) { - player.Position = lastPosition; + _videoPlayer.Position = lastPosition; } - player.PlaybackSession.PositionChanged += MediaDurationPosition_OnChangedBridge; - player.PlaybackSession.PlaybackStateChanged += NotifyVideoLoadedOnStateChanged; + _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 + } } - PlayVideoView(null, + // 2026/04/19: Now the VideoFrameAvailable handle is used to notify NotifyVideoLoaded -> NotifyImageLoaded. + _videoPlayer.VideoFrameAvailable += NotifyVideoLoaded; + + PlayVideoView(ActionVideoAfterPlay, volumeFadeDurationMs, volumeFadeResolutionMs, token); + + void ActionVideoAfterPlay() + { + _videoPlayer.VideoFrameAvailable += !_useSafeFrameRenderer + ? VideoPlayer_VideoFrameAvailableUnsafe + : VideoPlayer_VideoFrameAvailableSafe; + } } else if (BackgroundSource != null) { @@ -156,23 +454,31 @@ public void DisposeAndPauseVideoView(Action? actionOnPause 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? player = _videoPlayer; - if (player == null!) + if (_videoPlayer == null!) { actionAfterPause?.Invoke(); return; } - if (disposeVideoPlayer && player.PlaybackSession != null) + if (disposeVideoPlayer) { - player.PlaybackSession.PlaybackStateChanged -= NotifyVideoLoadedOnStateChanged; - player.PlaybackSession.PositionChanged -= MediaDurationPosition_OnChangedBridge; + // Unsubscribe early to avoid wasted skipped frames. + _videoPlayer.VideoFrameAvailable -= !_useSafeFrameRenderer + ? VideoPlayer_VideoFrameAvailableUnsafe + : VideoPlayer_VideoFrameAvailableSafe; + + _videoPlayer.PlaybackSession.PositionChanged -= MediaDurationPosition_OnChangedBridge; } + // Interlocked.Exchange(ref _isBlockVideoFrameDraw, 1); // Blocks early PauseVideoView(Impl, volumeFadeDurationMs, volumeFadeResolutionMs, token); return; @@ -245,59 +551,4 @@ void ActionAfterPauseInject() } #endregion - - #region Video Loaded Notification - - private void NotifyVideoLoadedOnStateChanged(MediaPlaybackSession sender, object args) - { - if (sender.PlaybackState != MediaPlaybackState.Playing) - { - return; - } - - sender.PlaybackStateChanged -= NotifyVideoLoadedOnStateChanged; - DispatcherQueue.TryEnqueue(DispatcherQueuePriority.Low, () => - { - if (!this.IsObjectDisposed()) - { - NotifyImageLoaded(); - } - }); - } - - #endregion - - #region Video Frame Capture - - public async Task CaptureCurrentVideoFrame() - { - MediaPlayerElement? element = _videoPlayerElement; - if (element == null || _canvasWidth <= 0 || _canvasHeight <= 0) - { - return null; - } - - try - { - RenderTargetBitmap rtb = new(); - await rtb.RenderAsync(element, _canvasWidth, _canvasHeight); - - IBuffer pixelBuffer = await rtb.GetPixelsAsync(); - byte[] pixels = pixelBuffer.ToArray(); - - CanvasDevice device = CanvasDevice.GetSharedDevice(); - CanvasRenderTarget renderTarget = new(device, _canvasWidth, _canvasHeight, 96f); - renderTarget.SetPixelBytes(pixels); - return renderTarget; - } - catch (Exception ex) - { - Logger.LogWriteLine($"[LayeredBackgroundImage::CaptureCurrentVideoFrame] {ex}", - LogType.Error, - true); - return null; - } - } - - #endregion } diff --git a/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Events.Loaders.cs b/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Events.Loaders.cs index fcb3b5057a..92866855a8 100644 --- a/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Events.Loaders.cs +++ b/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Events.Loaders.cs @@ -421,12 +421,11 @@ private static async Task LoadVideoFromSourceAsync( { await Task.Delay(150); + // Unsubscribe frame renderer event to avoid double call, and then mark deinitialization. Interlocked.Exchange(ref instance._isVideoInitialized, 0); - if (instance._videoPlayer?.PlaybackSession != null) - { - instance._videoPlayer.PlaybackSession.PlaybackStateChanged -= instance.NotifyVideoLoadedOnStateChanged; - instance._videoPlayer.PlaybackSession.PositionChanged -= instance.MediaDurationPosition_OnChangedBridge; - } + player?.VideoFrameAvailable -= !instance._useSafeFrameRenderer + ? instance.VideoPlayer_VideoFrameAvailableUnsafe + : instance.VideoPlayer_VideoFrameAvailableSafe; ffmpegMediaSource.Dispose(); @@ -516,35 +515,42 @@ void Impl() SetValue(MediaDurationProperty, sender.NaturalDuration); SetValue(IsCurrentMediaSeekableProperty, sender.CanSeek); - DisposeVideoPlayerElement(); - - MediaPlayerElement playerElement = new() + // Create instance + Image image = new() { Tag = (_backgroundGrid, this), - Name = "VideoRenderFrame", - AreTransportControlsEnabled = false, - Stretch = BackgroundStretch, - HorizontalAlignment = BackgroundHorizontalAlignment, - VerticalAlignment = BackgroundVerticalAlignment + Name = "VideoRenderFrame" }; - InitializeRenderTargetSize(sender.PlaybackSession); - - playerElement.SetMediaPlayer(_videoPlayer); + // 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); - Interlocked.Exchange(ref _videoPlayerElement, playerElement); + InitializeRenderTargetSize(sender.PlaybackSession); - playerElement.Loaded += MediaPlayerElement_VideoFrameOnLoaded; - playerElement.Unloaded += MediaPlayerElement_VideoFrameOnUnloaded; + // Register events + image.Loaded += Image_VideoFrameOnLoaded; + image.Unloaded += Image_VideoFrameOnUnloaded; - playerElement.Transitions.Add(new ContentThemeTransition()); - _backgroundGrid.Children.Add(playerElement); + // Add to children + image.Transitions.Add(new ContentThemeTransition()); + _backgroundGrid.Children.Add(image); } } - private static void MediaPlayerElement_VideoFrameOnLoaded(object sender, RoutedEventArgs e) + private static void Image_VideoFrameOnLoaded(object sender, RoutedEventArgs e) { - if (sender is not MediaPlayerElement { Tag: ValueTuple parentGrid }) + if (sender is not Image { Tag: ValueTuple parentGrid }) { return; } @@ -553,12 +559,12 @@ private static void MediaPlayerElement_VideoFrameOnLoaded(object sender, RoutedE Interlocked.Exchange(ref parentGrid.Item2._videoState, VideoState.Playing); parentGrid.Item2.InitializeAndPlayVideoView(); - MediaPlayerElement_ImageOpened(sender, e); + Image_ImageOpened(sender, e); } - private static void MediaPlayerElement_VideoFrameOnUnloaded(object sender, RoutedEventArgs e) + private static void Image_VideoFrameOnUnloaded(object sender, RoutedEventArgs e) { - if (sender is not MediaPlayerElement { Tag: ValueTuple parentGrid }) + if (sender is not Image { Tag: ValueTuple parentGrid }) { return; } @@ -566,37 +572,6 @@ private static void MediaPlayerElement_VideoFrameOnUnloaded(object sender, Route parentGrid.Item2.DisposeAndPauseVideoView(); } - private static void MediaPlayerElement_ImageOpened(object sender, RoutedEventArgs e) - { - if (sender is not MediaPlayerElement { Tag: ValueTuple parentGrid } playerElement) - { - 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 playerElement to the grid - ClearMediaGrid(parentGrid.Item1, playerElement); - - // Remove transition once loaded - playerElement.Transitions.Clear(); - } - private static void Image_ImageOpened(object sender, RoutedEventArgs e) { if (sender is not Image { Tag: ValueTuple parentGrid } image) @@ -668,22 +643,15 @@ private static void ClearMediaGrid(Grid grid, UIElement? except = null) grid.Children.Where(x => x != except) .ToList(); - foreach (UIElement element in elementExcepted) + foreach (Image image in elementExcepted.OfType()) { - switch (element) - { - case MediaPlayerElement playerElement: - playerElement.Loaded -= MediaPlayerElement_VideoFrameOnLoaded; - playerElement.Unloaded -= MediaPlayerElement_VideoFrameOnUnloaded; - playerElement.SetMediaPlayer(null); - break; - case Image image: - // This one is for Image. The source will always guarantee to call this event. - image.ImageOpened -= Image_ImageOpened; - // Clears the loaded ImageSource - image.Source = null; - break; - } + // 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) diff --git a/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Events.cs b/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Events.cs index b89a8b40e8..717f31ee51 100644 --- a/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Events.cs +++ b/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Events.cs @@ -392,27 +392,28 @@ private void NotifyImageLoaded() ?? _backgroundGrid.Children.LastOrDefault(); Size size = default; - switch (canvasElement) + if (canvasElement is Image image) { - case Image image: - if (image.Source is BitmapSource asBitmapSource) - { - size = new Size(asBitmapSource.PixelWidth, asBitmapSource.PixelHeight); - } - else - { - size = image.RenderSize; - } - break; - case MediaPlayerElement playerElement: - size = playerElement.RenderSize; - break; + 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 From 56730f085640667be73ae590a8ffefb199064a82 Mon Sep 17 00:00:00 2001 From: shatyuka Date: Thu, 9 Jul 2026 04:03:38 +0800 Subject: [PATCH 5/8] Fix SharedLastMediaPosition memory leak and thread safety --- .../LayeredBackgroundImage.Events.Control.cs | 29 +++++++++++++++++-- ...redBackgroundImage.Events.FrameRenderer.cs | 6 ++-- 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Events.Control.cs b/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Events.Control.cs index f31f0f2179..20f4a6f088 100644 --- a/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Events.Control.cs +++ b/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Events.Control.cs @@ -344,11 +344,36 @@ private static bool SaveMediaPosition(object? source, TimeSpan timeSpan) timeSpan = timeSpan.Negate(); } - ref TimeSpan span = ref CollectionsMarshal.GetValueRefOrAddDefault(SharedLastMediaPosition, hashCode, out _); - span = timeSpan; + SharedLastMediaPosition[hashCode] = timeSpan; + + TrimSharedLastMediaPositionIfNeeded(); return true; } + private static void TrimSharedLastMediaPositionIfNeeded() + { + // Fast path: skip cleanup when below the cap. + if (Interlocked.Increment(ref _sharedLastMediaPositionCount) <= MaxSharedLastMediaPositionEntries) + { + return; + } + + int excess = _sharedLastMediaPositionCount - MaxSharedLastMediaPositionEntries; + if (excess <= 0) + { + return; + } + + foreach (var entry in SharedLastMediaPosition) + { + if (SharedLastMediaPosition.TryRemove(entry.Key, out _) && + Interlocked.Decrement(ref _sharedLastMediaPositionCount) <= MaxSharedLastMediaPositionEntries) + { + break; + } + } + } + #endregion } diff --git a/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Events.FrameRenderer.cs b/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Events.FrameRenderer.cs index 6a3c6394e2..6be5bfbade 100644 --- a/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Events.FrameRenderer.cs +++ b/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Events.FrameRenderer.cs @@ -10,7 +10,7 @@ using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Media; using System; -using System.Collections.Generic; +using System.Collections.Concurrent; using System.Linq; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; @@ -55,8 +55,10 @@ private static ref readonly Guid IMediaPlayer5_IID #endregion #region Fields + private const int MaxSharedLastMediaPositionEntries = 32; - private static readonly Dictionary SharedLastMediaPosition = new(); + private static readonly ConcurrentDictionary SharedLastMediaPosition = new(); + private static int _sharedLastMediaPositionCount; private CanvasRenderTarget? _canvasRenderTarget; private nint _canvasRenderTargetNativePtr; From e39fed9c307c5f0198e29fa906eb12fa3098d370 Mon Sep 17 00:00:00 2001 From: shatyuka Date: Thu, 9 Jul 2026 04:40:02 +0800 Subject: [PATCH 6/8] Fix video playback memory leaks and thread safety --- .../LayeredBackgroundImage.Events.FrameRenderer.cs | 10 ++++++++-- .../LayeredBackgroundImage.Events.Loaders.cs | 14 ++++++++++++-- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Events.FrameRenderer.cs b/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Events.FrameRenderer.cs index 6be5bfbade..a9d0aceaa5 100644 --- a/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Events.FrameRenderer.cs +++ b/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Events.FrameRenderer.cs @@ -481,6 +481,9 @@ public void DisposeAndPauseVideoView(Action? actionOnPause } // 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). + MediaPlayer? playerAtDisposeStart = _videoPlayer; PauseVideoView(Impl, volumeFadeDurationMs, volumeFadeResolutionMs, token); return; @@ -488,11 +491,14 @@ void Impl() { try { - if (_videoPlayer != null! && DispatcherQueue != null!) + if (_videoPlayer != null! && + ReferenceEquals(_videoPlayer, playerAtDisposeStart) && + DispatcherQueue != null!) { DispatcherQueue.TryEnqueue(DispatcherQueuePriority.High, () => { - if (disposeVideoPlayer) + if (disposeVideoPlayer && + ReferenceEquals(_videoPlayer, playerAtDisposeStart)) DisposeVideoPlayer(disposeRenderImageSource); }); } diff --git a/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Events.Loaders.cs b/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Events.Loaders.cs index 92866855a8..fd13a34dc6 100644 --- a/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Events.Loaders.cs +++ b/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Events.Loaders.cs @@ -219,8 +219,16 @@ private void LoadFromSourceAsyncDetached( if (lastMediaType == MediaSourceType.Video) { - // Dispose and Invalidate Video Player - DisposeAndPauseVideoView(); + // 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); @@ -428,6 +436,8 @@ private static async Task LoadVideoFromSourceAsync( : instance.VideoPlayer_VideoFrameAvailableSafe; ffmpegMediaSource.Dispose(); + sourceStreamRandom?.Dispose(); + sourceStreamRandom = null; if (loadFfmpegRetry <= 0) { From 5f471a369d689af43d12b441388e0db51e3d3bc2 Mon Sep 17 00:00:00 2001 From: shatyuka Date: Sat, 11 Jul 2026 21:22:28 +0800 Subject: [PATCH 7/8] Fix SharedLastMediaPosition position cache --- .../LayeredBackgroundImage.Events.Control.cs | 21 +++++++------------ ...redBackgroundImage.Events.FrameRenderer.cs | 1 - 2 files changed, 7 insertions(+), 15 deletions(-) diff --git a/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Events.Control.cs b/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Events.Control.cs index 20f4a6f088..6867442c63 100644 --- a/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Events.Control.cs +++ b/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Events.Control.cs @@ -353,25 +353,18 @@ private static bool SaveMediaPosition(object? source, TimeSpan timeSpan) private static void TrimSharedLastMediaPositionIfNeeded() { - // Fast path: skip cleanup when below the cap. - if (Interlocked.Increment(ref _sharedLastMediaPositionCount) <= MaxSharedLastMediaPositionEntries) - { + if (SharedLastMediaPosition.Count <= MaxSharedLastMediaPositionEntries) return; - } - - int excess = _sharedLastMediaPositionCount - MaxSharedLastMediaPositionEntries; - if (excess <= 0) - { - return; - } + int excess = SharedLastMediaPosition.Count - MaxSharedLastMediaPositionEntries; foreach (var entry in SharedLastMediaPosition) { - if (SharedLastMediaPosition.TryRemove(entry.Key, out _) && - Interlocked.Decrement(ref _sharedLastMediaPositionCount) <= MaxSharedLastMediaPositionEntries) - { + if (!SharedLastMediaPosition.TryRemove(entry.Key, out _)) + continue; + + excess--; + if (excess <= 0) break; - } } } diff --git a/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Events.FrameRenderer.cs b/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Events.FrameRenderer.cs index a9d0aceaa5..303222a5af 100644 --- a/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Events.FrameRenderer.cs +++ b/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Events.FrameRenderer.cs @@ -58,7 +58,6 @@ private static ref readonly Guid IMediaPlayer5_IID private const int MaxSharedLastMediaPositionEntries = 32; private static readonly ConcurrentDictionary SharedLastMediaPosition = new(); - private static int _sharedLastMediaPositionCount; private CanvasRenderTarget? _canvasRenderTarget; private nint _canvasRenderTargetNativePtr; From 87e9a84d75d87993b22182c20af51a8cfd9695e1 Mon Sep 17 00:00:00 2001 From: shatyuka Date: Sat, 11 Jul 2026 21:53:20 +0800 Subject: [PATCH 8/8] Fix MediaPlayer event cleanup before disposal --- ...BackgroundImage.Events.FrameInitializer.cs | 23 +++++++++++++------ ...redBackgroundImage.Events.FrameRenderer.cs | 10 +++----- 2 files changed, 19 insertions(+), 14 deletions(-) diff --git a/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Events.FrameInitializer.cs b/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Events.FrameInitializer.cs index 74cbf9097d..a8b1a5a523 100644 --- a/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Events.FrameInitializer.cs +++ b/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Events.FrameInitializer.cs @@ -156,22 +156,32 @@ private unsafe void InitializeRenderTarget() #region Disposers + private void DetachVideoPlayerEvents(MediaPlayer player) + { + player.MediaOpened -= InitializeVideoFrameOnMediaOpened; + player.VideoFrameAvailable -= NotifyVideoLoaded; + player.VideoFrameAvailable -= VideoPlayer_VideoFrameAvailableUnsafe; + player.VideoFrameAvailable -= VideoPlayer_VideoFrameAvailableSafe; + player.PlaybackSession.PositionChanged -= MediaDurationPosition_OnChangedBridge; + } + private void DisposeVideoPlayer(bool disposeRenderImageSource = true) { try { - if (_videoPlayer.IsObjectDisposed()) + MediaPlayer? player = _videoPlayer; + if (player is null || player.IsObjectDisposed()) { return; } - _videoPlayer.MediaOpened -= InitializeVideoFrameOnMediaOpened; - _videoPlayer.Pause(); + DetachVideoPlayerEvents(player); + player.Pause(); // Save last video player duration for later - if (_videoPlayer.CanSeek) + if (player.CanSeek) { - _ = SaveMediaPosition(BackgroundSource, _videoPlayer.Position); + _ = SaveMediaPosition(BackgroundSource, player.Position); } if (!_useSafeFrameRenderer) @@ -180,8 +190,7 @@ private void DisposeVideoPlayer(bool disposeRenderImageSource = true) } Interlocked.Exchange(ref _videoFfmpegMediaSource, null)?.Dispose(); - // ReSharper disable once ConstantConditionalAccessQualifier - Interlocked.Exchange(ref _videoPlayer, null!)?.Dispose(); + Interlocked.Exchange(ref _videoPlayer, null)?.Dispose(); DisposeRenderTarget(disposeRenderImageSource); } diff --git a/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Events.FrameRenderer.cs b/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Events.FrameRenderer.cs index 303222a5af..38850acee0 100644 --- a/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Events.FrameRenderer.cs +++ b/CollapseLauncher/XAMLs/Theme/CustomControls/LayeredBackgroundImage.Events.FrameRenderer.cs @@ -463,7 +463,8 @@ public void DisposeAndPauseVideoView(Action? actionOnPause DispatcherQueue?.TryEnqueue(() => SetValue(IsVideoPlayProperty, false)); actionOnPause?.Invoke(); - if (_videoPlayer == null!) + MediaPlayer? playerAtDisposeStart = _videoPlayer; + if (playerAtDisposeStart is null) { actionAfterPause?.Invoke(); return; @@ -472,17 +473,12 @@ public void DisposeAndPauseVideoView(Action? actionOnPause if (disposeVideoPlayer) { // Unsubscribe early to avoid wasted skipped frames. - _videoPlayer.VideoFrameAvailable -= !_useSafeFrameRenderer - ? VideoPlayer_VideoFrameAvailableUnsafe - : VideoPlayer_VideoFrameAvailableSafe; - - _videoPlayer.PlaybackSession.PositionChanged -= MediaDurationPosition_OnChangedBridge; + 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). - MediaPlayer? playerAtDisposeStart = _videoPlayer; PauseVideoView(Impl, volumeFadeDurationMs, volumeFadeResolutionMs, token); return;