使用处理程序创建自定义控件

Browse sample. 浏览示例

应用的标准要求是能够播放视频。 本文介绍如何创建 .NET Multi-platform (.NET MAUI) 跨平台 Video 控件,该控件使用处理程序将跨平台控件 API 映射到播放视频的 Android、iOS 和 Mac Catalyst 上的本机视图。 此控件可以从三个源播放视频:

  • URL,表示远程视频。
  • 资源,是嵌入在应用中的文件。
  • 文件,来自设备的视频库。

视频控件需要传输控件(用于播放和暂停视频的按钮)和定位条(用于显示视频进度并允许用户快速跳转到不其他位置)。 Video 控件可使用平台提供的传输控件和定位条,也可使用你提供的自定义传输控件和定位条。 以下屏幕截图显示了 iOS 上的控件(包含和不含自定义传输控件):

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

更复杂的视频播放器还具备其他功能,例如音量控制,接到来电时中断视频播放的机制,以及在播放期间保持屏幕活动的方式。

下图显示了 Video 控件的体系结构。

Video handler architecture.

Video 类为控件提供跨平台 API。 跨平台 API 到本机视图 API 的映射由每个平台上的 VideoHandler 类执行,这会将 Video 类映射到 MauiVideoPlayer 类。 在 iOS 和 Mac Catalyst 上, MauiVideoPlayer 类使用 AVPlayer 类型提供视频播放。 在 Android 上,MauiVideoPlayer 类使用 VideoView 类型提供视频播放。 在 Windows 上,MauiVideoPlayer 类使用 MediaPlayerElement 类型提供视频播放。

重要

.NET MAUI 通过接口将其处理程序与跨平台控件分离。 这使得 Comet 和 Fabulous 等实验性框架能够提供自己的跨平台控件,这些控件可实现接口,同时仍使用 .NET MAUI 的处理程序。 仅当需要将处理程序与其跨平台控件分离以用于类似目的或出于测试目的,才需要为跨平台控件创建接口。

创建跨平台 .NET MAUI 自定义控件的过程,其平台实现由处理程序提供,如下所示:

  1. 为跨平台控件创建一个类,这会提供控件的公共 API。 有关详细信息,请参阅创建跨平台控件
  2. 创建任何必需的其他跨平台类型。
  3. 创建 partial 处理程序类。 有关详细信息,请参阅创建处理程序
  4. 在处理程序类中,创建 PropertyMapper 字典,用于定义在发生跨平台属性更改时要执行的操作。 有关详细信息,请参阅创建属性映射器
  5. (可选)在处理程序类中创建 CommandMapper 字典,用于定义跨平台控件向实现跨平台控件的本机视图发送指令时要执行的操作。 有关详细信息,请参阅创建命令映射器
  6. 为每个平台创建 partial 处理程序类,用于创建实现跨平台控件的本机视图。 有关详细信息,请参阅创建平台控件
  7. 在应用的 MauiProgram 类中使用 ConfigureMauiHandlersAddHandler 方法注册处理程序。 有关详细信息,请参阅注册处理程序

然后,可以使用跨平台控件。 有关详细信息,请参阅使用跨平台控件

创建跨平台控件

要创建跨平台控件,应创建派生自 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); }
        }        
        ...
    }
}

该控件应提供一个公共 API,供其处理程序和控件使用者访问。 跨平台控件应派生自 View,以表示用于在屏幕上放置布局和视图的视觉对象元素。

创建处理程序

创建跨平台控件后,应为处理程序创建 partial 类:

#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
    {
    }
}

处理程序类是一个分部类,其实现将在每个平台上使用附加分部类完成。

条件性 using 语句在每个平台上定义 PlatformView 类型。 在 Android、iOS、Mac Catalyst 和 Windows 上,本机视图由自定义 MauiVideoPlayer 类提供。 最终条件性 using 语句定义 PlatformView 等于 System.Object。 必须执行此操作,以便可以在处理程序内使用 PlatformView 类型,从而在所有平台上使用。 另一种方法是必须使用条件编译为每个平台定义一次 PlatformView 属性。

创建属性映射器

每个处理程序通常提供一个属性映射器,用于定义在跨平台控件中发生属性更改时要执行的操作。 PropertyMapper 类型是 Dictionary,用于将跨平台控件的属性映射到其关联的操作。

PropertyMapper 在 .NET MAUI 的泛型 ViewHandler 类中定义,并需要提供两个泛型参数:

  • 派生自 View 的跨平台控件的类。
  • 处理程序的类。

以下代码示例显示使用 PropertyMapper 定义扩展的 VideoHandler 类:

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)
    {
    }
}

PropertyMapperDictionary,其键为 string,值为泛型 Actionstring 表示跨平台控件的属性名称,Action 表示需要处理程序和跨平台控件作为参数的 static 方法。 例如,MapSource 方法的签名为 public static void MapSource(VideoHandler handler, Video video)

每个平台处理程序都必须提供操作的实现,用于操作本机视图 API。 这可确保在跨平台控件上设置属性时,基础本机视图将根据需要进行更新。 此方法的优势在于,它允许轻松自定义跨平台控件,因为跨平台控件使用者无需子类化即可修改属性映射器。

创建命令映射器

每个处理程序还可以提供命令映射器,用于定义跨平台控件向本机视图发送命令时要执行的操作。 命令映射器类似于属性映射器,但允许传递其他数据。 在此上下文中,命令是发送到本机视图的指令及其数据(可选)。 CommandMapper 类型是 Dictionary,用于将跨平台控件成员映射到其关联的操作。

CommandMapper 在 .NET MAUI 的泛型 ViewHandler 类中定义,并需要提供两个泛型参数:

  • 派生自 View 的跨平台控件的类。
  • 处理程序的类。

以下代码示例显示使用 CommandMapper 定义扩展的 VideoHandler 类:

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)
    {
    }
}

CommandMapperDictionary,其键为 string,值为泛型 Actionstring 表示跨平台控件的命令名称,Action 表示需要处理程序、跨平台控件和可选数据作为参数的 static 方法。 例如,MapPlayRequested 方法的签名为 public static void MapPlayRequested(VideoHandler handler, Video video, object? args)

每个平台处理程序都必须提供操作的实现,用于操作本机视图 API。 这可确保从跨平台控件发送命令时,将根据需要对基础本机视图进行操作。 此方法的优势在于,它不需要本机视图订阅和取消订阅跨平台控制事件。 此外,它允许轻松进行自定义,因为跨平台控件使用者无需子类化即可修改命令映射器。

创建平台控件

为处理程序创建映射器后,必须在所有平台上提供处理程序实现。 可以通过在 Platforms 文件夹的子文件夹中添加分部类处理程序实现来达成此目的。 或者,可以将项目配置为支持基于文件名的多目标和/或基于文件夹的多目标。

示例应用配置为支持基于文件名的多目标,以便处理程序类全部位于单个文件夹中:

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

包含映射器的 VideoHandler 类名为 VideoHandler.cs。 其平台实现位于 VideoHandler.Android.cs、VideoHandler.MaciOS.cs 和 VideoHandler.Windows.cs 文件中。 通过将以下 XML 添加到项目文件作为 <Project> 节点的子级来配置此基于文件名的多目标:

<!-- 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>

有关配置多目标的详细信息,请参阅配置多目标

每个平台处理程序类都应是分部类,并派生自泛型 ViewHandler 类,这需要两个类型参数:

  • 派生自 View 的跨平台控件的类。
  • 在平台上实现跨平台控件的本机视图的类型。 这应与处理程序中 PlatformView 属性的类型相同。

重要

ViewHandler 类提供 VirtualViewPlatformView 属性。 使用 VirtualView 属性从其处理程序中访问跨平台控件。 使用 PlatformView 属性访问每个平台上实现跨平台控件的本机视图。

每个平台处理程序实现都应重写以下方法:

  • CreatePlatformView,应创建并返回实现跨平台控件的本机视图。
  • ConnectHandler,用于执行任何本机视图设置,例如初始化本机视图和执行事件订阅。
  • DisconnectHandler,用于执行任何本机视图清理,例如取消订阅事件和释放对象。

重要

.NET MAUI 有意不调用 DisconnectHandler 方法。 相反,你必须从应用的生命周期中的合适位置自行调用。 有关详细信息,请参阅本机视图清理

每个平台处理程序还应实现映射器字典中定义的操作。

此外,每个平台处理程序还应根据需要提供代码,以在平台上实现跨平台控件的功能。 或者,这可以通过其他类型提供,此处采用的正是此方法。

Android

视频在 Android 上通过 VideoView 播放。 但在这里,VideoView 已封装在 MauiVideoPlayer 类型中,以保持本机视图与其处理程序分离。 以下示例显示了 Android 的 VideoHandler 分部类及其三个重写函数:

#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 派生自 ViewHandler 类,其中泛型 Video 参数指定跨平台控件类型,而 MauiVideoPlayer 参数指定封装 VideoView 本机视图的类型。

CreatePlatformView 重写函数将创建并返回一个 MauiVideoPlayer 对象。 ConnectHandler 重写函数是执行任何所需本机视图设置的位置。 DisconnectHandler 重写函数是执行任何本机视图清理的位置,因此在 Dispose 实例上调用 MauiVideoPlayer 方法。

平台处理程序还必须实现属性映射器字典中定义的操作:

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();
    }
    ...
}

每个操作都是为响应跨平台控件上的属性更改而执行的,且是需要处理程序和跨平台控件实例作为参数的 static 方法。 在每种情况下,该操作都会调用 MauiVideoPlayer 类型中定义的方法。

平台处理程序还必须实现命令映射器字典中定义的操作:

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);
    }
    ...
}

每个操作都是为响应从跨平台控件发送的命令而执行的,且是需要处理程序和跨平台控件实例以及可选数据作为参数的 static 方法。 在每种情况下,该操作都在提取可选数据后调用 MauiVideoPlayer 类中定义的方法。

在 Android 上,MauiVideoPlayer 类封装了 VideoView,以保持本机视图与其处理程序分离:

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 派生自 CoordinatorLayout,因为 Android 上 .NET MAUI 应用中的根本机视图为 CoordinatorLayout。 虽然 MauiVideoPlayer 类可能派生自其他本机 Android 类型,但在某些情况下,很难控制本机视图的定位。

可以直接将 VideoView 添加到 CoordinatorLayout,并根据需要调整其在布局中的位置。 但在这里,Android RelativeLayout 已添加到 CoordinatorLayout,且 VideoView 已添加到 RelativeLayout。 同时在 RelativeLayoutVideoView 上设置布局参数,以便 VideoView 位于页面中心,并可展开以填充可用空间,同时保持其纵横比不变。

构造函数还会订阅 VideoView.Prepared 事件。 准备好播放视频时将引发此事件,且此事件会在 Dispose 重写函数中取消订阅:

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);
    }
    ...
}

除了从 Prepared 事件取消订阅之外,Dispose 重写函数还会执行本机视图清理。

注意

Dispose 重写函数由处理程序的 DisconnectHandler 重写函数调用。

平台传输控件包括用于播放、暂停和停止视频的按钮,且这些按钮由 Android 的 MediaController 类型提供。 如果 Video.AreTransportControlsEnabled 属性设置为 true,则 MediaController 设置为 VideoView 的 Media Player。 之所以发生这种情况,是因为在设置 AreTransportControlsEnabled 属性时,处理程序的属性映射器会确保调用 MapAreTransportControlsEnabled 方法,进而调用 MauiVideoPlayer 中的 UpdateTransportControlsEnabled 方法:

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;
            }
        }
    }
    ...
}

如果不使用传输控件,它们会淡出,但可以通过点击视频来还原。

如果 Video.AreTransportControlsEnabled 属性设置为 false,则会将 MediaController 作为 VideoView 的 Media Player 移除。 在此方案中,可以通过编程方式控制视频播放或提供自己的传输控件。 有关详细信息,请参阅创建自定义传输控件

iOS 和 Mac Catalyst

视频在 iOS 和 Mac Catalyst 上使用 AVPlayerAVPlayerViewController 进行播放。 但在这里,这些类型封装于 MauiVideoPlayer 类型中,以保持本机视图与其处理程序分离。 以下示例显示了 iOS 的 VideoHandler 分部类及其三个重写函数:

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 派生自 ViewHandler 类,其中泛型 Video 参数指定跨平台控件类型,MauiVideoPlayer 参数指定封装 AVPlayerAVPlayerViewController 本机视图的类型。

CreatePlatformView 重写函数将创建并返回一个 MauiVideoPlayer 对象。 ConnectHandler 重写函数是执行任何所需本机视图设置的位置。 DisconnectHandler 重写函数是执行任何本机视图清理的位置,因此在 Dispose 实例上调用 MauiVideoPlayer 方法。

平台处理程序还必须实现属性映射器字典中定义的操作:

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();
    }
    ...
}

每个操作都是为了响应跨平台控件上更改的属性而执行的,并且是需要 static 处理程序和跨平台控件实例作为参数的方法。 在每种情况下,操作都会调用 MauiVideoPlayer 类型中定义的方法。

平台处理程序还必须实现命令映射器字典中定义的操作:

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);
    }
    ...
}

每个操作是为响应从跨平台控件发送的命令而执行,并且是需要 static 处理程序和跨平台控件实例以及可选数据作为参数的方法。 在每种情况下,操作都会调用在提取可选数据后在 MauiVideoPlayer 类中定义的方法。

在 iOS 和 Mac Catalyst 上, MauiVideoPlayer 类封装 AVPlayerAVPlayerViewController 类型,以保持本机视图与其处理程序分开:

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 派生自 UIView,这是 iOS 和 Mac Catalyst 上的基类,用于显示内容并处理与该内容的用户交互的对象。 构造函数创建 AVPlayer 对象,以管理媒体文件的播放和计时,并将其设置为 AVPlayerViewControllerPlayer 属性值。 AVPlayerViewController 显示来自 AVPlayer 的内容,并显示传输控件和其他功能。 然后设置控件的大小和位置,这可确保视频居中位于页面,并展开以填充可用空间,同时保持其纵横比。 在 iOS 16 和 Mac Catalyst 16 上, AVPlayerViewController 必须添加到基于 Shell 的应用的父级 ViewController,否则不会显示传输控件。 然后本机视图(来自 AVPlayerViewController 的视图)会添加到页面。

Dispose 方法负责执行本机视图清理:

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);
    }
    ...
}

在某些情况下,视频在视频播放页面导航离开后继续播放。 要停止视频,在 Dispose 重写函数中将 ReplaceCurrentItemWithPlayerItem 设置为 null,并执行其他本机视图清理。

注意

Dispose 重写函数由处理程序的 DisconnectHandler 重写函数调用。

平台传输控件包括用于播放、暂停和停止视频的按钮,并由 AVPlayerViewController 类型提供。 如果 Video.AreTransportControlsEnabled 属性设置为 true,则 AVPlayerViewController 将显示其播放控件。 之所以发生这种情况,是因为在设置 AreTransportControlsEnabled 属性时,处理程序的属性映射器会确保调用 MapAreTransportControlsEnabled 方法,进而调用 MauiVideoPlayer 中的 UpdateTransportControlsEnabled 方法:

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

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

如果不使用传输控件,它们会淡出,但可以通过点击视频来还原。

如果 Video.AreTransportControlsEnabled 属性设置为 false,则 AVPlayerViewController 不显示其播放控件。 在此方案中,可以通过编程方式控制视频播放或提供自己的传输控件。 有关详细信息,请参阅创建自定义传输控件

Windows

视频在 Windows 上使用 MediaPlayerElement 播放。 但是,此处 MediaPlayerElement 已封装在 MauiVideoPlayer 类型中,以保持本机视图与其处理程序分离。 以下示例显示了 Windows 的 VideoHandler 分部类及其三个重写函数:

#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 派生自 ViewHandler 类,其中通用 Video 参数指定跨平台控件类型,MauiVideoPlayer 参数指定封装 MediaPlayerElement 本机视图的类型。

CreatePlatformView 重写函数将创建并返回一个 MauiVideoPlayer 对象。 ConnectHandler 重写函数是执行任何所需本机视图设置的位置。 DisconnectHandler 重写函数是执行任何本机视图清理的位置,因此在 Dispose 实例上调用 MauiVideoPlayer 方法。

平台处理程序还必须实现属性映射器字典中定义的操作:

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();
    }
    ...
}

每个操作都是为了响应跨平台控件上更改的属性而执行的,并且是需要 static 处理程序和跨平台控件实例作为参数的方法。 在每种情况下,操作都会调用 MauiVideoPlayer 类型中定义的方法。

平台处理程序还必须实现命令映射器字典中定义的操作:

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);
    }
    ...
}

每个操作是为响应从跨平台控件发送的命令而执行,并且是需要 static 处理程序和跨平台控件实例以及可选数据作为参数的方法。 在每种情况下,操作都会调用在提取可选数据后在 MauiVideoPlayer 类中定义的方法。

在 Windows 上,MauiVideoPlayer 类封装 MediaPlayerElement 以保持本机视图与其处理程序分离:

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 派生自 GridMediaPlayerElement 添加为 Grid 的子项。 这可让 MediaPlayerElement 自动调整大小以填充所有可用空间。

Dispose 方法负责执行本机视图清理:

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;
    }
    ...
}

除了取消订阅 MediaOpened 事件之外,Dispose 重写函数还会执行本机视图清理。

注意

Dispose 重写函数由处理程序的 DisconnectHandler 重写函数调用。

平台传输控件包括用于播放、暂停和停止视频的按钮,并由 MediaPlayerElement 类型提供。 如果 Video.AreTransportControlsEnabled 属性设置为 true,则 MediaPlayerElement 将显示其播放控件。 之所以发生这种情况,是因为在设置 AreTransportControlsEnabled 属性时,处理程序的属性映射器会确保调用 MapAreTransportControlsEnabled 方法,进而调用 MauiVideoPlayer 中的 UpdateTransportControlsEnabled 方法:

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

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

}

如果 Video.AreTransportControlsEnabled 属性设置为 false,则 MediaPlayerElement 不显示其播放控件。 在此方案中,可以通过编程方式控制视频播放或提供自己的传输控件。 有关详细信息,请参阅创建自定义传输控件

将跨平台控件转换为平台控件

派生自 Element 的任何 .NET MAUI 跨平台控件都可以使用 ToPlatform 扩展方法转换为其基础平台控件:

  • 在 Android 上,ToPlatform 将 .NET MAUI 控件转换为 Android View 对象。
  • 在 iOS 和 Mac Catalyst 上,ToPlatform 将 .NET MAUI 控件转换为 UIView 对象。
  • 在 Windows 上,ToPlatform 将 .NET MAUI 控件转换为 FrameworkElement 对象。

注意

ToPlatform 方法位于 Microsoft.Maui.Platform 命名空间中。

在所有平台上,ToPlatform 方法都需要一个 MauiContext 参数。

ToPlatform 方法可以将跨平台控件从平台代码转换为其基础平台控件,例如在平台的分部处理程序类中:

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);
            ...
        }
        ...
    }
}

在此示例中,在 Android 的 VideoHandler 分部类中,MapSource 方法将 Video 实例转换为 MauiVideoPlayer 对象。

ToPlatform 方法还可以从跨平台代码将跨平台控件转换为其基础平台控件:

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
        ...
    }
    ...
}

在此示例中,名为 video 的跨平台 Video 控件转换为 OnHandlerChanged() 替代中每个平台上的基础本机视图。 当实现跨平台控件的本机视图可用并初始化时,将调用此重写函数。 ToPlatform 方法返回的对象可以转换为其确切的本机类型,此处为 MauiVideoPlayer

播放视频

Video 类定义用于指定视频文件源的 Source 属性,以及 AutoPlay 属性。 AutoPlay 默认为 true,这意味着视频应该在设置 Source 后自动开始播放。 有关这些属性的定义,请参阅创建跨平台控件

类型 VideoSourceSource 属性是由三个静态方法组成的抽象类,这些方法实例化三个派生自 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 };
        }
    }
}

VideoSource 类包含引用 VideoSourceConverterTypeConverter 属性:

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.");
        }
    }
}

在 XAML 中将 Source 属性设置为字符串时,将调用此类型转换器。 ConvertFromInvariantString 方法尝试将字符串转换为 Uri 对象。 如果成功,并且方案不为 file,则该方法返回 UriVideoSource。 否则,将返回 ResourceVideoSource

播放 Web 视频

UriVideoSource 类用于指定包含 URI 的远程视频。 它定义类型 stringUri 属性:

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); }
        }
    }
}

Source 属性设置为 UriVideoSource 时,处理程序的属性映射器可确保调用 MapSource 方法:

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

MapSource 方法反过来调用处理程序的 PlatformView 属性上的 UpdateSource 方法。 类型为 MauiVideoPlayerPlatformView 属性表示在每个平台上提供视频播放器实现的本机视图。

Android

视频在 Android 上通过 VideoView 播放。 下面的代码示例演示 UpdateSource 方法在类型为 UriVideoSource 时如何处理 Source 属性:

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();
            }
        }
        ...
    }
}

处理类型 UriVideoSource 的对象时,VideoViewSetVideoUri 方法用于指定要播放的视频,并使用从字符串 URI 创建的 Android Uri 对象。

AutoPlay 属性在 VideoView 上没有等效项,因此,如果设置了新视频,则调用 Start 方法。

iOS 和 Mac Catalyst

要在 iOS 和 Mac Catalyst 上播放视频,需要创建类型为 AVAsset 的对象来封装视频,并使用该对象创建 AVPlayerItem,然后将其移交给 AVPlayer 对象。 下面的代码示例演示 UpdateSource 方法在类型为 UriVideoSource 时如何处理 Source 属性:

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();
            }
        }
        ...
    }
}

处理类型为 UriVideoSource 的对象时,静态 AVAsset.FromUrl 方法用于指定要播放的视频,并使用从字符串 URI 创建的 iOS NSUrl 对象。

AutoPlay 属性在 iOS 视频类中没有等效项,因此在 UpdateSource 方法的末尾检查该属性,以对 AVPlayer 对象调用 Play 方法。

在某些情况下,iOS 上的视频在离开视频播放页后会继续播放。 要停止视频,请在 Dispose 重写函数中将 ReplaceCurrentItemWithPlayerItem 设置为 null

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

Windows

视频在 Windows 上用 MediaPlayerElement 播放。 下面的代码示例演示 UpdateSource 方法在类型为 UriVideoSource 时如何处理 Source 属性:

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;
            }
        }
        ...
    }
}

处理类型为 UriVideoSource 的对象时,MediaPlayerElement.Source 属性设置为 MediaSource 对象,该对象使用要播放的视频的 URI 初始化 Uri。 设置后 MediaPlayerElement.Source ,将针对 MediaPlayerElement.MediaPlayer.MediaOpened 事件注册 OnMediaPlayerMediaOpened 事件处理程序方法。 此事件处理程序用于设置 Video 控件的 Duration 属性。

UpdateSource 方法的末尾,将检查 Video.AutoPlay 属性,如果为真,则设置 MediaPlayerElement.AutoPlay 属性为 true 以开始视频播放。

播放视频资源

ResourceVideoSource 类用于访问嵌入应用中的视频文件。 它定义类型为 stringPath 属性:

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); }
        }
    }
}

Source 属性设置为 ResourceVideoSource 时,处理程序的属性映射器将确保调用 MapSource 方法:

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

MapSource 方法反过来调用处理程序的 PlatformView 属性上的 UpdateSource 方法。 类型为 MauiVideoPlayerPlatformView 属性表示在每个平台上提供视频播放器实现的本机视图。

Android

以下代码示例演示 UpdateSource 方法如何处理当类型为 ResourceVideoSource 时的 Source 属性:

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;
                }
            }
            ...
        }
        ...
    }
}

处理类型为 ResourceVideoSource 的对象时,使用 VideoViewSetVideoPath 方法指定要播放的视频,其中包含将应用的包名称与视频的文件名组合在一起的字符串参数。

资源视频文件存储在包的 assets 文件夹中,它需要内容提供程序才能进行访问。 内容提供程序由 VideoProvider 类提供,该类创建提供视频文件访问权限的 AssetFileDescriptor 对象:

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 和 Mac Catalyst

以下代码示例演示 UpdateSource 方法如何处理当类型为 ResourceVideoSource 时的 Source 属性:

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);
                }
            }
            ...
        }
        ...
    }
}

处理类型为 ResourceVideoSource 的对象时,使用 NSBundleGetUrlForResource 方法从应用包中检索文件。 完整路径必须划分为文件名、扩展名和目录。

在某些情况下,iOS 上的视频在离开视频播放页后会继续播放。 要停止视频,请在 Dispose 重写函数中将 ReplaceCurrentItemWithPlayerItem 设置为 null

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

Windows

以下代码示例演示 UpdateSource 方法如何处理当类型为 ResourceVideoSource 时的 Source 属性:

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;
                }
            }
            ...
        }
        ...
    }
}

处理类型为 ResourceVideoSource 的对象时,将 MediaPlayerElement.Source 属性设置为 MediaSource 对象,该对象使用前缀为 ms-appx:/// 的视频资源的路径初始化 Uri

从设备库播放视频文件

FileVideoSource 类用于访问设备视频库中的视频。 它定义类型为 stringFile 属性:

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); }
        }
    }
}

Source 属性设置为 FileVideoSource 时,处理程序的属性映射器将确保调用 MapSource 方法:

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

MapSource 方法反过来调用处理程序的 PlatformView 属性上的 UpdateSource 方法。 类型为 MauiVideoPlayerPlatformView 属性表示在每个平台上提供视频播放器实现的本机视图。

Android

以下代码示例演示 UpdateSource 方法如何处理当类型为 FileVideoSource 时的 Source 属性:

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;
                }
            }
            ...
        }
        ...
    }
}

处理类型为 FileVideoSource 的对象时,使用 VideoViewSetVideoPath 方法指定要播放的视频文件。

iOS 和 Mac Catalyst

以下代码示例演示 UpdateSource 方法如何处理当类型为 FileVideoSource 时的 Source 属性:

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 }));
            }
            ...
        }
        ...
    }
}

处理类型为 FileVideoSource 的对象时,使用静态 AVAsset.FromUrl 方法指定要播放的视频文件,其中包含从字符串 URI 创建 iOS NSUrl 对象的 NSUrl.CreateFileUrl 方法。

Windows

以下代码示例演示 UpdateSource 方法如何处理当类型为 FileVideoSource 时的 Source 属性:

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;
                }
            }
            ...
        }
        ...
    }
}

处理类型为 FileVideoSource 的对象时,视频文件名将转换为 StorageFile 对象。 然后,MediaSource.CreateFromStorageFile 方法返回 MediaSource 对象,该对象设置为 MediaPlayerElement.Source 属性的值。

循环视频

Video 类定义 IsLooping 属性,该属性支持控件在视频播放结束后自动将其位置设置到开始位置。 它默认为 false,指示视频不会自动循环播放。

设置 IsLooping 属性后,处理程序的属性映射器将确保调用 MapIsLooping 方法:

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

MapIsLooping 方法反过来调用处理程序的 PlatformView 属性上的 UpdateIsLooping 方法。 类型为 MauiVideoPlayerPlatformView 属性表示在每个平台上提供视频播放器实现的本机视图。

Android

以下代码示例演示 UpdateIsLooping 方法在 Android 上如何启用视频循环播放:

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;
        }
        ...
    }
}

要启用视频循环播放,MauiVideoPlayer 类需实现 MediaPlayer.IOnPreparedListener 接口。 此接口定义在媒体源准备好播放时调用的 OnPrepared 回调。 当 Video.IsLooping 属性为 true 时,UpdateIsLooping 方法将 MauiVideoPlayer 设置为提供 OnPrepared 回调的对象。 回调将 MediaPlayer.IsLooping 属性设置为 Video.IsLooping 属性的值。

iOS 和 Mac Catalyst

以下代码示例演示 UpdateIsLooping 方法在 iOS 和 Mac Catalyst 上如何启用视频循环播放:

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);
        }
        ...
    }
}

在 iOS 和 Mac Catalyst 上,当视频播放到末尾时使用通知来执行回调。 当 Video.IsLooping 属性为 true 时,UpdateIsLooping 方法将为 AVPlayerItem.DidPlayToEndTimeNotification 通知添加观察程序,并在收到通知时执行 PlayedToEnd 方法。 反过来,此方法将从视频的开头恢复播放。 如果 Video.IsLooping 属性为 false,则视频将在播放结束时暂停。

由于 MauiVideoPlayer 为通知添加了观察程序,因此在执行本机视图清理时,还必须删除观察程序。 这是在 Dispose 重写函数中完成的:

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;
    }
    ...
}

Dispose 重写函数调用 DestroyPlayedToEndObserver 方法,该方法删除 AVPlayerItem.DidPlayToEndTimeNotification 通知的观察程序,并在 NSObject 上调用 Dispose 方法。

Windows

以下代码示例演示 UpdateIsLooping 方法在 Windows 上如何启用视频循环:

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

要启用视频循环,UpdateIsLooping 方法将 MediaPlayerElement.MediaPlayer.IsLoopingEnabled 属性设置为 Video.IsLooping 属性的值。

创建自定义传输控件

视频播放器的传输控件包括播放、暂停和停止视频的按钮。 这些按钮通常使用熟悉的图标而不是文本进行标识,并且播放和暂停按钮通常合并为一个按钮。

默认情况下,Video 控件显示每个平台支持的传输控件。 但是,将 AreTransportControlsEnabled 属性设置为 false 时,将禁止使用这些控件。 然后,可以通过编程方式控制视频播放,或提供自己的传输控件。

实现自己的传输控件需要 Video 类能够通知其本机视图播放、暂停或停止视频,并知道视频播放的当前状态。 Video 类定义名为 PlayPauseStop 的方法,这些方法引发相应的事件,并向 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);
        }
    }
}

VideoPositionEventArgs 类定义可以通过其构造函数进行设置的 Position 属性。 此属性表示视频播放开始、暂停或停止的位置。

PlayPauseStop 方法中的最后一行向 VideoHandler 发送命令和关联数据。 VideoHandlerCommandMapper 将命令名称映射到接收命令时执行的操作。 例如,当 VideoHandler 收到 PlayRequested 命令时,它将执行其 MapPlayRequested 方法。 此方法的优势在于,它不需要本机视图订阅和取消订阅跨平台控制事件。 此外,它允许轻松进行自定义,因为跨平台控件使用者无需子类化即可修改命令映射器。 有关 CommandMapper 的详细信息,请参阅创建命令映射器

Android、iOS 和 Mac Catalyst 上的 MauiVideoPlayer 实现包含 Video 控件在发送 PlayRequestedPauseRequestedStopRequested 命令时执行的 PlayRequestedPauseRequestedStopRequested 方法。 每个方法在其本机视图上调用方法来播放、暂停或停止视频。 例如,以下代码显示了 iOS 和 Mac Catalyst 上的 PlayRequestedPauseRequestedStopRequested 方法:

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}.");
        }
    }
}

这三种方法中的每一种都使用随命令发送的数据记录视频的播放、暂停或停止位置。

此机制可确保对 Video 控件调用 PlayPauseStop 方法时,指示其本机视图播放、暂停或停止视频,并记录播放、暂停或停止视频的位置。 所有这一切均采用分离方法,无需本机视图订阅跨平台事件。

视频状态

实现播放、暂停和停止功能不足以支持自定义传输控件。 通常,播放和暂停功能应使用同一按钮实现,该按钮会更改外观以指示视频当前是播放还是暂停。 此外,如果视频尚未加载,则不应启用该按钮。

这些要求意味着视频播放器需要提供一个当前状态,以指示它是否正在播放或暂停,或者是否还没有准备好播放视频。 此状态可由枚举表示:

public enum VideoStatus
{
    NotReady,
    Playing,
    Paused
}

Video 类定义名为 Status 且类型为 VideoStatus 的只读可绑定属性。 此属性定义为只读,因为它只能从控件的处理程序进行设置:

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); }
        }
        ...
    }
}

通常,只读可绑定属性在 Status 属性上具有私有的 set 访问器,以允许在类中设置它。 但是,对于处理程序支持的 View 派生,必须从类外部设置属性,且只能由控件的处理程序设置。

出于此原因,另一个属性被定义为 IVideoController.Status。 这是一个显式接口实现,由 Video 类实现的 IVideoController 接口使之成为可能:

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

此接口使 Video 外部的类可以通过引用 IVideoController 接口来设置 Status 属性。 该属性也可以从其他类和处理器中设置,但不太可能会被无意设置。 最重要的是,不能通过数据绑定设置 Status 属性。

为了协助处理程序实现更新 Status 属性,Video 类定义了 UpdateStatus 事件和命令:

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));
        }
        ...
    }
}

OnTimerTick 事件处理程序每十分之一秒执行一次,这将引发 UpdateStatus 事件并调用 UpdateStatus 命令。

UpdateStatus 命令从 Video 控件发送到其处理程序时,处理程序的命令映射器将确保调用 MapUpdateStatus 方法:

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

MapUpdateStatus 方法反过来调用处理程序的 PlatformView 属性的 UpdateStatus 方法。 类型为 MauiVideoPlayerPlatformView 属性封装每个平台上提供视频播放器实现的本机视图。

Android

以下代码示例展示了在 Android 上使用 UpdateStatus 方法设置 Status 属性:

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;
            ...
        }
        ...
    }
}

VideoView.IsPlaying 属性是一个布尔值,用于指示视频是播放还是暂停。 要确定 VideoView 是否无法播放或暂停视频,必须处理 Prepared 事件。 当媒体源准备好播放时,将引发此事件。 事件在 MauiVideoPlayer 构造函数中订阅,并在其 Dispose 重写函数中取消订阅。 然后,UpdateStatus 方法使用 isPrepared 字段和 VideoView.IsPlaying 属性,通过将 Status 属性强制转换为 IVideoController 来设置 Video 对象上的该属性。

iOS 和 Mac Catalyst

下面的代码示例展示了 iOS 和 Mac Catalyst 上的 UpdateStatus 方法如何设置 Status 属性:

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;
            ...
        }
        ...
    }
}

必须访问 AVPlayer 的两个属性来设置 Status 属性,即 AVPlayerStatus 类型的 Status 属性和 AVPlayerTimeControlStatus 类型的 TimeControlStatus 属性。 然后,可以通过将 Status 属性强制转换为 IVideoController 来设置 Video 对象上的该属性。

Windows

以下代码示例演示了 Windows 上的 UpdateStatus 方法如何设置 Status 属性:

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;
            }
        }
        ...
    }
}

UpdateStatus 方法使用 MediaPlayerElement.MediaPlayer.CurrentState 属性的值来确定 Status 属性的值。 然后,可以通过将 Status 属性强制转换为 IVideoController 来设置 Video 对象上的该属性。

定位工具栏

由每个平台实现的传输控件都有一个定位工具栏。 该工具栏类似于滑块或滚动条,用于显示视频进度。 此外,用户可以操纵该定位工具栏,将其前移或后移到视频中的新位置。

实现自己的定位工具栏需要 Video 类了解视频的持续时间及其在该持续时间内的当前位置。

持续时间

Video 控件支持自定义定位工具栏所需的一项信息是视频的持续时间。 Video 类定义了一个类型为 TimeSpan 且名为 Duration 的只读、可绑定属性。 此属性定义为只读,因为它只能从控件的处理程序进行设置:

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); }
        }
        ...
    }
}

通常,只读可绑定属性在 Duration 属性上具有私有的 set 访问器,以允许在类中设置它。 但是,对于处理程序支持的 View 派生,必须从类外部设置属性,且只能由控件的处理程序设置。

注意

Duration 可绑定属性的属性更改事件处理程序会调用名为 SetTimeToEnd 的方法,该方法在计算结束时间中进行了描述。

出于此原因,另一个属性被定义为 IVideoController.Duration。 这是一个显式接口实现,由 Video 类实现的 IVideoController 接口使之成为可能:

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

借助此界面,Video 外部的类可以通过引用 IVideoController 接口来设置 Duration 属性。 该属性也可以从其他类和处理器中设置,但不太可能会被无意设置。 最重要的是,不能通过数据绑定设置 Duration 属性。

设置 Video 控件的 Source 属性后,无法立即获取视频持续时间。 必须先下载部分视频文件,然后本机视图才能确定其持续时间。

Android

在 Android 上,VideoView.Duration 属性会报告引发 VideoView.Prepared 事件后的有效持续时间(以毫秒为单位)。 该 MauiVideoPlayer 类使用 Prepared 事件处理程序获取 Duration 属性值:

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 和 Mac Catalyst

在 iOS 和 Mac Catalyst 中,视频持续时间从 AVPlayerItem.Duration 属性获取,但不是在创建 AVPlayerItem 后立即获得。 可以为 Duration 属性设置 iOS 观察程序,但 MauiVideoPlayer 类通过每秒调用 10 次的 UpdateStatus 方法获取持续时间:

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);
                ...
            }
        }
        ...
    }
}

ConvertTime 方法将 CMTime 对象转换为 TimeSpan 值。

Windows

在 Windows 上,MediaPlayerElement.MediaPlayer.NaturalDuration 属性是引发 MediaPlayerElement.MediaPlayer.MediaOpened 事件后变为有效的 TimeSpan 值。 MauiVideoPlayer 类使用 MediaOpened 事件处理程序获取 NaturalDuration 属性值:

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;
            });
        }
        ...
    }
}

然后,OnMediaPlayer 事件处理程序在主线程上调用 MainThread.BeginInvokeOnMainThread 方法,通过将 Duration 属性强制转换为 IVideoControllerVideo 对象设置该属性。 这是必要操作,因为 MediaPlayerElement.MediaPlayer.MediaOpened 事件是在后台线程上处理的。 有关在主线程上运行代码的详细信息,请参阅在 .NET MAUI UI 线程上创建线程

位置

Video 控件还需要 Position 属性,该属性会在视频播放时从零增加到 DurationVideo 类通过公共 getset 访问器,将此属性作为可绑定属性来实现:

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); }
        }
        ...
    }
}

get 访问器会在视频播放时返回其当前位置。 set 访问器向前或向后移动视频位置,以便响应用户对定位工具栏的操作。

注意

可绑定属性的属性更改事件处理程序 Position 调用名为 SetTimeToEnd 的方法,该方法在计算结束时间中进行了描述。

在 Android、iOS 和 Mac Catalyst 上,获取当前位置的属性只有 get 访问器。 相反,Seek 方法可用于设置位置。 这似乎是一种更明智的方法,而不是使用具有固有问题的单个 Position 属性。 视频播放时,必须不断更新 Position 属性,以反映新位置。 但你不希望对 Position 属性执行太多更改,导致视频播放器移动到视频中的新位置。 如果发生这种情况,视频播放器将通过查找 Position 属性的最后值来响应,并且视频不会前移。

尽管使用 getset 访问器实现 Position 属性时遇到困难,但还是采用了这种方法,因为它可以利用数据绑定。 Video 控件的 Position 属性可与 Slider 绑定,后者既用于显示位置,也用于寻找新位置。 但是,实现该 Position 属性时,需要采取一些预防措施,以避免出现反馈循环。

Android

在 Android 上,VideoView.CurrentPosition 属性指示视频的当前位置。 MauiVideoPlayer 类在 UpdateStatus 方法中设置 Duration 属性的同时,还设置了 Position 属性:

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);
            }
        }
        ...
    }
}

每次 UpdateStatus 方法设置 Position 属性时,Position 属性都会触发 PropertyChanged 事件,这会导致处理程序的属性映射器调用 UpdatePosition 方法。 对于大多数属性更改,UpdatePosition 方法不应执行任何操作。 否则,视频位置每次发生更改时,它就会移动到刚刚到达的同一位置。 为避免这种反馈循环,UpdatePosition 只在 Position 属性与 VideoView 当前位置之差大于一秒时,才调用 VideoView 对象上的 Seek 方法。

iOS 和 Mac Catalyst

在 iOS 和 Mac Catalyst 上,AVPlayerItem.CurrentTime 属性指示视频的当前位置。 MauiVideoPlayer 类在 UpdateStatus 方法中设置 Duration 属性的同时,还设置了 Position 属性:

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));
            }
        }
        ...
    }
}

每次 UpdateStatus 方法设置 Position 属性时,Position 属性都会触发 PropertyChanged 事件,这会导致处理程序的属性映射器调用 UpdatePosition 方法。 对于大多数属性更改,UpdatePosition 方法不应执行任何操作。 否则,视频位置每次发生更改时,它就会移动到刚刚到达的同一位置。 为避免这种反馈循环,UpdatePosition 只在 Position 属性与 AVPlayer 当前位置的时间差大于一秒时,才调用 AVPlayer 对象上的 Seek 方法。

Windows

在 Windows 上,MediaPlayerElement.MedaPlayer.Position 属性指示视频的当前位置。 MauiVideoPlayer 类在 UpdateStatus 方法中设置 Duration 属性的同时,还设置了 Position 属性:

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;
                }
            }
        }
        ...
    }
}

每次 UpdateStatus 方法设置 Position 属性时,Position 属性都会触发 PropertyChanged 事件,这会导致处理程序的属性映射器调用 UpdatePosition 方法。 对于大多数属性更改,UpdatePosition 方法不应执行任何操作。 否则,视频位置每次发生更改时,它就会移动到刚刚到达的同一位置。 为避免这种反馈循环,UpdatePosition 只在 Position 属性与 MediaPlayerElement 的当前位置的时间差大于一秒时,才设置 MediaPlayerElement.MediaPlayer.Position 属性。

计算结束时间

有时视频播放器会显示视频的剩余时间。 视频开始时,该值从视频的持续时间开始,视频结束时减小到零。

Video 类包含一个只读 TimeToEnd 属性,该属性根据 DurationPosition 属性的变化进行计算:

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;
        }
        ...
    }
}

DurationPosition 属性的属性更改事件处理程序会调用 SetTimeToEnd 方法。

自定义定位工具栏

自定义定位工具栏可以通过创建派生自 Slider 的类来实现,该类包含类型为 TimeSpanDurationPosition 属性:

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;
                }
            };
        }
    }
}

Duration 属性的属性更改事件处理程序将 SliderMaximum 属性设置为 TimeSpan 值的 TotalSeconds 属性。 同样,Position 属性的属性更改事件处理程序会设置 SliderValue 属性。 这是 Slider 跟踪 PositionSlider 位置的机制。

只有在一种情况下,即用户操作 Slider,以指示视频应前进或倒退到新位置时,PositionSlider 才会从底层 Slider 更新。 这是在 PositionSlider 构造函数中的 PropertyChanged 处理程序中检测到的。 该事件处理程序检查 Value 属性中的更改,并且如果与 Position 属性不同,则会根据 Value 属性设置 Position 属性。

注册处理程序

自定义控件及其处理程序必须向应用注册,然后才能使用。 这应该发生在应用项目中 MauiProgram 类的 CreateMauiApp 方法中,该类是应用的跨平台入口点:

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();
    }
}

处理程序使用 ConfigureMauiHandlersAddHandler 方法注册。 AddHandler 方法的第一个参数是跨平台控件类型,第二个参数是其处理程序类型。

使用跨平台控件

向应用注册处理程序后,便可使用跨平台控件。

播放 Web 视频

Video 控件可从 URL 播放视频,如以下示例所示:

<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>

在此示例中,VideoSourceConverter 类将代表 URI 的字符串转换为 UriVideoSource。 然后,视频会开始加载,并在下载和缓冲足够数量的数据后开始播放。 在每个平台上,如果未使用传输控件,则传输控件会淡出,但可以通过点击视频将其恢复。

播放视频资源

Video 控件可以播放通过 MauiAsset 生成操作嵌入到应用的 Resources\Raw 文件夹中的视频文件:

<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>

在此示例中,VideoSourceConverter 类将代表视频文件名的字符串转换为 ResourceVideoSource。 对于每个平台,由于文件就在应用包上,不需要下载,因此视频几乎可在视频源设置完成后立即开始播放。 在每个平台上,如果未使用传输控件,则传输控件会淡出,但可以通过点击视频将其恢复。

从设备库播放视频文件

可以检索存储在设备上的视频文件,然后由 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.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>

点击 Button 时,其 Clicked 事件处理程序就会执行,如以下代码示例所示:

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;
}

Clicked 事件处理程序使用 .NET MAUI 的 MediaPicker 类让用户从设备中选择视频文件。 然后,选取的视频文件封装为 FileVideoSource 对象,并设置为 Video 控件的 Source 属性。 有关 MediaPicker 类的详细信息,请参阅媒体选取器。 对于每个平台,由于文件就在设备上,不需要下载,因此视频几乎可在视频源设置完成后立即开始播放。 在每个平台上,如果未使用传输控件,则传输控件会淡出,但可以通过点击视频将其恢复。

配置视频控件

可以通过将 AutoPlay 属性设置为 false 来阻止视频自动启动:

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

可以通过将 AreTransportControlsEnabled 属性设置为 false 来禁止显示传输控件:

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

如果将 AutoPlay 和 AreTransportControlsEnabled 设置为 false,则视频不会开始播放,且没有任何方法可以开始播放。 在这种情况下,需要从代码隐藏文件调用 Play 方法,或创建自己的传输控件。

此外,可以通过将 IsLooping 属性设置为 true: 来设置视频循环播放

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

如果将 IsLooping 属性设置为 true,这可确保 Video 控件在到达其末尾后自动将视频位置设置为起始位置。

使用自定义传输控件

以下 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.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>

在此示例中,Video 控件将 AreTransportControlsEnabled 属性设置为 false,并且定义用于播放和暂停视频的 Button 以及用于停止视频播放的 Button。 按钮外观是使用 Unicode 字符及其文本等效项定义的,用于创建由图标和文本组成的按钮:

Screenshot of play and pause buttons.

在视频播放时,“播放”按钮将更新为“暂停”按钮:

Screenshot of pause and stop buttons.

UI 还包括在视频加载时显示的 ActivityIndicator。 数据触发器用于启用和禁用 ActivityIndicator 及按钮,并在“播放”和“暂停”之间切换第一个按钮: 有关数据触发器的详细信息,请参阅数据触发器

代码隐藏文件定义按钮 Clicked 事件的事件处理程序:

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();
    }
    ...
}

自定义定位工具栏

以下示例演示在 XAML 中使用的自定义定位栏 PositionSlider

<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>

Video 对象的 Position 属性绑定到 PositionSliderPosition 属性,而不出现性能问题,因为 Video.Position 属性在每个平台上通过 MauiVideoPlayer.UpdateStatus 方法更改,每秒仅调用 10 次。 此外,两个 Label 对象显示来自 Video 对象的 Position 和 TimeToEnd 属性值。

本机视图清理

每个平台的处理程序实现都会重写 DisconnectHandler 实现,用于执行本机视图清理,例如取消订阅事件和处理对象。 但 .NET MAUI 有意不调用此重写函数。 相反,你必须从应用的生命周期中的合适位置自行调用。 这通常是包含 Video 控件的页面导航离开时,会导致引发页面的 Unloaded 事件。

可以在 XAML 中注册页面的 Unloaded 事件的事件处理程序:

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

然后,Unloaded 事件的事件处理程序可以在其 Handler 实例上调用 DisconnectHandler 方法:

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

除了清理本机视图资源之外,调用处理程序的 DisconnectHandler 方法还可确保视频停止在 iOS 上向后导航时播放。