Create a custom control using handlers

Browse sample. Browse the sample

A standard requirement for apps is the ability to play videos. This article examines how to create a .NET Multi-platform App UI (.NET MAUI) cross-platform Video control that uses a handler to map the cross-platform control API to the native views on Android, iOS, and Mac Catalyst that play videos. This control can play video from three sources:

  • A URL, which represents a remote video.
  • A resource, which is a file embedded in the app.
  • A file, from the device's video library.

Video controls require transport controls, which are buttons for playing and pausing the video, and a positioning bar that shows the progress through the video and allows the user to move quickly to a different location. The Video control can either use the transport controls and positioning bar provided by the platform, or you can supply custom transport controls and a positioning bar. The following screenshots show the control on iOS, with and without custom transport controls:

Screenshot of video playback on iOS. Screenshot of video playback using custom transport controls on iOS.

A more sophisticated video control would have additional features, such as a volume control, a mechanism to interrupt video playback when a call is received, and a way of keeping the screen active during playback.

The architecture of the Video control is shown in the following diagram:

Video handler architecture.

The Video class provides the cross-platform API for the control. Mapping of the cross-platform API to the native view APIs is performed by the VideoHandler class on each platform, which maps the Video class to the MauiVideoPlayer class. On iOS and Mac Catalyst, the MauiVideoPlayer class uses the AVPlayer type to provide video playback. On Android, the MauiVideoPlayer class uses the VideoView type to provide video playback. On Windows, the MauiVideoPlayer class uses the MediaPlayerElement type to provide video playback.

Important

.NET MAUI decouples its handlers from its cross-platform controls through interfaces. This enables experimental frameworks such as Comet and Fabulous to provide their own cross-platform controls, that implement the interfaces, while still using .NET MAUI's handlers. Creating an interface for your cross-platform control is only necessary if you need to decouple your handler from its cross-platform control for a similar purpose, or for testing purposes.

The process for creating a cross-platform .NET MAUI custom control, whose platform implementations are provided by handlers, is as follows:

  1. Create a class for the cross-platform control, which provides the control's public API. For more information, see Create the cross-platform control.
  2. Create any required additional cross-platform types.
  3. Create a partial handler class. For more information, see Create the handler.
  4. In the handler class, create a PropertyMapper dictionary, which defines the Actions to take when cross-platform property changes occur. For more information, see Create the property mapper.
  5. Optionally, in your handler class, create a CommandMapper dictionary, which defines the Actions to take when the cross-platform control sends instructions to the native views that implement the cross-platform control. For more information, see Create the command mapper.
  6. Create partial handler classes for each platform that create the native views that implement the cross-platform control. For more information, see Create the platform controls.
  7. Register the handler using the ConfigureMauiHandlers and AddHandler methods in your app's MauiProgram class. For more information, see Register the handler.

Then, the cross-platform control can be consumed. For more information, see Consume the cross-platform control.

Create the cross-platform control

To create a cross-platform control, you should create a class that derives from View:

using System.ComponentModel;

namespace VideoDemos.Controls
{
    public class Video : View, IVideoController
    {
        public static readonly BindableProperty AreTransportControlsEnabledProperty =
            BindableProperty.Create(nameof(AreTransportControlsEnabled), typeof(bool), typeof(Video), true);

        public static readonly BindableProperty SourceProperty =
            BindableProperty.Create(nameof(Source), typeof(VideoSource), typeof(Video), null);

        public static readonly BindableProperty AutoPlayProperty =
            BindableProperty.Create(nameof(AutoPlay), typeof(bool), typeof(Video), true);

        public static readonly BindableProperty IsLoopingProperty =
            BindableProperty.Create(nameof(IsLooping), typeof(bool), typeof(Video), false);            

        public bool AreTransportControlsEnabled
        {
            get { return (bool)GetValue(AreTransportControlsEnabledProperty); }
            set { SetValue(AreTransportControlsEnabledProperty, value); }
        }

        [TypeConverter(typeof(VideoSourceConverter))]
        public VideoSource Source
        {
            get { return (VideoSource)GetValue(SourceProperty); }
            set { SetValue(SourceProperty, value); }
        }

        public bool AutoPlay
        {
            get { return (bool)GetValue(AutoPlayProperty); }
            set { SetValue(AutoPlayProperty, value); }
        }

        public bool IsLooping
        {
            get { return (bool)GetValue(IsLoopingProperty); }
            set { SetValue(IsLoopingProperty, value); }
        }        
        ...
    }
}

The control should provide a public API that will be accessed by its handler, and control consumers. Cross-platform controls should derive from View, which represents a visual element that's used to place layouts and views on the screen.

Create the handler

After creating your cross-platform control, you should create a partial class for your handler:

#if IOS || MACCATALYST
using PlatformView = VideoDemos.Platforms.MaciOS.MauiVideoPlayer;
#elif ANDROID
using PlatformView = VideoDemos.Platforms.Android.MauiVideoPlayer;
#elif WINDOWS
using PlatformView = VideoDemos.Platforms.Windows.MauiVideoPlayer;
#elif (NETSTANDARD || !PLATFORM) || (NET6_0_OR_GREATER && !IOS && !ANDROID)
using PlatformView = System.Object;
#endif
using VideoDemos.Controls;
using Microsoft.Maui.Handlers;

namespace VideoDemos.Handlers
{
    public partial class VideoHandler
    {
    }
}

The handler class is a partial class whose implementation will be completed on each platform with an additional partial class.

The conditional using statements define the PlatformView type on each platform. On Android, iOS, Mac Catalyst, and Windows, the native views are provided by the custom MauiVideoPlayer class. The final conditional using statement defines PlatformView to be equal to System.Object. This is necessary so that the PlatformView type can be used within the handler for usage across all platforms. The alternative would be to have to define the PlatformView property once per platform, using conditional compilation.

Create the property mapper

Each handler typically provides a property mapper, which defines what Actions to take when a property change occurs in the cross-platform control. The PropertyMapper type is a Dictionary that maps the cross-platform control's properties to their associated Actions.

PropertyMapper is defined in .NET MAUI's generic ViewHandler class, and requires two generic arguments to be supplied:

  • The class for the cross-platform control, which derives from View.
  • The class for the handler.

The following code example shows the VideoHandler class extended with the PropertyMapper definition:

public partial class VideoHandler
{
    public static IPropertyMapper<Video, VideoHandler> PropertyMapper = new PropertyMapper<Video, VideoHandler>(ViewHandler.ViewMapper)
    {
        [nameof(Video.AreTransportControlsEnabled)] = MapAreTransportControlsEnabled,
        [nameof(Video.Source)] = MapSource,
        [nameof(Video.IsLooping)] = MapIsLooping,
        [nameof(Video.Position)] = MapPosition
    };

    public VideoHandler() : base(PropertyMapper)
    {
    }
}

The PropertyMapper is a Dictionary whose key is a string and whose value is a generic Action. The string represents the cross-platform control's property name, and the Action represents a static method that requires the handler and cross-platform control as arguments. For example, the signature of the MapSource method is public static void MapSource(VideoHandler handler, Video video).

Each platform handler must provide implementations of the Actions, which manipulate the native view APIs. This ensures that when a property is set on a cross-platform control, the underlying native view will be updated as required. The advantage of this approach is that it allows for easy cross-platform control customization, because the property mapper can be modified by cross-platform control consumers without subclassing.

Create the command mapper

Each handler can also provide a command mapper, which defines what Actions to take when the cross-platform control sends commands to native views. Command mappers are similar to property mappers, but allow for additional data to be passed. In this context, a command is an instruction, and optionally its data, that's sent to a native view. The CommandMapper type is a Dictionary that maps cross-platform control members to their associated Actions.

CommandMapper is defined in .NET MAUI's generic ViewHandler class, and requires two generic arguments to be supplied:

  • The class for the cross-platform control, which derives from View.
  • The class for the handler.

The following code example shows the VideoHandler class extended with the CommandMapper definition:

public partial class VideoHandler
{
    public static IPropertyMapper<Video, VideoHandler> PropertyMapper = new PropertyMapper<Video, VideoHandler>(ViewHandler.ViewMapper)
    {
        [nameof(Video.AreTransportControlsEnabled)] = MapAreTransportControlsEnabled,
        [nameof(Video.Source)] = MapSource,
        [nameof(Video.IsLooping)] = MapIsLooping,
        [nameof(Video.Position)] = MapPosition
    };

    public static CommandMapper<Video, VideoHandler> CommandMapper = new(ViewCommandMapper)
    {
        [nameof(Video.UpdateStatus)] = MapUpdateStatus,
        [nameof(Video.PlayRequested)] = MapPlayRequested,
        [nameof(Video.PauseRequested)] = MapPauseRequested,
        [nameof(Video.StopRequested)] = MapStopRequested
    };

    public VideoHandler() : base(PropertyMapper, CommandMapper)
    {
    }
}

The CommandMapper is a Dictionary whose key is a string and whose value is a generic Action. The string represents the cross-platform control's command name, and the Action represents a static method that requires the handler, cross-platform control, and optional data as arguments. For example, the signature of the MapPlayRequested method is public static void MapPlayRequested(VideoHandler handler, Video video, object? args).

Each platform handler must provide implementations of the Actions, which manipulate the native view APIs. This ensures that when a command is sent from the cross-platform control, the underlying native view will be manipulated as required. The advantage of this approach is that it removes the need for native views to subscribe to and unsubscribe from cross-platform control events. In addition, it allows for easy customization because the command mapper can be modified by cross-platform control consumers without subclassing.

Create the platform controls

After creating the mappers for your handler, you must provide handler implementations on all platforms. This can be accomplished by adding partial class handler implementations in the child folders of the Platforms folder. Alternatively you could configure your project to support filename-based multi-targeting, or folder-based multi-targeting, or both.

The sample app is configured to support filename-based multi-targeting, so that the handler classes all are located in a single folder:

Screenshot of the files in the Handlers folder of the project.

The VideoHandler class containing the mappers is named VideoHandler.cs. Its platform implementations are in the VideoHandler.Android.cs, VideoHandler.MaciOS.cs, and VideoHandler.Windows.cs files. This filename-based multi-targeting is configured by adding the following XML to the project file, as children of the <Project> node:

<!-- Android -->
<ItemGroup Condition="$(TargetFramework.StartsWith('net8.0-android')) != true">
  <Compile Remove="**\*.Android.cs" />
  <None Include="**\*.Android.cs" Exclude="$(DefaultItemExcludes);$(DefaultExcludesInProjectFolder)" />
</ItemGroup>

<!-- iOS and Mac Catalyst -->
<ItemGroup Condition="$(TargetFramework.StartsWith('net8.0-ios')) != true AND $(TargetFramework.StartsWith('net8.0-maccatalyst')) != true">
  <Compile Remove="**\*.MaciOS.cs" />
  <None Include="**\*.MaciOS.cs" Exclude="$(DefaultItemExcludes);$(DefaultExcludesInProjectFolder)" />
</ItemGroup>

<!-- Windows -->
<ItemGroup Condition="$(TargetFramework.Contains('-windows')) != true ">
  <Compile Remove="**\*.Windows.cs" />
  <None Include="**\*.Windows.cs" Exclude="$(DefaultItemExcludes);$(DefaultExcludesInProjectFolder)" />
</ItemGroup>

For more information about configuring multi-targeting, see Configure multi-targeting.

Each platform handler class should be a partial class and derive from the generic ViewHandler class, which requires two type arguments:

  • The class for the cross-platform control, which derives from View.
  • The type of the native view that implements the cross-platform control on the platform. This should be identical to the type of the PlatformView property in the handler.

Important

The ViewHandler class provides VirtualView and PlatformView properties. The VirtualView property is used to access the cross-platform control from its handler. The PlatformView property, is used to access the native view on each platform that implements the cross-platform control.

Each of the platform handler implementations should override the following methods:

  • CreatePlatformView, which should create and return the native view that implements the cross-platform control.
  • ConnectHandler, which should perform any native view setup, such as initializing the native view and performing event subscriptions.
  • DisconnectHandler, which should perform any native view cleanup, such as unsubscribing from events and disposing objects.

Important

The DisconnectHandler method is intentionally not invoked by .NET MAUI. Instead, you must invoke it yourself from a suitable location in your app's lifecycle. For more information, see Native view cleanup.

Each platform handler should also implement the Actions that are defined in the mapper dictionaries.

In addition, each platform handler should also provide code, as required, to implement the functionality of the cross-platform control on the platform. Alternatively, this can be provided by an additional type, which is the approach adopted here.

Android

Video is played on Android with a VideoView. However, here, the VideoView has been encapsulated in a MauiVideoPlayer type to keep the native view separated from its handler. The following example shows the VideoHandler partial class for Android, with its three overrides:

#nullable enable
using Microsoft.Maui.Handlers;
using VideoDemos.Controls;
using VideoDemos.Platforms.Android;

namespace VideoDemos.Handlers
{
    public partial class VideoHandler : ViewHandler<Video, MauiVideoPlayer>
    {
        protected override MauiVideoPlayer CreatePlatformView() => new MauiVideoPlayer(Context, VirtualView);

        protected override void ConnectHandler(MauiVideoPlayer platformView)
        {
            base.ConnectHandler(platformView);

            // Perform any control setup here
        }

        protected override void DisconnectHandler(MauiVideoPlayer platformView)
        {
            platformView.Dispose();
            base.DisconnectHandler(platformView);
        }
        ...
    }
}

VideoHandler derives from the ViewHandler class, with the generic Video argument specifying the cross-platform control type, and the MauiVideoPlayer argument specifying the type that encapsulates the VideoView native view.

The CreatePlatformView override creates and returns a MauiVideoPlayer object. The ConnectHandler override is the location to perform any required native view setup. The DisconnectHandler override is the location to perform any native view cleanup, and so calls the Dispose method on the MauiVideoPlayer instance.

The platform handler also has to implement the Actions defined in the property mapper dictionary:

public partial class VideoHandler : ViewHandler<Video, MauiVideoPlayer>
{
    ...
    public static void MapAreTransportControlsEnabled(VideoHandler handler, Video video)
    {
        handler.PlatformView?.UpdateTransportControlsEnabled();
    }

    public static void MapSource(VideoHandler handler, Video video)
    {
        handler.PlatformView?.UpdateSource();
    }

    public static void MapIsLooping(VideoHandler handler, Video video)
    {
        handler.PlatformView?.UpdateIsLooping();
    }    

    public static void MapPosition(VideoHandler handler, Video video)
    {
        handler.PlatformView?.UpdatePosition();
    }
    ...
}

Each Action is executed in response to a property changing on the cross-platform control, and is a static method that requires handler and cross-platform control instances as arguments. In each case, the Action calls a method defined in the MauiVideoPlayer type.

The platform handler also has to implement the Actions defined in the command mapper dictionary:

public partial class VideoHandler : ViewHandler<Video, MauiVideoPlayer>
{
    ...
    public static void MapUpdateStatus(VideoHandler handler, Video video, object? args)
    {
        handler.PlatformView?.UpdateStatus();
    }

    public static void MapPlayRequested(VideoHandler handler, Video video, object? args)
    {
        if (args is not VideoPositionEventArgs)
            return;

        TimeSpan position = ((VideoPositionEventArgs)args).Position;
        handler.PlatformView?.PlayRequested(position);
    }

    public static void MapPauseRequested(VideoHandler handler, Video video, object? args)
    {
        if (args is not VideoPositionEventArgs)
            return;

        TimeSpan position = ((VideoPositionEventArgs)args).Position;
        handler.PlatformView?.PauseRequested(position);
    }

    public static void MapStopRequested(VideoHandler handler, Video video, object? args)
    {
        if (args is not VideoPositionEventArgs)
            return;

        TimeSpan position = ((VideoPositionEventArgs)args).Position;
        handler.PlatformView?.StopRequested(position);
    }
    ...
}

Each Action is executed in response to a command being sent from the cross-platform control, and is a static method that requires handler and cross-platform control instances, and optional data as arguments. In each case, the Action calls a method defined in the MauiVideoPlayer class, after extracting the optional data.

On Android, the MauiVideoPlayer class encapsulates the VideoView to keep the native view separated from its handler:

using Android.Content;
using Android.Views;
using Android.Widget;
using AndroidX.CoordinatorLayout.Widget;
using VideoDemos.Controls;
using Color = Android.Graphics.Color;
using Uri = Android.Net.Uri;

namespace VideoDemos.Platforms.Android
{
    public class MauiVideoPlayer : CoordinatorLayout
    {
        VideoView _videoView;
        MediaController _mediaController;
        bool _isPrepared;
        Context _context;
        Video _video;

        public MauiVideoPlayer(Context context, Video video) : base(context)
        {
            _context = context;
            _video = video;

            SetBackgroundColor(Color.Black);

            // Create a RelativeLayout for sizing the video
            RelativeLayout relativeLayout = new RelativeLayout(_context)
            {
                LayoutParameters = new CoordinatorLayout.LayoutParams(LayoutParams.MatchParent, LayoutParams.MatchParent)
                {
                    Gravity = (int)GravityFlags.Center
                }
            };

            // Create a VideoView and position it in the RelativeLayout
            _videoView = new VideoView(context)
            {
                LayoutParameters = new RelativeLayout.LayoutParams(LayoutParams.MatchParent, LayoutParams.MatchParent)
            };

            // Add to the layouts
            relativeLayout.AddView(_videoView);
            AddView(relativeLayout);

            // Handle events
            _videoView.Prepared += OnVideoViewPrepared;
        }
        ...
    }
}

MauiVideoPlayer derives from CoordinatorLayout, because the root native view in a .NET MAUI app on Android is CoordinatorLayout. While the MauiVideoPlayer class could derive from other native Android types, it can be difficult to control native view positioning in some scenarios.

The VideoView could be added directly to the CoordinatorLayout, and positioned in the layout as required. However, here, an Android RelativeLayout is added to the CoordinatorLayout, and the VideoView is added to the RelativeLayout. Layout parameters are set on both the RelativeLayout and VideoView so that the VideoView is centered in the page, and expands to fill the available space while maintaining its aspect ratio.

The constructor also subscribes to the VideoView.Prepared event. This event is raised when the video is ready for playback, and is unsubscribed from in the Dispose override:

public class MauiVideoPlayer : CoordinatorLayout
{
    VideoView _videoView;
    Video _video;
    ...

    protected override void Dispose(bool disposing)
    {
        if (disposing)
        {
            _videoView.Prepared -= OnVideoViewPrepared;
            _videoView.Dispose();
            _videoView = null;
            _video = null;
        }

        base.Dispose(disposing);
    }
    ...
}

In addition to unsubscribing from the Prepared event, the Dispose override also performs native view cleanup.

Note

The Dispose override is called by the handler's DisconnectHandler override.

The platform transport controls include buttons that play, pause, and stop the video, and are provided by Android's MediaController type. If the Video.AreTransportControlsEnabled property is set to true, a MediaController is set as the media player of the VideoView. This occurs because when the AreTransportControlsEnabled property is set, the handler's property mapper ensures that the MapAreTransportControlsEnabled method is invoked, which in turn calls the UpdateTransportControlsEnabled method in MauiVideoPlayer:

public class MauiVideoPlayer : CoordinatorLayout
{
    VideoView _videoView;
    MediaController _mediaController;
    Video _video;
    ...

    public void UpdateTransportControlsEnabled()
    {
        if (_video.AreTransportControlsEnabled)
        {
            _mediaController = new MediaController(_context);
            _mediaController.SetMediaPlayer(_videoView);
            _videoView.SetMediaController(_mediaController);
        }
        else
        {
            _videoView.SetMediaController(null);
            if (_mediaController != null)
            {
                _mediaController.SetMediaPlayer(null);
                _mediaController = null;
            }
        }
    }
    ...
}

The transport controls fade out if they're not used but can be restored by tapping on the video.

If the Video.AreTransportControlsEnabled property is set to false, the MediaController is removed as the media player of the VideoView. In this scenario, you can then control video playback programmatically or supply your own transport controls. For more information, see Create custom transport controls.

iOS and Mac Catalyst

Video is played on iOS and Mac Catalyst with an AVPlayer and an AVPlayerViewController. However, here, these types are encapsulated in a MauiVideoPlayer type to keep the native views separated from their handler. The following example shows the VideoHandler partial class for iOS, with its three overrides:

using Microsoft.Maui.Handlers;
using VideoDemos.Controls;
using VideoDemos.Platforms.MaciOS;

namespace VideoDemos.Handlers
{
    public partial class VideoHandler : ViewHandler<Video, MauiVideoPlayer>
    {
        protected override MauiVideoPlayer CreatePlatformView() => new MauiVideoPlayer(VirtualView);

        protected override void ConnectHandler(MauiVideoPlayer platformView)
        {
            base.ConnectHandler(platformView);

            // Perform any control setup here
        }

        protected override void DisconnectHandler(MauiVideoPlayer platformView)
        {
            platformView.Dispose();
            base.DisconnectHandler(platformView);
        }
        ...
    }
}

VideoHandler derives from the ViewHandler class, with the generic Video argument specifying the cross-platform control type, and the MauiVideoPlayer argument specifying the type that encapsulates the AVPlayer and AVPlayerViewController native views.

The CreatePlatformView override creates and returns a MauiVideoPlayer object. The ConnectHandler override is the location to perform any required native view setup. The DisconnectHandler override is the location to perform any native view cleanup, and so calls the Dispose method on the MauiVideoPlayer instance.

The platform handler also has to implement the Actions defined in the property mapper dictionary:

public partial class VideoHandler : ViewHandler<Video, MauiVideoPlayer>
{
    ...
    public static void MapAreTransportControlsEnabled(VideoHandler handler, Video video)
    {
        handler?.PlatformView.UpdateTransportControlsEnabled();
    }

    public static void MapSource(VideoHandler handler, Video video)
    {
        handler?.PlatformView.UpdateSource();
    }

    public static void MapIsLooping(VideoHandler handler, Video video)
    {
        handler.PlatformView?.UpdateIsLooping();
    }    

    public static void MapPosition(VideoHandler handler, Video video)
    {
        handler?.PlatformView.UpdatePosition();
    }
    ...
}

Each Action is executed in response to a property changing on the cross-platform control, and is a static method that requires handler and cross-platform control instances as arguments. In each case, the Action calls a method defined in the MauiVideoPlayer type.

The platform handler also has to implement the Actions defined in the command mapper dictionary:

public partial class VideoHandler : ViewHandler<Video, MauiVideoPlayer>
{
    ...
    public static void MapUpdateStatus(VideoHandler handler, Video video, object? args)
    {
        handler.PlatformView?.UpdateStatus();
    }

    public static void MapPlayRequested(VideoHandler handler, Video video, object? args)
    {
        if (args is not VideoPositionEventArgs)
            return;

        TimeSpan position = ((VideoPositionEventArgs)args).Position;
        handler.PlatformView?.PlayRequested(position);
    }

    public static void MapPauseRequested(VideoHandler handler, Video video, object? args)
    {
        if (args is not VideoPositionEventArgs)
            return;

        TimeSpan position = ((VideoPositionEventArgs)args).Position;
        handler.PlatformView?.PauseRequested(position);
    }

    public static void MapStopRequested(VideoHandler handler, Video video, object? args)
    {
        if (args is not VideoPositionEventArgs)
            return;

        TimeSpan position = ((VideoPositionEventArgs)args).Position;
        handler.PlatformView?.StopRequested(position);
    }
    ...
}

Each Action is executed in response to a command being sent from the cross-platform control, and is a static method that requires handler and cross-platform control instances, and optional data as arguments. In each case, the Action calls a method defined in the MauiVideoPlayer class, after extracting the optional data.

On iOS and Mac Catalyst, the MauiVideoPlayer class encapsulates the AVPlayer and AVPlayerViewController types to keep the native views separated from their handler:

using AVFoundation;
using AVKit;
using CoreMedia;
using Foundation;
using System.Diagnostics;
using UIKit;
using VideoDemos.Controls;

namespace VideoDemos.Platforms.MaciOS
{
    public class MauiVideoPlayer : UIView
    {
        AVPlayer _player;
        AVPlayerViewController _playerViewController;
        Video _video;
        ...

        public MauiVideoPlayer(Video video)
        {
            _video = video;

            _playerViewController = new AVPlayerViewController();
            _player = new AVPlayer();
            _playerViewController.Player = _player;
            _playerViewController.View.Frame = this.Bounds;

#if IOS16_0_OR_GREATER || MACCATALYST16_1_OR_GREATER
            // On iOS 16 and Mac Catalyst 16, for Shell-based apps, the AVPlayerViewController has to be added to the parent ViewController, otherwise the transport controls won't be displayed.
            var viewController = WindowStateManager.Default.GetCurrentUIViewController();

            // If there's no view controller, assume it's not Shell and continue because the transport controls will still be displayed.
            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.View.AddSubview(_playerViewController.View);
            }
#endif
            // Use the View from the AVPlayerViewController as the native control
            AddSubview(_playerViewController.View);
        }
        ...
    }
}

MauiVideoPlayer derives from UIView, which is the base class on iOS and Mac Catalyst for objects that display content and handle user interaction with that content. The constructor creates an AVPlayer object, which manages the playback and timing of a media file, and sets it as the Player property value of an AVPlayerViewController. The AVPlayerViewController displays content from the AVPlayer and presents transport controls and other features. The size and location of the control is then set, which ensures that the video is centered in the page and expands to fill the available space while maintaining its aspect ratio. On iOS 16 and Mac Catalyst 16, the AVPlayerViewController has to be added to the parent ViewController for Shell-based apps, otherwise the transport controls aren't displayed. The native view, which is the view from the AVPlayerViewController, is then added to the page.

The Dispose method is responsible for performing native view cleanup:

public class MauiVideoPlayer : UIView
{
    AVPlayer _player;
    AVPlayerViewController _playerViewController;
    Video _video;
    ...

    protected override void Dispose(bool disposing)
    {
        if (disposing)
        {
            if (_player != null)
            {
                DestroyPlayedToEndObserver();
                _player.ReplaceCurrentItemWithPlayerItem(null);
                _player.Dispose();
            }
            if (_playerViewController != null)
                _playerViewController.Dispose();

            _video = null;
        }

        base.Dispose(disposing);
    }
    ...
}

In some scenarios, videos continue playing after a video playback page has been navigated away from. To stop the video, the ReplaceCurrentItemWithPlayerItem is set to null in the Dispose override, and other native view cleanup is performed.

Note

The Dispose override is called by the handler's DisconnectHandler override.

The platform transport controls include buttons that play, pause, and stop the video, and are provided by the AVPlayerViewController type. If the Video.AreTransportControlsEnabled property is set to true, the AVPlayerViewController will display its playback controls. This occurs because when the AreTransportControlsEnabled property is set, the handler's property mapper ensures that the MapAreTransportControlsEnabled method is invoked, which in turn calls the UpdateTransportControlsEnabled method in MauiVideoPlayer:

public class MauiVideoPlayer : UIView
{
    AVPlayerViewController _playerViewController;
    Video _video;
    ...

    public void UpdateTransportControlsEnabled()
    {
        _playerViewController.ShowsPlaybackControls = _video.AreTransportControlsEnabled;
    }
    ...
}

The transport controls fade out if they're not used but can be restored by tapping on the video.

If the Video.AreTransportControlsEnabled property is set to false, the AVPlayerViewController doesn't show its playback controls. In this scenario, you can then control video playback programmatically or supply your own transport controls. For more information, see Create custom transport controls.

Windows

Video is played on Windows with the MediaPlayerElement. However, here, the MediaPlayerElement has been encapsulated in a MauiVideoPlayer type to keep the native view separated from its handler. The following example shows the VideoHandler partial class fo Windows, with its three overrides:

#nullable enable
using Microsoft.Maui.Handlers;
using VideoDemos.Controls;
using VideoDemos.Platforms.Windows;

namespace VideoDemos.Handlers
{
    public partial class VideoHandler : ViewHandler<Video, MauiVideoPlayer>
    {
        protected override MauiVideoPlayer CreatePlatformView() => new MauiVideoPlayer(VirtualView);

        protected override void ConnectHandler(MauiVideoPlayer platformView)
        {
            base.ConnectHandler(platformView);

            // Perform any control setup here
        }

        protected override void DisconnectHandler(MauiVideoPlayer platformView)
        {
            platformView.Dispose();
            base.DisconnectHandler(platformView);
        }
        ...
    }
}

VideoHandler derives from the ViewHandler class, with the generic Video argument specifying the cross-platform control type, and the MauiVideoPlayer argument specifying the type that encapsulates the MediaPlayerElement native view.

The CreatePlatformView override creates and returns a MauiVideoPlayer object. The ConnectHandler override is the location to perform any required native view setup. The DisconnectHandler override is the location to perform any native view cleanup, and so calls the Dispose method on the MauiVideoPlayer instance.

The platform handler also has to implement the Actions defined in the property mapper dictionary:

public partial class VideoHandler : ViewHandler<Video, MauiVideoPlayer>
{
    ...
    public static void MapAreTransportControlsEnabled(VideoHandler handler, Video video)
    {
        handler.PlatformView?.UpdateTransportControlsEnabled();
    }

    public static void MapSource(VideoHandler handler, Video video)
    {
        handler.PlatformView?.UpdateSource();
    }

    public static void MapIsLooping(VideoHandler handler, Video video)
    {
        handler.PlatformView?.UpdateIsLooping();
    }

    public static void MapPosition(VideoHandler handler, Video video)
    {
        handler.PlatformView?.UpdatePosition();
    }
    ...
}

Each Action is executed in response to a property changing on the cross-platform control, and is a static method that requires handler and cross-platform control instances as arguments. In each case, the Action calls a method defined in the MauiVideoPlayer type.

The platform handler also has to implement the Actions defined in the command mapper dictionary:

public partial class VideoHandler : ViewHandler<Video, MauiVideoPlayer>
{
    ...
    public static void MapUpdateStatus(VideoHandler handler, Video video, object? args)
    {
        handler.PlatformView?.UpdateStatus();
    }

    public static void MapPlayRequested(VideoHandler handler, Video video, object? args)
    {
        if (args is not VideoPositionEventArgs)
            return;

        TimeSpan position = ((VideoPositionEventArgs)args).Position;
        handler.PlatformView?.PlayRequested(position);
    }

    public static void MapPauseRequested(VideoHandler handler, Video video, object? args)
    {
        if (args is not VideoPositionEventArgs)
            return;

        TimeSpan position = ((VideoPositionEventArgs)args).Position;
        handler.PlatformView?.PauseRequested(position);
    }

    public static void MapStopRequested(VideoHandler handler, Video video, object? args)
    {
        if (args is not VideoPositionEventArgs)
            return;

        TimeSpan position = ((VideoPositionEventArgs)args).Position;
        handler.PlatformView?.StopRequested(position);
    }
    ...
}

Each Action is executed in response to a command being sent from the cross-platform control, and is a static method that requires handler and cross-platform control instances, and optional data as arguments. In each case, the Action calls a method defined in the MauiVideoPlayer class, after extracting the optional data.

On Windows, the MauiVideoPlayer class encapsulates the MediaPlayerElement to keep the native view separated from its handler:

using Microsoft.UI.Xaml.Controls;
using VideoDemos.Controls;
using Windows.Media.Core;
using Windows.Media.Playback;
using Windows.Storage;
using Grid = Microsoft.UI.Xaml.Controls.Grid;

namespace VideoDemos.Platforms.Windows
{
    public class MauiVideoPlayer : Grid, IDisposable
    {
        MediaPlayerElement _mediaPlayerElement;
        Video _video;
        ...

        public MauiVideoPlayer(Video video)
        {
            _video = video;
            _mediaPlayerElement = new MediaPlayerElement();
            this.Children.Add(_mediaPlayerElement);
        }
        ...
    }
}

MauiVideoPlayer derives from Grid, and the MediaPlayerElement is added as a child of the Grid. This enables the MediaPlayerElement to automatically size to fill all available space.

The Dispose method is responsible for performing native view cleanup:

public class MauiVideoPlayer : Grid, IDisposable
{
    MediaPlayerElement _mediaPlayerElement;
    Video _video;
    bool _isMediaPlayerAttached;
    ...

    public void Dispose()
    {
        if (_isMediaPlayerAttached)
        {
            _mediaPlayerElement.MediaPlayer.MediaOpened -= OnMediaPlayerMediaOpened;
            _mediaPlayerElement.MediaPlayer.Dispose();
        }
        _mediaPlayerElement = null;
    }
    ...
}

In addition to unsubscribing from the MediaOpened event, the Dispose override also performs native view cleanup.

Note

The Dispose override is called by the handler's DisconnectHandler override.

The platform transport controls include buttons that play, pause, and stop the video, and are provided by the MediaPlayerElement type. If the Video.AreTransportControlsEnabled property is set to true, the MediaPlayerElement will display its playback controls. This occurs because when the AreTransportControlsEnabled property is set, the handler's property mapper ensures that the MapAreTransportControlsEnabled method is invoked, which in turn calls the UpdateTransportControlsEnabled method in MauiVideoPlayer:

public class MauiVideoPlayer : Grid, IDisposable
{
    MediaPlayerElement _mediaPlayerElement;
    Video _video;
    bool _isMediaPlayerAttached;
    ...

    public void UpdateTransportControlsEnabled()
    {
        _mediaPlayerElement.AreTransportControlsEnabled = _video.AreTransportControlsEnabled;
    }
    ...

}

If the Video.AreTransportControlsEnabled property is set to false, the MediaPlayerElement doesn't show its playback controls. In this scenario, you can then control video playback programmatically or supply your own transport controls. For more information, see Create custom transport controls.

Convert a cross-platform control into a platform control

Any .NET MAUI cross-platform control, that derives from Element, can be converted to its underlying platform control with the ToPlatform extension method:

  • On Android, ToPlatform converts a .NET MAUI control to an Android View object.
  • On iOS and Mac Catalyst, ToPlatform converts a .NET MAUI control to a UIView object.
  • On Windows, ToPlatform converts a .NET MAUI control to a FrameworkElement object.

Note

The ToPlatform method is in the Microsoft.Maui.Platform namespace.

On all platforms, the ToPlatform method requires a MauiContext argument.

The ToPlatform method can convert a cross-platform control to its underlying platform control from platform code, such as in a partial handler class for a platform:

using Microsoft.Maui.Handlers;
using Microsoft.Maui.Platform;
using VideoDemos.Controls;
using VideoDemos.Platforms.Android;

namespace VideoDemos.Handlers
{
    public partial class VideoHandler : ViewHandler<Video, MauiVideoPlayer>
    {
        ...
        public static void MapSource(VideoHandler handler, Video video)
        {
            handler.PlatformView?.UpdateSource();

            // Convert cross-platform control to its underlying platform control
            MauiVideoPlayer mvp = (MauiVideoPlayer)video.ToPlatform(handler.MauiContext);
            ...
        }
        ...
    }
}

In this example, in the VideoHandler partial class for Android, the MapSource method converts the Video instance to a MauiVideoPlayer object.

The ToPlatform method can also convert a cross-platform control to its underlying platform control from cross-platform code:

using Microsoft.Maui.Platform;

namespace VideoDemos.Views;

public partial class MyPage : ContentPage
{
    ...
    protected override void OnHandlerChanged()
    {
        // Convert cross-platform control to its underlying platform control
#if ANDROID
        Android.Views.View nativeView = video.ToPlatform(video.Handler.MauiContext);
#elif IOS || MACCATALYST
        UIKit.UIView nativeView = video.ToPlatform(video.Handler.MauiContext);
#elif WINDOWS
        Microsoft.UI.Xaml.FrameworkElement nativeView = video.ToPlatform(video.Handler.MauiContext);
#endif
        ...
    }
    ...
}

In this example, a cross-platform Video control named video is converted to its underlying native view on each platform in the OnHandlerChanged() override. This override is called when the native view that implements the cross-platform control is available and initialized. The object returned by the ToPlatform method could be cast to its exact native type, which here is a MauiVideoPlayer.

Play a video

The Video class defines a Source property, which is used to specify the source of the video file, and an AutoPlay property. AutoPlay defaults to true, which means that the video should begin playing automatically after Source has been set. For the definition of these properties, see Create the cross-platform control.

The Source property is of type VideoSource, which is an abstract class that consists of three static methods that instantiate the three classes that derive from VideoSource:

using System.ComponentModel;

namespace VideoDemos.Controls
{
    [TypeConverter(typeof(VideoSourceConverter))]
    public abstract class VideoSource : Element
    {
        public static VideoSource FromUri(string uri)
        {
            return new UriVideoSource { Uri = uri };
        }

        public static VideoSource FromFile(string file)
        {
            return new FileVideoSource { File = file };
        }

        public static VideoSource FromResource(string path)
        {
            return new ResourceVideoSource { Path = path };
        }
    }
}

The VideoSource class includes a TypeConverter attribute that references VideoSourceConverter:

using System.ComponentModel;

namespace VideoDemos.Controls
{
    public class VideoSourceConverter : TypeConverter, IExtendedTypeConverter
    {
        object IExtendedTypeConverter.ConvertFromInvariantString(string value, IServiceProvider serviceProvider)
        {
            if (!string.IsNullOrWhiteSpace(value))
            {
                Uri uri;
                return Uri.TryCreate(value, UriKind.Absolute, out uri) && uri.Scheme != "file" ?
                    VideoSource.FromUri(value) : VideoSource.FromResource(value);
            }
            throw new InvalidOperationException("Cannot convert null or whitespace to VideoSource.");
        }
    }
}

The type converter is invoked when the Source property is set to a string in XAML. The ConvertFromInvariantString method attempts to convert the string to a Uri object. If it succeeds, and the scheme isn't file, then the method returns a UriVideoSource. Otherwise it returns a ResourceVideoSource.

Play a web video

The UriVideoSource class is used to specify a remote video with a URI. It defines a Uri property of type string:

namespace VideoDemos.Controls
{
    public class UriVideoSource : VideoSource
    {
        public static readonly BindableProperty UriProperty =
            BindableProperty.Create(nameof(Uri), typeof(string), typeof(UriVideoSource));

        public string Uri
        {
            get { return (string)GetValue(UriProperty); }
            set { SetValue(UriProperty, value); }
        }
    }
}

When the Source property is set to a UriVideoSource, the handler's property mapper ensures that the MapSource method is invoked:

public static void MapSource(VideoHandler handler, Video video)
{
    handler?.PlatformView.UpdateSource();
}

The MapSource method in turns calls the UpdateSource method on the handler's PlatformView property. The PlatformView property, which is of type MauiVideoPlayer, represents the native view that provides the video player implementation on each platform.

Android

Video is played on Android with a VideoView. The following code example shows how the UpdateSource method processes the Source property when it's of type UriVideoSource:

using Android.Content;
using Android.Views;
using Android.Widget;
using AndroidX.CoordinatorLayout.Widget;
using VideoDemos.Controls;
using Color = Android.Graphics.Color;
using Uri = Android.Net.Uri;

namespace VideoDemos.Platforms.Android
{
    public class MauiVideoPlayer : CoordinatorLayout
    {
        VideoView _videoView;
        bool _isPrepared;
        Video _video;
        ...

        public void UpdateSource()
        {
            _isPrepared = false;
            bool hasSetSource = false;

            if (_video.Source is UriVideoSource)
            {
                string uri = (_video.Source as UriVideoSource).Uri;
                if (!string.IsNullOrWhiteSpace(uri))
                {
                    _videoView.SetVideoURI(Uri.Parse(uri));
                    hasSetSource = true;
                }
            }
            ...

            if (hasSetSource && _video.AutoPlay)
            {
                _videoView.Start();
            }
        }
        ...
    }
}

When processing objects of type UriVideoSource, the SetVideoUri method of VideoView is used to specify the video to be played, with an Android Uri object created from the string URI.

The AutoPlay property has no equivalent on VideoView, so the Start method is called if a new video has been set.

iOS and Mac Catalyst

To play a video on iOS and Mac Catalyst, an object of type AVAsset is created to encapsulate the video, and that is used to create an AVPlayerItem, which is then handed off to the AVPlayer object. The following code example shows how the UpdateSource method processes the Source property when it's of type UriVideoSource:

using AVFoundation;
using AVKit;
using CoreMedia;
using Foundation;
using System.Diagnostics;
using UIKit;
using VideoDemos.Controls;

namespace VideoDemos.Platforms.MaciOS
{
    public class MauiVideoPlayer : UIView
    {
        AVPlayer _player;
        AVPlayerItem _playerItem;
        Video _video;
        ...

        public void UpdateSource()
        {
            AVAsset asset = null;

            if (_video.Source is UriVideoSource)
            {
                string uri = (_video.Source as UriVideoSource).Uri;
                if (!string.IsNullOrWhiteSpace(uri))
                    asset = AVAsset.FromUrl(new NSUrl(uri));
            }
            ...

            if (asset != null)
                _playerItem = new AVPlayerItem(asset);
            else
                _playerItem = null;

            _player.ReplaceCurrentItemWithPlayerItem(_playerItem);
            if (_playerItem != null && _video.AutoPlay)
            {
                _player.Play();
            }
        }
        ...
    }
}

When processing objects of type UriVideoSource, the static AVAsset.FromUrl method is used to specify the video to be played, with an iOS NSUrl object created from the string URI.

The AutoPlay property has no equivalent in the iOS video classes, so the property is examined at the end of the UpdateSource method to call the Play method on the AVPlayer object.

In some cases on iOS, videos continue playing after the video playback page has been navigated away from. To stop the video, the ReplaceCurrentItemWithPlayerItem is set to null in the Dispose override:

protected override void Dispose(bool disposing)
{
    if (disposing)
    {
        if (_player != null)
        {
            _player.ReplaceCurrentItemWithPlayerItem(null);
            ...
        }
        ...
    }
    base.Dispose(disposing);
}

Windows

Video is played on Windows with a MediaPlayerElement. The following code example shows how the UpdateSource method processes the Source property when it's of type UriVideoSource:

using Microsoft.UI.Xaml.Controls;
using VideoDemos.Controls;
using Windows.Media.Core;
using Windows.Media.Playback;
using Windows.Storage;
using Grid = Microsoft.UI.Xaml.Controls.Grid;

namespace VideoDemos.Platforms.Windows
{
    public class MauiVideoPlayer : Grid, IDisposable
    {
        MediaPlayerElement _mediaPlayerElement;
        Video _video;
        bool _isMediaPlayerAttached;
        ...

        public async void UpdateSource()
        {
            bool hasSetSource = false;

            if (_video.Source is UriVideoSource)
            {
                string uri = (_video.Source as UriVideoSource).Uri;
                if (!string.IsNullOrWhiteSpace(uri))
                {
                    _mediaPlayerElement.Source = MediaSource.CreateFromUri(new Uri(uri));
                    hasSetSource = true;
                }
            }
            ...

            if (hasSetSource && !_isMediaPlayerAttached)
            {
                _isMediaPlayerAttached = true;
                _mediaPlayerElement.MediaPlayer.MediaOpened += OnMediaPlayerMediaOpened;
            }

            if (hasSetSource && _video.AutoPlay)
            {
                _mediaPlayerElement.AutoPlay = true;
            }
        }
        ...
    }
}

When processing objects of type UriVideoSource, the MediaPlayerElement.Source property is set to a MediaSource object that initializes a Uri with the URI of the video to be played. When the MediaPlayerElement.Source has been set, the OnMediaPlayerMediaOpened event handler method is registered against the MediaPlayerElement.MediaPlayer.MediaOpened event. This event handler is used to set the Duration property of the Video control.

At the end of the UpdateSource method, the Video.AutoPlay property is examined and if it's true the MediaPlayerElement.AutoPlay property is set to true to start video playback.

Play a video resource

The ResourceVideoSource class is used to access video files that are embedded in the app. It defines a Path property of type string:

namespace VideoDemos.Controls
{
    public class ResourceVideoSource : VideoSource
    {
        public static readonly BindableProperty PathProperty =
            BindableProperty.Create(nameof(Path), typeof(string), typeof(ResourceVideoSource));

        public string Path
        {
            get { return (string)GetValue(PathProperty); }
            set { SetValue(PathProperty, value); }
        }
    }
}

When the Source property is set to a ResourceVideoSource, the handler's property mapper ensures that the MapSource method is invoked:

public static void MapSource(VideoHandler handler, Video video)
{
    handler?.PlatformView.UpdateSource();
}

The MapSource method in turns calls the UpdateSource method on the handler's PlatformView property. The PlatformView property, which is of type MauiVideoPlayer, represents the native view that provides the video player implementation on each platform.

Android

The following code example shows how the UpdateSource method processes the Source property when it's of type ResourceVideoSource:

using Android.Content;
using Android.Views;
using Android.Widget;
using AndroidX.CoordinatorLayout.Widget;
using VideoDemos.Controls;
using Color = Android.Graphics.Color;
using Uri = Android.Net.Uri;

namespace VideoDemos.Platforms.Android
{
    public class MauiVideoPlayer : CoordinatorLayout
    {
        VideoView _videoView;
        bool _isPrepared;
        Context _context;
        Video _video;
        ...

        public void UpdateSource()
        {
            _isPrepared = false;
            bool hasSetSource = false;
            ...

            else if (_video.Source is ResourceVideoSource)
            {
                string package = Context.PackageName;
                string path = (_video.Source as ResourceVideoSource).Path;
                if (!string.IsNullOrWhiteSpace(path))
                {
                    string assetFilePath = "content://" + package + "/" + path;
                    _videoView.SetVideoPath(assetFilePath);
                    hasSetSource = true;
                }
            }
            ...
        }
        ...
    }
}

When processing objects of type ResourceVideoSource, the SetVideoPath method of VideoView is used to specify the video to be played, with a string argument combining the app's package name with the video's filename.

A resource video file is stored in the package's assets folder, and requires a content provider to access it. The content provider is provided by the VideoProvider class, which creates an AssetFileDescriptor object that provides access to the video file:

using Android.Content;
using Android.Content.Res;
using Android.Database;
using Debug = System.Diagnostics.Debug;
using Uri = Android.Net.Uri;

namespace VideoDemos.Platforms.Android
{
    [ContentProvider(new string[] { "com.companyname.videodemos" })]
    public class VideoProvider : ContentProvider
    {
        public override AssetFileDescriptor OpenAssetFile(Uri uri, string mode)
        {
            var assets = Context.Assets;
            string fileName = uri.LastPathSegment;
            if (fileName == null)
                throw new FileNotFoundException();

            AssetFileDescriptor afd = null;
            try
            {
                afd = assets.OpenFd(fileName);
            }
            catch (IOException ex)
            {
                Debug.WriteLine(ex);
            }
            return afd;
        }

        public override bool OnCreate()
        {
            return false;
        }
        ...
    }
}

iOS and Mac Catalyst

The following code example shows how the UpdateSource method processes the Source property when it's of type ResourceVideoSource:

using AVFoundation;
using AVKit;
using CoreMedia;
using Foundation;
using System.Diagnostics;
using UIKit;
using VideoDemos.Controls;

namespace VideoDemos.Platforms.MaciOS
{
    public class MauiVideoPlayer : UIView
    {
        Video _video;
        ...

        public void UpdateSource()
        {
            AVAsset asset = null;
            ...

            else if (_video.Source is ResourceVideoSource)
            {
                string path = (_video.Source as ResourceVideoSource).Path;
                if (!string.IsNullOrWhiteSpace(path))
                {
                    string directory = Path.GetDirectoryName(path);
                    string filename = Path.GetFileNameWithoutExtension(path);
                    string extension = Path.GetExtension(path).Substring(1);
                    NSUrl url = NSBundle.MainBundle.GetUrlForResource(filename, extension, directory);
                    asset = AVAsset.FromUrl(url);
                }
            }
            ...
        }
        ...
    }
}

When processing objects of type ResourceVideoSource, the GetUrlForResource method of NSBundle is used to retrieve the file from the app package. The complete path must be divided into a filename, extension, and directory.

In some cases on iOS, videos continue playing after the video playback page has been navigated away from. To stop the video, the ReplaceCurrentItemWithPlayerItem is set to null in the Dispose override:

protected override void Dispose(bool disposing)
{
    if (disposing)
    {
        if (_player != null)
        {
            _player.ReplaceCurrentItemWithPlayerItem(null);
            ...
        }
        ...
    }
    base.Dispose(disposing);
}

Windows

The following code example shows how the UpdateSource method processes the Source property when it's of type ResourceVideoSource:

using Microsoft.UI.Xaml.Controls;
using VideoDemos.Controls;
using Windows.Media.Core;
using Windows.Media.Playback;
using Windows.Storage;
using Grid = Microsoft.UI.Xaml.Controls.Grid;

namespace VideoDemos.Platforms.Windows
{
    public class MauiVideoPlayer : Grid, IDisposable
    {
        MediaPlayerElement _mediaPlayerElement;
        Video _video;
        ...

        public async void UpdateSource()
        {
            bool hasSetSource = false;

            ...
            else if (_video.Source is ResourceVideoSource)
            {
                string path = "ms-appx:///" + (_video.Source as ResourceVideoSource).Path;
                if (!string.IsNullOrWhiteSpace(path))
                {
                    _mediaPlayerElement.Source = MediaSource.CreateFromUri(new Uri(path));
                    hasSetSource = true;
                }
            }
            ...
        }
        ...
    }
}

When processing objects of type ResourceVideoSource, the MediaPlayerElement.Source property is set to a MediaSource object that initializes a Uri with the path of the video resource prefixed with ms-appx:///.

Play a video file from the device's library

The FileVideoSource class is used to access videos in the device's video library. It defines a File property of type string:

namespace VideoDemos.Controls
{
    public class FileVideoSource : VideoSource
    {
        public static readonly BindableProperty FileProperty =
            BindableProperty.Create(nameof(File), typeof(string), typeof(FileVideoSource));

        public string File
        {
            get { return (string)GetValue(FileProperty); }
            set { SetValue(FileProperty, value); }
        }
    }
}

When the Source property is set to a FileVideoSource, the handler's property mapper ensures that the MapSource method is invoked:

public static void MapSource(VideoHandler handler, Video video)
{
    handler?.PlatformView.UpdateSource();
}

The MapSource method in turns calls the UpdateSource method on the handler's PlatformView property. The PlatformView property, which is of type MauiVideoPlayer, represents the native view that provides the video player implementation on each platform.

Android

The following code example shows how the UpdateSource method processes the Source property when it's of type FileVideoSource:

using Android.Content;
using Android.Views;
using Android.Widget;
using AndroidX.CoordinatorLayout.Widget;
using VideoDemos.Controls;
using Color = Android.Graphics.Color;
using Uri = Android.Net.Uri;

namespace VideoDemos.Platforms.Android
{
    public class MauiVideoPlayer : CoordinatorLayout
    {
        VideoView _videoView;
        bool _isPrepared;
        Video _video;
        ...

        public void UpdateSource()
        {
            _isPrepared = false;
            bool hasSetSource = false;
            ...

            else if (_video.Source is FileVideoSource)
            {
                string filename = (_video.Source as FileVideoSource).File;
                if (!string.IsNullOrWhiteSpace(filename))
                {
                    _videoView.SetVideoPath(filename);
                    hasSetSource = true;
                }
            }
            ...
        }
        ...
    }
}

When processing objects of type FileVideoSource, the SetVideoPath method of VideoView is used to specify the video file to be played.

iOS and Mac Catalyst

The following code example shows how the UpdateSource method processes the Source property when it's of type FileVideoSource:

using AVFoundation;
using AVKit;
using CoreMedia;
using Foundation;
using System.Diagnostics;
using UIKit;
using VideoDemos.Controls;

namespace VideoDemos.Platforms.MaciOS
{
    public class MauiVideoPlayer : UIView
    {
        Video _video;
        ...

        public void UpdateSource()
        {
            AVAsset asset = null;
            ...

            else if (_video.Source is FileVideoSource)
            {
                string uri = (_video.Source as FileVideoSource).File;
                if (!string.IsNullOrWhiteSpace(uri))
                    asset = AVAsset.FromUrl(NSUrl.CreateFileUrl(new [] { uri }));
            }
            ...
        }
        ...
    }
}

When processing objects of type FileVideoSource, the static AVAsset.FromUrl method is used to specify the video file to be played, with the NSUrl.CreateFileUrl method creating an iOS NSUrl object from the string URI.

Windows

The following code example shows how the UpdateSource method processes the Source property when it's of type FileVideoSource:

using Microsoft.UI.Xaml.Controls;
using VideoDemos.Controls;
using Windows.Media.Core;
using Windows.Media.Playback;
using Windows.Storage;
using Grid = Microsoft.UI.Xaml.Controls.Grid;

namespace VideoDemos.Platforms.Windows
{
    public class MauiVideoPlayer : Grid, IDisposable
    {
        MediaPlayerElement _mediaPlayerElement;
        Video _video;
        ...

        public async void UpdateSource()
        {
            bool hasSetSource = false;

            ...
            else if (_video.Source is FileVideoSource)
            {
                string filename = (_video.Source as FileVideoSource).File;
                if (!string.IsNullOrWhiteSpace(filename))
                {
                    StorageFile storageFile = await StorageFile.GetFileFromPathAsync(filename);
                    _mediaPlayerElement.Source = MediaSource.CreateFromStorageFile(storageFile);
                    hasSetSource = true;
                }
            }
            ...
        }
        ...
    }
}

When processing objects of type FileVideoSource, the video filename is converted to a StorageFile object. Then, the MediaSource.CreateFromStorageFile method returns a MediaSource object that's set as the value of the MediaPlayerElement.Source property.

Loop a video

The Video class defines an IsLooping property, which enables the control to automatically set the video position to the start after reaching its end. It defaults to false, which indicates that videos don't automatically loop.

When the IsLooping property is set, the handler's property mapper ensures that the MapIsLooping method is invoked:

public static void MapIsLooping(VideoHandler handler, Video video)
{
    handler.PlatformView?.UpdateIsLooping();
}  

The MapIsLooping method in turn calls the UpdateIsLooping method on the handler's PlatformView property. The PlatformView property, which is of type MauiVideoPlayer, represents the native view that provides the video player implementation on each platform.

Android

The following code example shows how the UpdateIsLooping method on Android enables video looping:

using Android.Content;
using Android.Media;
using Android.Views;
using Android.Widget;
using AndroidX.CoordinatorLayout.Widget;
using VideoDemos.Controls;
using Color = Android.Graphics.Color;
using Uri = Android.Net.Uri;

namespace VideoDemos.Platforms.Android
{
    public class MauiVideoPlayer : CoordinatorLayout, MediaPlayer.IOnPreparedListener
    {
        VideoView _videoView;
        Video _video;
        ...

        public void UpdateIsLooping()
        {
            if (_video.IsLooping)
            {
                _videoView.SetOnPreparedListener(this);
            }
            else
            {
                _videoView.SetOnPreparedListener(null);
            }
        }

        public void OnPrepared(MediaPlayer mp)
        {
            mp.Looping = _video.IsLooping;
        }
        ...
    }
}

To enable video looping, the MauiVideoPlayer class implements the MediaPlayer.IOnPreparedListener interface. This interface defines an OnPrepared callback that's invoked when the media source is ready for playback. When the Video.IsLooping property is true, the UpdateIsLooping method sets MauiVideoPlayer as the object that provides the OnPrepared callback. The callback sets the MediaPlayer.IsLooping property to the value of the Video.IsLooping property.

iOS and Mac Catalyst

The following code example shows how the UpdateIsLooping method on iOS and Mac Catalyst enables video looping:

using System.Diagnostics;
using AVFoundation;
using AVKit;
using CoreMedia;
using Foundation;
using UIKit;
using VideoDemos.Controls;

namespace VideoDemos.Platforms.MaciOS
{
    public class MauiVideoPlayer : UIView
    {
        AVPlayer _player;
        AVPlayerViewController _playerViewController;
        Video _video;
        NSObject? _playedToEndObserver;
        ...

        public void UpdateIsLooping()
        {
            DestroyPlayedToEndObserver();
            if (_video.IsLooping)
            {
                _player.ActionAtItemEnd = AVPlayerActionAtItemEnd.None;
                _playedToEndObserver = NSNotificationCenter.DefaultCenter.AddObserver(AVPlayerItem.DidPlayToEndTimeNotification, PlayedToEnd);
            }
            else
                _player.ActionAtItemEnd = AVPlayerActionAtItemEnd.Pause;
        }

        void PlayedToEnd(NSNotification notification)
        {
            if (_video == null || notification.Object != _playerViewController.Player?.CurrentItem)
                return;

            _playerViewController.Player?.Seek(CMTime.Zero);
        }
        ...
    }
}

On iOS and Mac Catalyst, a notification is used to execute a callback when the video has been played to the end. When the Video.IsLooping property is true, the UpdateIsLooping method adds an observer for the AVPlayerItem.DidPlayToEndTimeNotification notification, and executes the PlayedToEnd method when the notification is received. In turn, this method resumes playback from the beginning of the video. If the Video.IsLooping property is false, the video pauses at the end of playback.

Because MauiVideoPlayer adds an observer for a notification it must also remove the observer when performing native view cleanup. This is accomplished in the Dispose override:

public class MauiVideoPlayer : UIView
{
    AVPlayer _player;
    AVPlayerViewController _playerViewController;
    Video _video;
    NSObject? _playedToEndObserver;
    ...

    protected override void Dispose(bool disposing)
    {
        if (disposing)
        {
            if (_player != null)
            {
                DestroyPlayedToEndObserver();
                ...
            }
            ...
        }

        base.Dispose(disposing);
    }

    void DestroyPlayedToEndObserver()
    {
        if (_playedToEndObserver != null)
        {
            NSNotificationCenter.DefaultCenter.RemoveObserver(_playedToEndObserver);
            DisposeObserver(ref _playedToEndObserver);
        }
    }

    void DisposeObserver(ref NSObject? disposable)
    {
        disposable?.Dispose();
        disposable = null;
    }
    ...
}

The Dispose override calls the DestroyPlayedToEndObserver method that removes the observer for the AVPlayerItem.DidPlayToEndTimeNotification notification, and which also invokes the Dispose method on the NSObject.

Windows

The following code example shows how the UpdateIsLooping method on Windows enables video looping:

public void UpdateIsLooping()
{
    if (_isMediaPlayerAttached)
        _mediaPlayerElement.MediaPlayer.IsLoopingEnabled = _video.IsLooping;
}

To enable video looping, the UpdateIsLooping method sets the MediaPlayerElement.MediaPlayer.IsLoopingEnabled property to the value of the Video.IsLooping property.

Create custom transport controls

The transport controls of a video player include buttons that play, pause, and stop the video. These buttons are often identified with familiar icons rather than text, and the play and pause buttons are often combined into one button.

By default, the Video control displays transport controls supported by each platform. However, when you set the AreTransportControlsEnabled property to false, these controls are suppressed. You can then control video playback programmatically or supply your own transport controls.

Implementing your own transport controls requires the Video class to be able to notify its native views to play, pause, or stop the video, and know the current status of video playback. The Video class defines methods named Play, Pause, and Stop that raise a corresponding event, and send a command to the VideoHandler:

namespace VideoDemos.Controls
{
    public class Video : View, IVideoController
    {
        ...
        public event EventHandler<VideoPositionEventArgs> PlayRequested;
        public event EventHandler<VideoPositionEventArgs> PauseRequested;
        public event EventHandler<VideoPositionEventArgs> StopRequested;

        public void Play()
        {
            VideoPositionEventArgs args = new VideoPositionEventArgs(Position);
            PlayRequested?.Invoke(this, args);
            Handler?.Invoke(nameof(Video.PlayRequested), args);
        }

        public void Pause()
        {
            VideoPositionEventArgs args = new VideoPositionEventArgs(Position);
            PauseRequested?.Invoke(this, args);
            Handler?.Invoke(nameof(Video.PauseRequested), args);
        }

        public void Stop()
        {
            VideoPositionEventArgs args = new VideoPositionEventArgs(Position);
            StopRequested?.Invoke(this, args);
            Handler?.Invoke(nameof(Video.StopRequested), args);
        }
    }
}

The VideoPositionEventArgs class defines a Position property that can be set through its constructor. This property represents the position at which video playback was started, paused, or stopped.

The final line in the Play, Pause, and Stop methods sends a command and associated data to VideoHandler. The CommandMapper for VideoHandler maps command names to Actions that are executed when a command is received. For example, when VideoHandler receives the PlayRequested command, it executes its MapPlayRequested method. The advantage of this approach is that it removes the need for native views to subscribe to and unsubscribe from cross-platform control events. In addition, it allows for easy customization because the command mapper can be modified by cross-platform control consumers without subclassing. For more information about CommandMapper, see Create the command mapper.

The MauiVideoPlayer implementation on Android, iOS and Mac Catalyst, has PlayRequested, PauseRequested, and StopRequested methods that are executed in response to the Video control sending PlayRequested, PauseRequested, and StopRequested commands. Each method invokes a method on its native view to play, pause, or stop the video. For example, the following code shows the PlayRequested, PauseRequested, and StopRequested methods on iOS and Mac Catalyst:

using AVFoundation;
using AVKit;
using CoreMedia;
using Foundation;
using System.Diagnostics;
using UIKit;
using VideoDemos.Controls;

namespace VideoDemos.Platforms.MaciOS
{
    public class MauiVideoPlayer : UIView
    {
        AVPlayer _player;
        ...

        public void PlayRequested(TimeSpan position)
        {
            _player.Play();
            Debug.WriteLine($"Video playback from {position.Hours:X2}:{position.Minutes:X2}:{position.Seconds:X2}.");
        }

        public void PauseRequested(TimeSpan position)
        {
            _player.Pause();
            Debug.WriteLine($"Video paused at {position.Hours:X2}:{position.Minutes:X2}:{position.Seconds:X2}.");
        }

        public void StopRequested(TimeSpan position)
        {
            _player.Pause();
            _player.Seek(new CMTime(0, 1));
            Debug.WriteLine($"Video stopped at {position.Hours:X2}:{position.Minutes:X2}:{position.Seconds:X2}.");
        }
    }
}

Each of the three methods logs the position at which the video was played, paused, or stopped, using the data that's sent with the command.

This mechanism ensures that when the Play, Pause, or Stop method is invoked on the Video control, its native view is instructed to play, pause, or stop the video and log the position at which the video was played, paused, or stopped. This all happens using a decoupled approach, without native views having to subscribe to cross-platform events.

Video status

Implementing play, pause, and stop functionality isn't sufficient for supporting custom transport controls. Often the play and pause functionality should be implemented with the same button, which changes its appearance to indicate whether the video is currently playing or paused. In addition, the button shouldn't even be enabled if the video hasn't yet loaded.

These requirements imply that the video player needs to make available a current status indicating if it's playing or paused, or if it's not yet ready to play a video. This status can be represented by an enumeration:

public enum VideoStatus
{
    NotReady,
    Playing,
    Paused
}

The Video class defines a read-only bindable property named Status of type VideoStatus. This property is defined as read-only because it should only be set from the control's handler:

namespace VideoDemos.Controls
{
    public class Video : View, IVideoController
    {
        ...
        private static readonly BindablePropertyKey StatusPropertyKey =
            BindableProperty.CreateReadOnly(nameof(Status), typeof(VideoStatus), typeof(Video), VideoStatus.NotReady);

        public static readonly BindableProperty StatusProperty = StatusPropertyKey.BindableProperty;

        public VideoStatus Status
        {
            get { return (VideoStatus)GetValue(StatusProperty); }
        }

        VideoStatus IVideoController.Status
        {
            get { return Status; }
            set { SetValue(StatusPropertyKey, value); }
        }
        ...
    }
}

Usually, a read-only bindable property would have a private set accessor on the Status property to allow it to be set from within the class. However, for a View derivative supported by handlers, the property must be set from outside the class but only by the control's handler.

For this reason, another property is defined with the name IVideoController.Status. This is an explicit interface implementation, and is made possible by the IVideoController interface that the Video class implements:

public interface IVideoController
{
    VideoStatus Status { get; set; }
    TimeSpan Duration { get; set; }
}

This interface makes it possible for a class external to Video to set the Status property by referencing the IVideoController interface. The property can be set from other classes and the handler, but it's unlikely to be set inadvertently. Most importantly, the Status property can't be set through a data binding.

To assist the handler implementations in keeping the Status property updated, the Video class defines an UpdateStatus event and command:

using System.ComponentModel;

namespace VideoDemos.Controls
{
    public class Video : View, IVideoController
    {
        ...
        public event EventHandler UpdateStatus;

        IDispatcherTimer _timer;

        public Video()
        {
            _timer = Dispatcher.CreateTimer();
            _timer.Interval = TimeSpan.FromMilliseconds(100);
            _timer.Tick += OnTimerTick;
            _timer.Start();
        }

        ~Video() => _timer.Tick -= OnTimerTick;

        void OnTimerTick(object sender, EventArgs e)
        {
            UpdateStatus?.Invoke(this, EventArgs.Empty);
            Handler?.Invoke(nameof(Video.UpdateStatus));
        }
        ...
    }
}

The OnTimerTick event handler is executed every tenth of a second, which raises the UpdateStatus event and invokes the UpdateStatus command.

When the UpdateStatus command is sent from the Video control to its handler, the handler's command mapper ensures that the MapUpdateStatus method is invoked:

public static void MapUpdateStatus(VideoHandler handler, Video video, object? args)
{
    handler.PlatformView?.UpdateStatus();
}

The MapUpdateStatus method in turns calls the UpdateStatus method on the handler's PlatformView property. The PlatformView property, which is of type MauiVideoPlayer, encapsulates the native views that provide the video player implementation on each platform.

Android

The following code example shows the UpdateStatus method on Android sets the Status property:

using Android.Content;
using Android.Views;
using Android.Widget;
using AndroidX.CoordinatorLayout.Widget;
using VideoDemos.Controls;
using Color = Android.Graphics.Color;
using Uri = Android.Net.Uri;

namespace VideoDemos.Platforms.Android
{
    public class MauiVideoPlayer : CoordinatorLayout
    {
        VideoView _videoView;
        bool _isPrepared;
        Video _video;
        ...

        public MauiVideoPlayer(Context context, Video video) : base(context)
        {
            _video = video;
            ...
            _videoView.Prepared += OnVideoViewPrepared;
        }

        protected override void Dispose(bool disposing)
        {
            if (disposing)
            {
                _videoView.Prepared -= OnVideoViewPrepared;
                ...
            }

            base.Dispose(disposing);
        }

        void OnVideoViewPrepared(object sender, EventArgs args)
        {
            _isPrepared = true;
            ((IVideoController)_video).Duration = TimeSpan.FromMilliseconds(_videoView.Duration);
        }

        public void UpdateStatus()
        {
            VideoStatus status = VideoStatus.NotReady;

            if (_isPrepared)
                status = _videoView.IsPlaying ? VideoStatus.Playing : VideoStatus.Paused;

            ((IVideoController)_video).Status = status;
            ...
        }
        ...
    }
}

The VideoView.IsPlaying property is a Boolean that indicates if the video is playing or paused. To determine if the VideoView can't play or pause the video, its Prepared event must be handled. This event is raised when the media source is ready for playback. The event is subscribed to in the MauiVideoPlayer constructor, and unsubscribed from in its Dispose override. The UpdateStatus method then uses the isPrepared field and the VideoView.IsPlaying property to set the Status property on the Video object by casting it to IVideoController.

iOS and Mac Catalyst

The following code example shows the UpdateStatus method on iOS and Mac Catalyst sets the Status property:

using AVFoundation;
using AVKit;
using CoreMedia;
using Foundation;
using System.Diagnostics;
using UIKit;
using VideoDemos.Controls;

namespace VideoDemos.Platforms.MaciOS
{
    public class MauiVideoPlayer : UIView
    {
        AVPlayer _player;
        Video _video;
        ...

        public void UpdateStatus()
        {
            VideoStatus videoStatus = VideoStatus.NotReady;

            switch (_player.Status)
            {
                case AVPlayerStatus.ReadyToPlay:
                    switch (_player.TimeControlStatus)
                    {
                        case AVPlayerTimeControlStatus.Playing:
                            videoStatus = VideoStatus.Playing;
                            break;

                        case AVPlayerTimeControlStatus.Paused:
                            videoStatus = VideoStatus.Paused;
                            break;
                    }
                    break;
            }
            ((IVideoController)_video).Status = videoStatus;
            ...
        }
        ...
    }
}

Two properties of AVPlayer must be accessed to set the Status property - the Status property of type AVPlayerStatus and the TimeControlStatus property of type AVPlayerTimeControlStatus. The Status property can then be set on the Video object by casting it to IVideoController.

Windows

The following code example shows the UpdateStatus method on Windows sets the Status property:

using Microsoft.UI.Xaml.Controls;
using VideoDemos.Controls;
using Windows.Media.Core;
using Windows.Media.Playback;
using Windows.Storage;
using Grid = Microsoft.UI.Xaml.Controls.Grid;

namespace VideoDemos.Platforms.Windows
{
    public class MauiVideoPlayer : Grid, IDisposable
    {
        MediaPlayerElement _mediaPlayerElement;
        Video _video;
        bool _isMediaPlayerAttached;
        ...

        public void UpdateStatus()
        {
            if (_isMediaPlayerAttached)
            {
                VideoStatus status = VideoStatus.NotReady;

                switch (_mediaPlayerElement.MediaPlayer.CurrentState)
                {
                    case MediaPlayerState.Playing:
                        status = VideoStatus.Playing;
                        break;
                    case MediaPlayerState.Paused:
                    case MediaPlayerState.Stopped:
                        status = VideoStatus.Paused;
                        break;
                }

                ((IVideoController)_video).Status = status;
                _video.Position = _mediaPlayerElement.MediaPlayer.Position;
            }
        }
        ...
    }
}

The UpdateStatus method uses the value of the MediaPlayerElement.MediaPlayer.CurrentState property to determine the value of the Status property. The Status property can then be set on the Video object by casting it to IVideoController.

Positioning bar

The transport controls implemented by each platform include a positioning bar. This bar resembles a slider or scroll bar, and shows the current location of the video within its total duration. Users can manipulate the positioning bar to move forwards or backwards to a new position in the video.

Implementing your own positioning bar requires the Video class to know the duration of the video, and its current position within that duration.

Duration

One item of information that the Video control needs to support a custom positioning bar is the duration of the video. The Video class defines a read-only bindable property named Duration, of type TimeSpan. This property is defined as read-only because it should only be set from the control's handler:

namespace VideoDemos.Controls
{
    public class Video : View, IVideoController
    {
        ...
        private static readonly BindablePropertyKey DurationPropertyKey =
            BindableProperty.CreateReadOnly(nameof(Duration), typeof(TimeSpan), typeof(Video), new TimeSpan(),
                propertyChanged: (bindable, oldValue, newValue) => ((Video)bindable).SetTimeToEnd());

        public static readonly BindableProperty DurationProperty = DurationPropertyKey.BindableProperty;

        public TimeSpan Duration
        {
            get { return (TimeSpan)GetValue(DurationProperty); }
        }

        TimeSpan IVideoController.Duration
        {
            get { return Duration; }
            set { SetValue(DurationPropertyKey, value); }
        }
        ...
    }
}

Usually, a read-only bindable property would have a private set accessor on the Duration property to allow it to be set from within the class. However, for a View derivative supported by handlers, the property must be set from outside the class but only by the control's handler.

Note

The property-changed event handler for the Duration bindable property calls a method named SetTimeToEnd, which is described in Calculating time to end.

For this reason, another property is defined with the name IVideoController.Duration. This is an explicit interface implementation, and is made possible by the IVideoController interface that the Video class implements:

public interface IVideoController
{
    VideoStatus Status { get; set; }
    TimeSpan Duration { get; set; }
}

This interface makes it possible for a class external to Video to set the Duration property by referencing the IVideoController interface. The property can be set from other classes and the handler, but it's unlikely to be set inadvertently. Most importantly, the Duration property can't be set through a data binding.

The duration of a video isn't available immediately after the Source property of the Video control is set. The video must be partially downloaded before the native view can determine its duration.

Android

On Android, the VideoView.Duration property reports a valid duration in milliseconds after the VideoView.Prepared event has been raised. The MauiVideoPlayer class uses the Prepared event handler to obtain the Duration property value:

using Android.Content;
using Android.Views;
using Android.Widget;
using AndroidX.CoordinatorLayout.Widget;
using VideoDemos.Controls;
using Color = Android.Graphics.Color;
using Uri = Android.Net.Uri;

namespace VideoDemos.Platforms.Android
{
    public class MauiVideoPlayer : CoordinatorLayout
    {
        VideoView _videoView;
        Video _video;
        ...

        void OnVideoViewPrepared(object sender, EventArgs args)
        {
            ...
            ((IVideoController)_video).Duration = TimeSpan.FromMilliseconds(_videoView.Duration);
        }
        ...
    }
}
iOS and Mac Catalyst

On iOS and Mac Catalyst, the duration of a video is obtained from the AVPlayerItem.Duration property, but not immediately after the AVPlayerItem is created. It's possible to set an iOS observer for the Duration property, but the MauiVideoPlayer class obtains the duration in the UpdateStatus method that's called 10 times a second:

using AVFoundation;
using AVKit;
using CoreMedia;
using Foundation;
using System.Diagnostics;
using UIKit;
using VideoDemos.Controls;

namespace VideoDemos.Platforms.MaciOS
{
    public class MauiVideoPlayer : UIView
    {
        AVPlayerItem _playerItem;
        ...

        TimeSpan ConvertTime(CMTime cmTime)
        {
            return TimeSpan.FromSeconds(Double.IsNaN(cmTime.Seconds) ? 0 : cmTime.Seconds);
        }

        public void UpdateStatus()
        {
            ...
            if (_playerItem != null)
            {
                ((IVideoController)_video).Duration = ConvertTime(_playerItem.Duration);
                ...
            }
        }
        ...
    }
}

The ConvertTime method converts a CMTime object to a TimeSpan value.

Windows

On Windows, the MediaPlayerElement.MediaPlayer.NaturalDuration property is a TimeSpan value that becomes valid when the MediaPlayerElement.MediaPlayer.MediaOpened event has been raised. The MauiVideoPlayer class uses the MediaOpened event handler to obtain the NaturalDuration property value:

using Microsoft.UI.Xaml.Controls;
using VideoDemos.Controls;
using Windows.Media.Core;
using Windows.Media.Playback;
using Windows.Storage;
using Grid = Microsoft.UI.Xaml.Controls.Grid;

namespace VideoDemos.Platforms.Windows
{
    public class MauiVideoPlayer : Grid, IDisposable
    {
        MediaPlayerElement _mediaPlayerElement;
        Video _video;
        bool _isMediaPlayerAttached;
        ...

        void OnMediaPlayerMediaOpened(MediaPlayer sender, object args)
        {
            MainThread.BeginInvokeOnMainThread(() =>
            {
                ((IVideoController)_video).Duration = _mediaPlayerElement.MediaPlayer.NaturalDuration;
            });
        }
        ...
    }
}

The OnMediaPlayer event handler then calls the MainThread.BeginInvokeOnMainThread method to set the Duration property on the Video object, by casting it to IVideoController, on the main thread. This is necessary because the MediaPlayerElement.MediaPlayer.MediaOpened event is handled on a background thread. For more information about running code on the main thread, see Create a thread on the .NET MAUI UI thread.

Position

The Video control also needs a Position property that increases from zero to Duration as the video plays. The Video class implements this property as a bindable property with public get and set accessors:

namespace VideoDemos.Controls
{
    public class Video : View, IVideoController
    {
        ...
        public static readonly BindableProperty PositionProperty =
            BindableProperty.Create(nameof(Position), typeof(TimeSpan), typeof(Video), new TimeSpan(),
                propertyChanged: (bindable, oldValue, newValue) => ((Video)bindable).SetTimeToEnd());

        public TimeSpan Position
        {
            get { return (TimeSpan)GetValue(PositionProperty); }
            set { SetValue(PositionProperty, value); }
        }
        ...
    }
}

The get accessor returns the current position of the video as its playing. The set accessor responds to user manipulation of the positioning bar by moving the video position forwards or backwards.

Note

The property-changed event handler for the Position bindable property calls a method named SetTimeToEnd, which is described in Calculating time to end.

On Android, iOS and Mac Catalyst, the property that obtains the current position only has a get accessor. Instead, a Seek method is available to set the position. This seems to be a more sensible approach than using a single Position property, which has an inherent problem. As a video plays, a Position property must be continually updated to reflect the new position. But you don't want most changes of the Position property to cause the video player to move to a new position in the video. If that happens, the video player would respond by seeking to the last value of the Position property, and the video wouldn't advance.

Despite the difficulties of implementing a Position property with get and set accessors, this approach is used because it can utilize data binding. The Position property of the Video control can be bound to a Slider that's used both to display the position and to seek a new position. However, several precautions are necessary when implementing the Position property, to avoid feedback loops.

Android

On Android, the VideoView.CurrentPosition property indicates the current position of the video. The MauiVideoPlayer class sets the Position property in the UpdateStatus method at the same time as it sets the Duration property:

using Android.Content;
using Android.Views;
using Android.Widget;
using AndroidX.CoordinatorLayout.Widget;
using VideoDemos.Controls;
using Color = Android.Graphics.Color;
using Uri = Android.Net.Uri;

namespace VideoDemos.Platforms.Android
{
    public class MauiVideoPlayer : CoordinatorLayout
    {
        VideoView _videoView;
        Video _video;
        ...

        public void UpdateStatus()
        {
            ...
            TimeSpan timeSpan = TimeSpan.FromMilliseconds(_videoView.CurrentPosition);
            _video.Position = timeSpan;
        }

        public void UpdatePosition()
        {
            if (Math.Abs(_videoView.CurrentPosition - _video.Position.TotalMilliseconds) > 1000)
            {
                _videoView.SeekTo((int)_video.Position.TotalMilliseconds);
            }
        }
        ...
    }
}

Every time the Position property is set by the UpdateStatus method, the Position property fires a PropertyChanged event, which causes the property mapper for the handler to call the UpdatePosition method. The UpdatePosition method should do nothing for most of the property changes. Otherwise, with every change in the video's position it would be moved to same position it just reached. To avoid this feedback loop, the UpdatePosition only calls the Seek method on the VideoView object when the difference between the Position property and the current position of the VideoView is greater than one second.

iOS and Mac Catalyst

On iOS and Mac Catalyst, the AVPlayerItem.CurrentTime property indicates the current position of the video. The MauiVideoPlayer class sets the Position property in the UpdateStatus method at the same time as it sets the Duration property:

using AVFoundation;
using AVKit;
using CoreMedia;
using Foundation;
using System.Diagnostics;
using UIKit;
using VideoDemos.Controls;

namespace VideoDemos.Platforms.MaciOS
{
    public class MauiVideoPlayer : UIView
    {
        AVPlayer _player;
        AVPlayerItem _playerItem;
        Video _video;
        ...

        TimeSpan ConvertTime(CMTime cmTime)
        {
            return TimeSpan.FromSeconds(Double.IsNaN(cmTime.Seconds) ? 0 : cmTime.Seconds);
        }

        public void UpdateStatus()
        {
            ...
            if (_playerItem != null)
            {
                ...
                _video.Position = ConvertTime(_playerItem.CurrentTime);
            }
        }

        public void UpdatePosition()
        {
            TimeSpan controlPosition = ConvertTime(_player.CurrentTime);
            if (Math.Abs((controlPosition - _video.Position).TotalSeconds) > 1)
            {
                _player.Seek(CMTime.FromSeconds(_video.Position.TotalSeconds, 1));
            }
        }
        ...
    }
}

Every time the Position property is set by the UpdateStatus method, the Position property fires a PropertyChanged event, which causes the property mapper for the handler to call the UpdatePosition method. The UpdatePosition method should do nothing for most of the property changes. Otherwise, with every change in the video's position it would be moved to same position it just reached. To avoid this feedback loop, the UpdatePosition only calls the Seek method on the AVPlayer object when the difference between the Position property and the current position of the AVPlayer is greater than one second.

Windows

On Windows, the MediaPlayerElement.MedaPlayer.Position property indicates the current position of the video. The MauiVideoPlayer class sets the Position property in the UpdateStatus method at the same time as it sets the Duration property:

using Microsoft.UI.Xaml.Controls;
using VideoDemos.Controls;
using Windows.Media.Core;
using Windows.Media.Playback;
using Windows.Storage;
using Grid = Microsoft.UI.Xaml.Controls.Grid;

namespace VideoDemos.Platforms.Windows
{
    public class MauiVideoPlayer : Grid, IDisposable
    {
        MediaPlayerElement _mediaPlayerElement;
        Video _video;
        bool _isMediaPlayerAttached;
        ...

        public void UpdateStatus()
        {
            if (_isMediaPlayerAttached)
            {
                ...
                _video.Position = _mediaPlayerElement.MediaPlayer.Position;
            }
        }

        public void UpdatePosition()
        {
            if (_isMediaPlayerAttached)
            {
                if (Math.Abs((_mediaPlayerElement.MediaPlayer.Position - _video.Position).TotalSeconds) > 1)
                {
                    _mediaPlayerElement.MediaPlayer.Position = _video.Position;
                }
            }
        }
        ...
    }
}

Every time the Position property is set by the UpdateStatus method, the Position property fires a PropertyChanged event, which causes the property mapper for the handler to call the UpdatePosition method. The UpdatePosition method should do nothing for most of the property changes. Otherwise, with every change in the video's position it would be moved to same position it just reached. To avoid this feedback loop, the UpdatePosition only sets the MediaPlayerElement.MediaPlayer.Position property when the difference between the Position property and the current position of the MediaPlayerElement is greater than one second.

Calculating time to end

Sometimes video players show the time remaining in the video. This value begins at the video's duration when the video begins, and decreases down to zero when the video ends.

The Video class includes a read-only TimeToEnd property that's calculated based on changes to the Duration and Position properties:

namespace VideoDemos.Controls
{
    public class Video : View, IVideoController
    {
        ...
        private static readonly BindablePropertyKey TimeToEndPropertyKey =
            BindableProperty.CreateReadOnly(nameof(TimeToEnd), typeof(TimeSpan), typeof(Video), new TimeSpan());

        public static readonly BindableProperty TimeToEndProperty = TimeToEndPropertyKey.BindableProperty;

        public TimeSpan TimeToEnd
        {
            get { return (TimeSpan)GetValue(TimeToEndProperty); }
            private set { SetValue(TimeToEndPropertyKey, value); }
        }

        void SetTimeToEnd()
        {
            TimeToEnd = Duration - Position;
        }
        ...
    }
}

The SetTimeToEnd method is called from the property-changed event handlers of the Duration and Position properties.

Custom positioning bar

A custom positioning bar can be implemented by creating a class that derives from Slider, which contains Duration and Position properties of type TimeSpan:

namespace VideoDemos.Controls
{
    public class PositionSlider : Slider
    {
        public static readonly BindableProperty DurationProperty =
            BindableProperty.Create(nameof(Duration), typeof(TimeSpan), typeof(PositionSlider), new TimeSpan(1),
                propertyChanged: (bindable, oldValue, newValue) =>
                {
                    double seconds = ((TimeSpan)newValue).TotalSeconds;
                    ((Slider)bindable).Maximum = seconds <= 0 ? 1 : seconds;
                });

        public static readonly BindableProperty PositionProperty =
            BindableProperty.Create(nameof(Position), typeof(TimeSpan), typeof(PositionSlider), new TimeSpan(0),
                defaultBindingMode: BindingMode.TwoWay,
                propertyChanged: (bindable, oldValue, newValue) =>
                {
                    double seconds = ((TimeSpan)newValue).TotalSeconds;
                    ((Slider)bindable).Value = seconds;
                });

        public TimeSpan Duration
        {
            get { return (TimeSpan)GetValue(DurationProperty); }
            set { SetValue(DurationProperty, value); }
        }

        public TimeSpan Position
        {
            get { return (TimeSpan)GetValue(PositionProperty); }
            set { SetValue (PositionProperty, value); }
        }

        public PositionSlider()
        {
            PropertyChanged += (sender, args) =>
            {
                if (args.PropertyName == "Value")
                {
                    TimeSpan newPosition = TimeSpan.FromSeconds(Value);
                    if (Math.Abs(newPosition.TotalSeconds - Position.TotalSeconds) / Duration.TotalSeconds > 0.01)
                        Position = newPosition;
                }
            };
        }
    }
}

The property-changed event handler for the Duration property sets the Maximum property of the Slider to the TotalSeconds property of the TimeSpan value. Similarly, the property-changed event handler for the Position property sets the Value property of the Slider. This is the mechanism by which the Slider tracks the position of PositionSlider.

The PositionSlider is updated from the underlying Slider in only one scenario, which is when the user manipulates the Slider to indicate that the video should be advanced or reversed to a new position. This is detected in the PropertyChanged handler in the PositionSlider constructor. This event handler checks for a change in the Value property, and if it's different from the Position property, then the Position property is set from the Value property.

Register the handler

A custom control and its handler must be registered with an app, before it can be consumed. This should occur in the CreateMauiApp method in the MauiProgram class in your app project, which is the cross-platform entry point for the app:

using VideoDemos.Controls;
using VideoDemos.Handlers;

namespace VideoDemos;

public static class MauiProgram
{
    public static MauiApp CreateMauiApp()
    {
        var builder = MauiApp.CreateBuilder();
        builder
            .UseMauiApp<App>()
            .ConfigureFonts(fonts =>
            {
                fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
                fonts.AddFont("OpenSans-Semibold.ttf", "OpenSansSemibold");
            })
            .ConfigureMauiHandlers(handlers =>
            {
                handlers.AddHandler(typeof(Video), typeof(VideoHandler));
            });

        return builder.Build();
    }
}

The handler is registered with the ConfigureMauiHandlers and AddHandler method. The first argument to the AddHandler method is the cross-platform control type, with the second argument being its handler type.

Consume the cross-platform control

After registering the handler with your app, the cross-platform control can be consumed.

Play a web video

The Video control can play a video from a URL, as shown in the following example:

<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             xmlns:controls="clr-namespace:VideoDemos.Controls"
             x:Class="VideoDemos.Views.PlayWebVideoPage"
             Unloaded="OnContentPageUnloaded"
             Title="Play web video">
    <controls:Video x:Name="video"
                    Source="https://archive.org/download/BigBuckBunny_328/BigBuckBunny_512kb.mp4" />
</ContentPage>

In this example, the VideoSourceConverter class converts the string that represents the URI to a UriVideoSource. The video then begins loading and starts playing once a sufficient quantity of data has been downloaded and buffered. On each platform, the transport controls fade out if they're not used but can be restored by tapping on the video.

Play a video resource

Video files that are embedded in the Resources\Raw folder of the app, with a MauiAsset build action, can be played by the Video control:

<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             xmlns:controls="clr-namespace:VideoDemos.Controls"
             x:Class="VideoDemos.Views.PlayVideoResourcePage"
             Unloaded="OnContentPageUnloaded"
             Title="Play video resource">
    <controls:Video x:Name="video"
                    Source="video.mp4" />
</ContentPage>

In this example, the VideoSourceConverter class converts the string that represents the filename of the video to a ResourceVideoSource. For each platform, the video begins playing almost immediately after the video source is set because the file is in the app package and doesn't need to be downloaded. On each platform, the transport controls fade out if they're not used but can be restored by tapping on the video.

Play a video file from the device's library

Video files that are stored on the device can be retrieved and then played by the Video control:

<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             xmlns:controls="clr-namespace:VideoDemos.Controls"
             x:Class="VideoDemos.Views.PlayLibraryVideoPage"
             Unloaded="OnContentPageUnloaded"
             Title="Play library video">
    <Grid RowDefinitions="*,Auto">
        <controls:Video x:Name="video" />
        <Button Grid.Row="1"
                Text="Show Video Library"
                Margin="10"
                HorizontalOptions="Center"
                Clicked="OnShowVideoLibraryClicked" />
    </Grid>
</ContentPage>

When the Button is tapped its Clicked event handler is executed, which is shown in the following code example:

async void OnShowVideoLibraryClicked(object sender, EventArgs e)
{
    Button button = sender as Button;
    button.IsEnabled = false;

    var pickedVideo = await MediaPicker.PickVideoAsync();
    if (!string.IsNullOrWhiteSpace(pickedVideo?.FileName))
    {
        video.Source = new FileVideoSource
        {
            File = pickedVideo.FullPath
        };
    }

    button.IsEnabled = true;
}

The Clicked event handler uses .NET MAUI's MediaPicker class to let the user pick a video file from the device. The picked video file is then encapsulated as a FileVideoSource object and set as the Source property of the Video control. For more information about the MediaPicker class, see Media picker. For each platform, the video begins playing almost immediately after the video source is set because the file is on the device and doesn't need to be downloaded. On each platform, the transport controls fade out if they're not used but can be restored by tapping on the video.

Configure the Video control

You can prevent a video from automatically starting by setting the AutoPlay property to false:

<controls:Video x:Name="video"
                Source="https://archive.org/download/BigBuckBunny_328/BigBuckBunny_512kb.mp4"
                AutoPlay="False" />

You can suppress the transport controls by setting the AreTransportControlsEnabled property to false:

<controls:Video x:Name="video"
                Source="https://archive.org/download/BigBuckBunny_328/BigBuckBunny_512kb.mp4"
                AreTransportControlsEnabled="False" />

If you set AutoPlay and AreTransportControlsEnabled to false, the video won't begin playing and there will be no way to start it playing. In this scenario you'd need to call the Play method from the code-behind file, or create your own transport controls.

In addition, you can set a video to loop by setting the IsLooping property to true:

<controls:Video x:Name="video"
                Source="https://archive.org/download/BigBuckBunny_328/BigBuckBunny_512kb.mp4"
                IsLooping="true" />

If you set the IsLooping property to true this ensures that the Video control automatically sets the video position to the start after reaching its end.

Use custom transport controls

The following XAML example shows custom transport controls that play, pause, and stop the video:

<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             xmlns:controls="clr-namespace:VideoDemos.Controls"
             x:Class="VideoDemos.Views.CustomTransportPage"
             Unloaded="OnContentPageUnloaded"
             Title="Custom transport controls">
    <Grid RowDefinitions="*,Auto">
        <controls:Video x:Name="video"
                        AutoPlay="False"
                        AreTransportControlsEnabled="False"
                        Source="https://archive.org/download/BigBuckBunny_328/BigBuckBunny_512kb.mp4" />
        <ActivityIndicator Color="Gray"
                           IsVisible="False">
            <ActivityIndicator.Triggers>
                <DataTrigger TargetType="ActivityIndicator"
                             Binding="{Binding Source={x:Reference video},
                                               Path=Status}"
                             Value="{x:Static controls:VideoStatus.NotReady}">
                    <Setter Property="IsVisible"
                            Value="True" />
                    <Setter Property="IsRunning"
                            Value="True" />
                </DataTrigger>
            </ActivityIndicator.Triggers>
        </ActivityIndicator>
        <Grid Grid.Row="1"
              Margin="0,10"
              ColumnDefinitions="0.5*,0.5*"
              BindingContext="{x:Reference video}">
            <Button Text="&#x25B6;&#xFE0F; Play"
                    HorizontalOptions="Center"
                    Clicked="OnPlayPauseButtonClicked">
                <Button.Triggers>
                    <DataTrigger TargetType="Button"
                                 Binding="{Binding Status}"
                                 Value="{x:Static controls:VideoStatus.Playing}">
                        <Setter Property="Text"
                                Value="&#x23F8; Pause" />
                    </DataTrigger>
                    <DataTrigger TargetType="Button"
                                 Binding="{Binding Status}"
                                 Value="{x:Static controls:VideoStatus.NotReady}">
                        <Setter Property="IsEnabled"
                                Value="False" />
                    </DataTrigger>
                </Button.Triggers>
            </Button>
            <Button Grid.Column="1"
                    Text="&#x23F9; Stop"
                    HorizontalOptions="Center"
                    Clicked="OnStopButtonClicked">
                <Button.Triggers>
                    <DataTrigger TargetType="Button"
                                 Binding="{Binding Status}"
                                 Value="{x:Static controls:VideoStatus.NotReady}">
                        <Setter Property="IsEnabled"
                                Value="False" />
                    </DataTrigger>
                </Button.Triggers>
            </Button>
        </Grid>
    </Grid>
</ContentPage>

In this example, the Video control sets the AreTransportControlsEnabled property to false and defines a Button that plays and pauses the video, and a Button that stop video playback. Button appearance is defined using unicode characters and their text equivalents, to create buttons that consist of an icon and text:

Screenshot of play and pause buttons.

When the video is playing, the play button is updated to a pause button:

Screenshot of pause and stop buttons.

The UI also includes an ActivityIndicator that's displayed while the video is loading. Data triggers are used to enable and disable the ActivityIndicator and the buttons, and to switch the first button between play and pause. For more information about data triggers, see Data triggers.

The code-behind file defines the event handlers for the button Clicked events:

public partial class CustomTransportPage : ContentPage
{
    ...
    void OnPlayPauseButtonClicked(object sender, EventArgs args)
    {
        if (video.Status == VideoStatus.Playing)
        {
            video.Pause();
        }
        else if (video.Status == VideoStatus.Paused)
        {
            video.Play();
        }
    }

    void OnStopButtonClicked(object sender, EventArgs args)
    {
        video.Stop();
    }
    ...
}

Custom positioning bar

The following example shows a custom positioning bar, PositionSlider, being consumed in XAML:

<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             xmlns:controls="clr-namespace:VideoDemos.Controls"
             x:Class="VideoDemos.Views.CustomPositionBarPage"
             Unloaded="OnContentPageUnloaded"
             Title="Custom position bar">
    <Grid RowDefinitions="*,Auto,Auto">
        <controls:Video x:Name="video"
                        AreTransportControlsEnabled="False"
                        Source="{StaticResource ElephantsDream}" />
        ...
        <Grid Grid.Row="1"
              Margin="10,0"
              ColumnDefinitions="0.25*,0.25*,0.25*,0.25*"
              BindingContext="{x:Reference video}">
            <Label Text="{Binding Path=Position,
                                  StringFormat='{0:hh\\:mm\\:ss}'}"
                   HorizontalOptions="Center"
                   VerticalOptions="Center" />
            ...
            <Label Grid.Column="3"
                   Text="{Binding Path=TimeToEnd,
                                  StringFormat='{0:hh\\:mm\\:ss}'}"
                   HorizontalOptions="Center"
                   VerticalOptions="Center" />
        </Grid>
        <controls:PositionSlider Grid.Row="2"
                                 Margin="10,0,10,10"
                                 BindingContext="{x:Reference video}"
                                 Duration="{Binding Duration}"
                                 Position="{Binding Position}">
            <controls:PositionSlider.Triggers>
                <DataTrigger TargetType="controls:PositionSlider"
                             Binding="{Binding Status}"
                             Value="{x:Static controls:VideoStatus.NotReady}">
                    <Setter Property="IsEnabled"
                            Value="False" />
                </DataTrigger>
            </controls:PositionSlider.Triggers>
        </controls:PositionSlider>
    </Grid>
</ContentPage>

The Position property of the Video object is bound to the Position property of the PositionSlider, without performance issues, because the Video.Position property is changed by the MauiVideoPlayer.UpdateStatus method on each platform, which is only called 10 times a second. In addition, two Label objects display the Position and TimeToEnd properties values from the Video object.

Native view cleanup

Each platform's handler implementation overrides the DisconnectHandler implementation, which is used to perform native view cleanup such as unsubscribing from events and disposing objects. However, this override is intentionally not invoked by .NET MAUI. Instead, you must invoke it yourself from a suitable location in your app's lifecycle. This will often be when the page containing the Video control is navigated away from, which causes the page's Unloaded event to be raised.

An event handler for the page's Unloaded event can be registered in XAML:

<ContentPage ...
             xmlns:controls="clr-namespace:VideoDemos.Controls"
             Unloaded="OnContentPageUnloaded">
    <controls:Video x:Name="video"
                    ... />
</ContentPage>

The event handler for the Unloaded event can then invoke the DisconnectHandler method on its Handler instance:

void OnContentPageUnloaded(object sender, EventArgs e)
{
    video.Handler?.DisconnectHandler();
}

In addition to cleaning up native view resources, invoking the handler's DisconnectHandler method also ensures that videos stop playing on backwards navigation on iOS.