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

浏览示例。 浏览示例

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

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

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

iOS 上视频播放的屏幕截图。在 iOS 上使用自定义传输控件播放视频的屏幕截图。

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

Video 控件的体系结构如下图所示:

视频处理程序体系结构。

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<TVirtualView,TPlatformView> 类中定义,需要提供两个泛型参数:

  • 派生自 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<TVirtualView,TPlatformView> 类中定义,需要提供两个泛型参数:

  • 派生自 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)
    {
    }
}

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

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

创建平台控件

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

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

项目的“处理程序”文件夹中的文件的屏幕截图。

包含映射器的 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<TVirtualView,TPlatformView> 该类,这需要两个类型参数:

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

重要说明

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

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

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

重要

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

重要

此方法 DisconnectHandler 默认由 .NET MAUI 自动调用,尽管此行为可以更改。 有关详细信息,请参阅 控制处理程序断开连接

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

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

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<TVirtualView,TPlatformView> 类,所带有的泛型 Video 参数指定跨平台控件类型,而 MauiVideoPlayer 参数指定封装 VideoView 本机视图的类型。

CreatePlatformView 替代会创建并返回 MauiVideoPlayer 对象。 ConnectHandler 替代是执行任何必需的本机视图设置的位置。 DisconnectHandler 替代是执行任何本机视图清理的位置,因此在 MauiVideoPlayer 实例上调用 Dispose 方法。

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

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 的媒体播放器。 之所以发生这种情况,是因为在设置 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,则将删除作为 VideoView 的媒体播放器的 MediaController。 在此场景中,可以通过编程方式控制视频播放或提供自己的传输控件。 有关详细信息,请参阅创建自定义传输控件

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

CreatePlatformView 替代会创建并返回 MauiVideoPlayer 对象。 ConnectHandler 替代是执行任何必需的本机视图设置的位置。 DisconnectHandler 替代是执行任何本机视图清理的位置,因此在 MauiVideoPlayer 实例上调用 Dispose 方法。

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

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<TVirtualView,TPlatformView> 类,所带有的泛型 Video 参数指定跨平台控件类型,而 MauiVideoPlayer 参数指定封装 MediaPlayerElement 本机视图的类型。

CreatePlatformView 替代会创建并返回 MauiVideoPlayer 对象。 ConnectHandler 替代是执行任何必需的本机视图设置的位置。 DisconnectHandler 替代是执行任何本机视图清理的位置,因此在 MauiVideoPlayer 实例上调用 Dispose 方法。

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

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 派生自 Grid,并将 MediaPlayerElement 添加为 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
        ...
    }
    ...
}

在此示例中,已在 OnHandlerChanged() 重写函数中将名为 video 的跨平台 Video 控件转换为每个平台上的基础本机视图。 当实现跨平台控件的本机视图可用并初始化时,将调用此重写函数。 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 属性,如果为 true,则将 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 方法中的最后一行将命令和关联数据发送到 VideoHandlerVideoHandlerCommandMapper 将命令名称映射到接收命令时执行的操作。 例如,当 VideoHandler 收到 PlayRequested 命令时,它将执行其 MapPlayRequested 方法。 此方法的优势在于,它无需本机视图订阅和取消订阅跨平台控件事件。 此外,它允许轻松进行自定义,因为跨平台控件使用者无需子类化即可修改命令映射器。 有关 CommandMapper 的详细信息,请参阅创建命令映射器

Android、iOS 和 Mac Catalyst 上的 MauiVideoPlayer 实现包含 PlayRequestedPauseRequestedStopRequested 方法,为响应 Video 控件发送 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 属性。

为协助处理程序实现持续更新属性 StatusVideo 类定义了事件 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 轮流调用 UpdateStatus 处理器 PlatformView 属性上的方法。 该 PlatformView 属性的类型为 MauiVideoPlayer,封装提供每个平台上视频播放器实现的本机视图。

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 属性,将该属性强制转换为 IVideoController,以便在 Video 对象上设置 Status 该属性。

iOS 和 Mac Catalyst

下列代码示例演示 UpdateStatus 方法如何在 iOS 和 Mac Catalyst 上设置 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

下列代码示例演示 UpdateStatus 方法如何在 Windows 设置 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 属性为 TimeSpan 值,后者在触发 MediaPlayerElement.MediaPlayer.MediaOpened 事件后变为有效。 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 方法来设置 Video 对象上的 Duration 属性,方法是通过将其强制转换为主线程上 IVideoController。 这是必要的,因为 MediaPlayerElement.MediaPlayer.MediaOpened 事件是在后台线程上处理的。 有关在主线程上运行代码的详细信息,请参阅在 .NET MAUI UI 线程上创建线程

Position

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。 然后,视频会开始加载,并在下载和缓冲足够数量的数据后开始播放。 在每个平台上,如果未使用传输控件,则传输控件会淡出,但可以通过点击视频将其恢复。

播放视频资源

通过 MauiAsset 生成操作嵌入到应用 Resources\Raw 文件夹中的视频文件可由 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.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 字符及其文本等效项定义的,以创建由图标和文本组成的按钮:

播放和暂停按钮的屏幕截图。

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

暂停和停止按钮的屏幕截图。

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 对象的 PositionTimeToEnd 属性值。

本机视图清理

每个平台的处理程序实现都会重写 DisconnectHandler 实现,该实现用于执行本地视图清理,如取消订阅事件和释放对象。 但是,.NET MAUI 有意不调用此重写函数。 实际上,必须从应用生命周期中的合适位置自行调用它。 这种情况通常发生在离开包含 Video 控件的页面时,这会触发页面的 Unloaded 事件。

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

<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 上向后导航时停止播放视频。

控制处理程序断开连接

每个平台的处理程序实现都会重写 DisconnectHandler 实现,该实现用于执行本地视图清理,如取消订阅事件和释放对象。 默认情况下,处理程序会尽可能自动与其控件断开连接,例如在应用中向后导航时。

在某些情况下,你可能希望控制处理程序何时与其控件断开连接,这可以通过附加属性实现 HandlerProperties.DisconnectPolicy 。 此属性需要一个 HandlerDisconnectPolicy 参数,枚举定义以下值:

以下示例演示如何设置 HandlerProperties.DisconnectPolicy 附加属性:

<controls:Video x:Name="video"
                HandlerProperties.DisconnectPolicy="Manual"
                Source="video.mp4"
                AutoPlay="False" />

等效 C# 代码如下:

Video video = new Video
{
    Source = "video.mp4",
    AutoPlay = false
};
HandlerProperties.SetDisconnectPolicy(video, HandlerDisconnectPolicy.Manual);

将附加属性设置为HandlerProperties.DisconnectPolicyManual时,必须从应用的生命周期中的合适位置自行调用处理程序DisconnectHandler的实现。 这可以通过调用 video.Handler?.DisconnectHandler();来实现。

此外,还有一种 DisconnectHandlers 扩展方法可将处理程序与给定 IView 断开连接:

video.DisconnectHandlers();

断开连接时,DisconnectHandlers 方法将沿控件树向下传播,直到完成或到达已设置手动策略的控件。