Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
using System.Diagnostics.CodeAnalysis;
using Microsoft.Maui.Controls;
namespace CommunityToolkit.Maui.Extensions;

// Since MediaElement can't access .NET MAUI internals we have to copy this code here
// 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)
Expand All @@ -29,9 +32,43 @@ internal static Page GetCurrentPage(this Page currentPage)
}
}

internal static bool TryGetCurrentPages([NotNullWhen(true)] out IReadOnlyList<Page>? currentPages)
{
currentPages = null;

if (Application.Current?.Windows is not IReadOnlyList<Window> windows)
{
return false;
}

if (windows.Count is 0)
{
return false;
}

List<Page> 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();
/// <summary>
/// Checks if the parent window is null.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,4 +42,9 @@ partial void PlatformDispose()
playerViewController?.Dispose();
playerViewController = null;
}

partial void PlatformRefreshPlaybackControlsVisibility(bool shouldShowPlaybackControls)
{
PlatformView?.RefreshPlaybackControlsVisibility(shouldShowPlaybackControls);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

/// <summary>
Expand All @@ -97,6 +98,8 @@ public static void MapSource(MediaElementHandler handler, MediaElement mediaElem
handler.MediaManager?.UpdateSource();
}

partial void PlatformRefreshPlaybackControlsVisibility(bool shouldShowPlaybackControls);

/// <summary>
/// Maps the <see cref="Core.IMediaElement.Speed"/> property between the abstract
/// <see cref="MediaElement"/> and platform counterpart.
Expand Down
207 changes: 90 additions & 117 deletions src/CommunityToolkit.Maui.MediaElement/Views/MauiMediaElement.macios.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -15,148 +10,126 @@ namespace CommunityToolkit.Maui.Core.Views;
/// </summary>
public class MauiMediaElement : UIView
{
#if IOS16_0_OR_GREATER || MACCATALYST16_1_OR_GREATER
readonly AVPlayerViewController playerViewController;
#endif
readonly UIView playerView;

/// <summary>
/// Initializes a new instance of the <see cref="MauiMediaElement"/> class.
/// </summary>
/// <param name="playerViewController">The <see cref="AVPlayerViewController"/> that acts as the platform media player.</param>
/// <param name="virtualView">The <see cref="MediaElement"/> used as the VirtualView for this <see cref="MauiMediaElement"/>.</param>
/// <exception cref="NullReferenceException">Thrown when <paramref name="playerViewController"/><c>.View</c> is <see langword="null"/>.</exception>
/// <exception cref="InvalidOperationException">Thrown when <paramref name="playerViewController"/><c>.View</c> is <see langword="null"/>.</exception>
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;
/// <inheritdoc/>
public override void LayoutSubviews()
{
base.LayoutSubviews();
playerView.Frame = Bounds;
TryAttachToParentViewController();
}

/// <inheritdoc/>
public override void MovedToSuperview()
{
base.MovedToSuperview();
TryAttachToParentViewController();
}

/// <inheritdoc/>
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))
/// <summary>
/// Forces AVKit to rebuild the player view hierarchy after playback controls are enabled.
/// </summary>
/// <param name="shouldShowPlaybackControls"><see langword="true"/> when playback controls should be visible.</param>
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<Page>(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<TItemsView> GetInternalControllerForItemsView<TItemsView>(ItemsViewHandler<TItemsView> handler) where TItemsView : ItemsView
{
var nonPublicInstanceFields = typeof(ItemsViewHandler<TItemsView>).GetFields(BindingFlags.NonPublic | BindingFlags.Instance);

var controllerProperty = nonPublicInstanceFields.Single(x => x.FieldType == typeof(ItemsViewController<TItemsView>));
return (ItemsViewController<TItemsView>)(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<TItemsView> GetInternalControllerForItemsView2<TItemsView>(ItemsViewHandler2<TItemsView> handler) where TItemsView : ItemsView
{
var nonPublicInstanceFields = typeof(ItemsViewHandler2<TItemsView>).GetFields(BindingFlags.NonPublic | BindingFlags.Instance);

var controllerProperty = nonPublicInstanceFields.Single(x => x.FieldType == typeof(ItemsViewController2<TItemsView>));
return (ItemsViewController2<TItemsView>)(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<ItemsView>().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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using FluentAssertions;
using Xunit;
using ParentWindow = CommunityToolkit.Maui.Extensions.PageExtensions.ParentWindow;
using CommunityToolkit.Maui.Extensions;

namespace CommunityToolkit.Maui.UnitTests.Views;

Expand Down Expand Up @@ -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);
}
}
}
Loading