diff --git a/src/CommunityToolkit.Maui.MediaElement/Extensions/PageExtensions.shared.cs b/src/CommunityToolkit.Maui.MediaElement/Extensions/PageExtensions.shared.cs index 0838febf93..eb03f98646 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Extensions/PageExtensions.shared.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Extensions/PageExtensions.shared.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using Microsoft.Maui.Controls; namespace CommunityToolkit.Maui.Extensions; @@ -5,6 +6,8 @@ namespace CommunityToolkit.Maui.Extensions; // https://github.com/dotnet/maui/blob/main/src/Controls/src/Core/Platform/PageExtensions.cs static class PageExtensions { + internal static Page GetCurrentPage(this Window window) => GetCurrentPage(window.Page ?? throw new InvalidOperationException($"{nameof(Page)} cannot be null.")); + internal static Page GetCurrentPage(this Page currentPage) { if (currentPage.NavigationProxy.ModalStack.LastOrDefault() is Page modal) @@ -29,9 +32,43 @@ internal static Page GetCurrentPage(this Page currentPage) } } + internal static bool TryGetCurrentPages([NotNullWhen(true)] out IReadOnlyList? currentPages) + { + currentPages = null; + + if (Application.Current?.Windows is not IReadOnlyList windows) + { + return false; + } + + if (windows.Count is 0) + { + return false; + } + + List pages = []; + foreach (var window in windows) + { + if (window.Page is null) + { + continue; + } + + pages.Add(window.GetCurrentPage()); + } + + if (pages.Count is 0) + { + return false; + } + + currentPages = pages; + return true; + } + internal record struct ParentWindow { - static Page CurrentPage => GetCurrentPage(Application.Current?.Windows[^1].Page ?? throw new InvalidOperationException($"{nameof(Page)} cannot be null.")); + static Page CurrentPage => (Application.Current?.Windows[^1] ?? throw new InvalidOperationException($"{nameof(Window)} cannot be null.")).GetCurrentPage(); /// /// Checks if the parent window is null. /// diff --git a/src/CommunityToolkit.Maui.MediaElement/Handlers/MediaElementHandler.macios.cs b/src/CommunityToolkit.Maui.MediaElement/Handlers/MediaElementHandler.macios.cs index 56ed0612d4..10c86991cb 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Handlers/MediaElementHandler.macios.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Handlers/MediaElementHandler.macios.cs @@ -42,4 +42,9 @@ partial void PlatformDispose() playerViewController?.Dispose(); playerViewController = null; } + + partial void PlatformRefreshPlaybackControlsVisibility(bool shouldShowPlaybackControls) + { + PlatformView?.RefreshPlaybackControlsVisibility(shouldShowPlaybackControls); + } } \ No newline at end of file diff --git a/src/CommunityToolkit.Maui.MediaElement/Handlers/MediaElementHandler.shared.cs b/src/CommunityToolkit.Maui.MediaElement/Handlers/MediaElementHandler.shared.cs index 00cc22a626..caec47c79d 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Handlers/MediaElementHandler.shared.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Handlers/MediaElementHandler.shared.cs @@ -84,6 +84,7 @@ public static void MapAspect(MediaElementHandler handler, MediaElement mediaElem public static void MapShouldShowPlaybackControls(MediaElementHandler handler, MediaElement mediaElement) { handler.MediaManager?.UpdateShouldShowPlaybackControls(); + handler.PlatformRefreshPlaybackControlsVisibility(mediaElement.ShouldShowPlaybackControls); } /// @@ -97,6 +98,8 @@ public static void MapSource(MediaElementHandler handler, MediaElement mediaElem handler.MediaManager?.UpdateSource(); } + partial void PlatformRefreshPlaybackControlsVisibility(bool shouldShowPlaybackControls); + /// /// Maps the property between the abstract /// and platform counterpart. diff --git a/src/CommunityToolkit.Maui.MediaElement/Views/MauiMediaElement.macios.cs b/src/CommunityToolkit.Maui.MediaElement/Views/MauiMediaElement.macios.cs index dbd225a911..4ecabb60fb 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Views/MauiMediaElement.macios.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Views/MauiMediaElement.macios.cs @@ -1,11 +1,6 @@ using System.Diagnostics.CodeAnalysis; -using System.Reflection; using AVKit; -using CommunityToolkit.Maui.Extensions; using CommunityToolkit.Maui.Views; -using Microsoft.Maui.Controls.Handlers.Items; -using Microsoft.Maui.Controls.Handlers.Items2; -using Microsoft.Maui.Handlers; using UIKit; namespace CommunityToolkit.Maui.Core.Views; @@ -15,148 +10,126 @@ namespace CommunityToolkit.Maui.Core.Views; /// public class MauiMediaElement : UIView { + #if IOS16_0_OR_GREATER || MACCATALYST16_1_OR_GREATER + readonly AVPlayerViewController playerViewController; + #endif + readonly UIView playerView; + /// /// Initializes a new instance of the class. /// /// The that acts as the platform media player. /// The used as the VirtualView for this . - /// Thrown when .View is . + /// Thrown when .View is . public MauiMediaElement(AVPlayerViewController playerViewController, MediaElement virtualView) { - ArgumentNullException.ThrowIfNull(playerViewController.View); - playerViewController.View.Frame = Bounds; -#if IOS16_0_OR_GREATER || MACCATALYST16_1_OR_GREATER - // On iOS 16+ and macOS 13+ the AVPlayerViewController has to be added to a parent ViewController, otherwise the transport controls won't be displayed. + ArgumentNullException.ThrowIfNull(virtualView); + + #if IOS16_0_OR_GREATER || MACCATALYST16_1_OR_GREATER + this.playerViewController = playerViewController; + #endif + playerView = playerViewController.View ?? throw new InvalidOperationException($"{nameof(playerViewController)}.{nameof(playerViewController.View)} cannot be null."); + + playerView.Frame = Bounds; + AddSubview(playerView); + TryAttachToParentViewController(); + } - UIViewController? viewController; + /// + public override void LayoutSubviews() + { + base.LayoutSubviews(); + playerView.Frame = Bounds; + TryAttachToParentViewController(); + } + + /// + public override void MovedToSuperview() + { + base.MovedToSuperview(); + TryAttachToParentViewController(); + } + + /// + public override void MovedToWindow() + { + base.MovedToWindow(); + TryAttachToParentViewController(); + } - // If any of the Parents in the VisualTree of MediaElement uses a UIViewController for their PlatformView, use it as the child ViewController - // This enables support for UI controls like CommunityToolkit.Maui.Popup whose PlatformView is a UIViewController (e.g. `public class MauiPopup : UIViewController`) - // To find the UIViewController, we traverse `MediaElement.Parent` until a Parent using UIViewController is located - if (virtualView.TryFindParentPlatformView(out UIViewController? parentUIViewController)) + /// + /// Forces AVKit to rebuild the player view hierarchy after playback controls are enabled. + /// + /// when playback controls should be visible. + public void RefreshPlaybackControlsVisibility(bool shouldShowPlaybackControls) + { + if (!shouldShowPlaybackControls) { - viewController = parentUIViewController; + return; } - // If none of the Parents in the VisualTree of MediaElement use a UIViewController, we can use the ViewController in the PageHandler - // To find the PageHandler, we traverse `MediaElement.Parent` until the Page is located - else if (virtualView.TryFindParent(out var page) - && page.Handler is PageHandler { ViewController: not null } pageHandler) + + TryAttachToParentViewController(forceReattach: true); + + SetNeedsLayout(); + LayoutIfNeeded(); + playerView.SetNeedsLayout(); + playerView.LayoutIfNeeded(); + playerView.SetNeedsDisplay(); + } + + void TryAttachToParentViewController(bool forceReattach = false) + { +#if IOS16_0_OR_GREATER || MACCATALYST16_1_OR_GREATER + if (!TryGetParentViewController(out var viewController) || viewController.View is not UIView parentView) { - viewController = pageHandler.ViewController; + return; } - // If the parent Page cannot be found, MediaElement is being used inside a DataTemplate. I.e. The MediaElement is being used inside a CarouselView or a CollectionView - // The top-most parent is null when MediaElement is placed in a DataTemplate because DataTemplates defer loading until they are about to be displayed on the screen - // When the MediaElement is used inside a DataTemplate, we must retrieve its CarouselViewHandler / CollectionViewHandler - // To retrieve its CarouselViewHandler / CollectionViewHandler, we must traverse all VisualElements on the current page - else + + if (!forceReattach && ReferenceEquals(playerViewController.ParentViewController, viewController)) { - ArgumentNullException.ThrowIfNull(virtualView); + return; + } - if (!TryGetCurrentPage(out var currentPage)) + if (playerViewController.ParentViewController is UIViewController previousParent) + { + if (playerViewController.View is UIView attachedView) { - throw new InvalidOperationException("Cannot find current page"); + attachedView.RemoveFromSuperview(); } - // look for an ItemsView (e.g. CarouselView or CollectionView) on page - if (TryGetItemsViewOnPage(currentPage, out var itemsView)) - { - var parentViewController = itemsView.Handler switch - { - CarouselViewHandler carouselViewHandler => carouselViewHandler.ViewController ?? GetInternalControllerForItemsView(carouselViewHandler), - CarouselViewHandler2 carouselViewHandler2 => carouselViewHandler2.ViewController ?? GetInternalControllerForItemsView2(carouselViewHandler2), - CollectionViewHandler collectionViewHandler => collectionViewHandler.ViewController ?? GetInternalControllerForItemsView(collectionViewHandler), - CollectionViewHandler2 collectionViewHandler2 => collectionViewHandler2.ViewController ?? GetInternalControllerForItemsView2(collectionViewHandler2), - null => throw new InvalidOperationException("Handler cannot be null"), - _ => throw new NotSupportedException($"{itemsView.Handler.GetType()} not yet supported") - }; - - viewController = parentViewController; - - // The Controller we need is a `protected internal` property called ItemsViewController in the ItemsViewHandler class: https://github.com/dotnet/maui/blob/cf002538cb73db4bf187a51e4786d7478a7025ee/src/Controls/src/Core/Handlers/Items/ItemsViewHandler.iOS.cs#L39 - // In this method, we must use reflection to get the value of its backing field - static ItemsViewController GetInternalControllerForItemsView(ItemsViewHandler handler) where TItemsView : ItemsView - { - var nonPublicInstanceFields = typeof(ItemsViewHandler).GetFields(BindingFlags.NonPublic | BindingFlags.Instance); - - var controllerProperty = nonPublicInstanceFields.Single(x => x.FieldType == typeof(ItemsViewController)); - return (ItemsViewController)(controllerProperty.GetValue(handler) ?? throw new InvalidOperationException($"Unable to get the value for the Controller property on {handler.GetType()}")); - } - - // The Controller we need is a `protected internal` property called ItemsViewController in the ItemsViewHandler2 class: https://github.com/dotnet/maui/blob/70e8ddfd4bd494bc71aa7afb812cc09161cf0c72/src/Controls/src/Core/Handlers/Items2/ItemsViewHandler2.iOS.cs#L64 - // In this method, we must use reflection to get the value of its backing field - static ItemsViewController2 GetInternalControllerForItemsView2(ItemsViewHandler2 handler) where TItemsView : ItemsView - { - var nonPublicInstanceFields = typeof(ItemsViewHandler2).GetFields(BindingFlags.NonPublic | BindingFlags.Instance); - - var controllerProperty = nonPublicInstanceFields.Single(x => x.FieldType == typeof(ItemsViewController2)); - return (ItemsViewController2)(controllerProperty.GetValue(handler) ?? throw new InvalidOperationException($"Unable to get the value for the Controller property on {handler.GetType()}")); - } - } - // If we don't find an ItemsView, default to the current UIViewController - else - { - viewController = Platform.GetCurrentUIViewController(); - } + AddSubview(playerView); + playerView.Frame = Bounds; + playerViewController.WillMoveToParentViewController(null); + playerViewController.RemoveFromParentViewController(); } + UIEdgeInsets insets = parentView.SafeAreaInsets; + playerViewController.AdditionalSafeAreaInsets = + new UIEdgeInsets(insets.Top * -1, insets.Left, insets.Bottom * -1, insets.Right); - if (viewController?.View is not null) - { - // Zero out the safe area insets of the AVPlayerViewController - UIEdgeInsets insets = viewController.View.SafeAreaInsets; - playerViewController.AdditionalSafeAreaInsets = - new UIEdgeInsets(insets.Top * -1, insets.Left, insets.Bottom * -1, insets.Right); - - // Add the View from the AVPlayerViewController to the parent ViewController - viewController.AddChildViewController(playerViewController); - } + viewController.AddChildViewController(playerViewController); + playerViewController.DidMoveToParentViewController(viewController); #endif - AddSubview(playerViewController.View); } - static bool TryGetItemsViewOnPage(Page currentPage, [NotNullWhen(true)] out ItemsView? itemsView) + #if IOS16_0_OR_GREATER || MACCATALYST16_1_OR_GREATER + bool TryGetParentViewController([NotNullWhen(true)] out UIViewController? viewController) { - var itemsViewsOnPage = ((IElementController)currentPage).Descendants().OfType().ToList(); - switch (itemsViewsOnPage.Count) - { - case > 1: - // We are unable to determine which ItemsView contains the MediaElement when multiple ItemsView are being used in the same page - // TODO: Add support for MediaElement in an ItemsView on a Page containing multiple ItemsViews - throw new NotSupportedException("MediaElement does not currently support pages containing multiple ItemsViews (eg multiple CarouselViews + CollectionViews)"); - case 1: - itemsView = itemsViewsOnPage[0]; - return true; - case <= 0: - itemsView = null; - return false; - } + viewController = GetViewControllerFromResponderChain(); + return viewController is not null; } - static bool TryGetCurrentPage([NotNullWhen(true)] out Page? currentPage) + UIViewController? GetViewControllerFromResponderChain() { - currentPage = null; - - if (Application.Current?.Windows is null) + for (UIResponder? responder = NextResponder; responder is not null; responder = responder.NextResponder) { - return false; - } - - if (Application.Current.Windows.Count is 0) - { - throw new InvalidOperationException("Unable to find active Window"); + if (responder is UIViewController viewController) + { + return viewController; + } } - if (Application.Current.Windows.Count > 1) - { - // We are unable to determine which Window contains the ItemsView that contains the MediaElement when multiple ItemsView are being used in the same page - // TODO: Add support for MediaElement in an ItemsView in a multi-window application - throw new NotSupportedException("MediaElement is not currently supported in multi-window applications"); - } - if (Application.Current.Windows[0].Page is Page page) - { - currentPage = PageExtensions.GetCurrentPage(page); - return true; - } - return false; + return null; } + #endif } \ No newline at end of file diff --git a/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.macios.cs b/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.macios.cs index beaab1516a..b4890584cb 100644 --- a/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.macios.cs +++ b/src/CommunityToolkit.Maui.MediaElement/Views/MediaManager.macios.cs @@ -31,6 +31,7 @@ public partial class MediaManager : IDisposable { Player = Player }; + PlayerViewController.ShowsPlaybackControls = MediaElement.ShouldShowPlaybackControls; // Pre-initialize Volume and Muted properties to the player object Player.Muted = MediaElement.ShouldMute; diff --git a/src/CommunityToolkit.Maui.UnitTests/Views/MediaElement/ParentWindowTests.cs b/src/CommunityToolkit.Maui.UnitTests/Views/MediaElement/ParentWindowTests.cs index 89afc9bd54..b49d7a7fc7 100644 --- a/src/CommunityToolkit.Maui.UnitTests/Views/MediaElement/ParentWindowTests.cs +++ b/src/CommunityToolkit.Maui.UnitTests/Views/MediaElement/ParentWindowTests.cs @@ -2,6 +2,7 @@ using FluentAssertions; using Xunit; using ParentWindow = CommunityToolkit.Maui.Extensions.PageExtensions.ParentWindow; +using CommunityToolkit.Maui.Extensions; namespace CommunityToolkit.Maui.UnitTests.Views; @@ -69,4 +70,31 @@ public void Exists_WhenAllConditionsAreMet_ReturnsTrue() ParentWindow.Exists.Should().BeTrue(); Application.Current.RemoveWindow(mockWindow); } + + [Fact] + public void TryGetCurrentPages_WhenApplicationHasMultipleWindows_ReturnsCurrentPageForEachWindow() + { + Application.Current.Should().NotBeNull(); + + var firstPage = new ContentPage(); + var secondPage = new ContentPage(); + var firstWindow = new Window { Page = firstPage }; + var secondWindow = new Window { Page = secondPage }; + + Application.Current.AddWindow(firstWindow); + Application.Current.AddWindow(secondWindow); + + try + { + PageExtensions.TryGetCurrentPages(out var currentPages).Should().BeTrue(); + currentPages.Should().NotBeNull(); + currentPages.Should().Contain(firstPage); + currentPages.Should().Contain(secondPage); + } + finally + { + Application.Current.RemoveWindow(firstWindow); + Application.Current.RemoveWindow(secondWindow); + } + } } \ No newline at end of file