适用于 SwiftUI 的灵活且可扩展的 VideoPlayer

视频播放器容器

VideoPlayerContainer 是一个带有 SwiftUI 的视频播放器组件。与系统内置的VideoPlayer相比,VideoPlayerContainer 提供了更加灵活和可扩展的能力,能够覆盖您在 Tik Tok 或 Youtube 等应用上看到的大部分常见场景。

展示柜

例子

图像

克隆存储库并打开 Xcode 项目后,您可以看到多个方案作为示例。分别运行一下,感受一下这个框架能提供什么能力,以及使用这个框架有多么容易满足您的需求。

安装

VideoPlayerContainer 支持多种在项目中安装库的方法。

使用 CocoaPods 安装

要使用 CocoaPods 将 VideoPlayerContainer 集成到您的 Xcode 项目中,请在您的 中指定它Podfile

pod 'VideoPlayerContainer', :git => 'https://github.com/MickeyHub/VideoPlayerContainer.git'

使用 Swift 包管理器安装

设置完 Swift 包后,将 VideoPlayerContainer 添加为依赖项就像将其添加到 Package.swift 的依赖项值中一样简单。

dependencies: [
    .package(url: "https://github.com/MickeyHub/VideoPlayerContainer.git", .upToNextMajor(from: "1.0.0"))
]

要求

由于我使用了一些新功能,例如自定义布局,因此当前版本需要最低iOS版本为16.0macOS版本为13.0. 但我正在考虑将最低版本支持降低到iOS 14.0macOS 12.4

核心理念

语境

Context 是核心组件,可以从 VideoPlayerContainer 中的所有其他组件完全访问,它拥有一个服务定位器,我们可以使用它来获取其他服务以从其他组件借用专业知识。

小工具

Widget 是 VideoPlayerContainer 内部的一个视图,这意味着它可以访问上下文,并且在大多数情况下,它有一个特定的服务来处理其所有逻辑代码。

服务

Service代表两种角色,一种角色是MVVM架构中的ViewModel,ViewModel处理View的所有输出和输入。另一个角色负责与其他服务的通信。我们鼓励人们在一个源文件中编写 Service 和 Widget。这样,我们就可以使用fileprivate, 和private来区分哪些 API 只用于其 Widget,哪些 API 对其他服务开放。

覆盖

Overlay 在 VideoPlayerContainer 中,overlay 是一层层放置在主容器内的子容器,它是小部件所在的位置。我们有 5 个内置叠加层,从下到上分别是渲染、功能、插件、控制和 toast。此外,我们允许用户插入自己的叠加层

图像

渲染叠加

渲染叠加位于容器的最底部。它提供播放服务和手势服务。

  1. 在您的自定义中使用context[RenderService.self]来获取RenderService或使用context[GestureService.self]来分别获取GestureServiceWidget
  2. 使用 RenderService,访问播放器实例,该实例是一个AVPlayer.
  3. 设置渲染画布的重力。
  4. 使用GestureService,观察预定义事件,如tapdouble-taplong-pressdragrotationhover、 和pinch
  5. 对于tapdouble-tap,您可以知道触摸是位于屏幕的左侧还是右侧。
  6. 对于drag,您可以知道拖动事件是水平还是垂直(左/右)完成。

功能叠加

功能叠加用于从 4 个方向(leftrighttopbottom)弹出面板。我们还提供两种样式:coversqueeze使用挤压风格,当弹出面板出现时,渲染画布将被挤压到另一侧,就像 Youtube 在全屏模式下的评论面板一样。

插件覆盖

Plugin Overlay 是一个没有约束的子容器。当您想要显示不适合其他叠加层的小部件并且不想插入自己的自定义叠加层时,这就是适合您的地方,例如拖动时搜索栏的缩略图预览小部件或简单的小部件仅在触发后短时间内可见。

控制叠加

控制覆盖层是最复杂的覆盖层,也是完成大部分工作的地方。控制叠加分为 5 个部分:leftrighttopbottomcenter在继续之前,请允许我先介绍一个概念,叫做状态:

halfscreen我们预定义了、 、fullscreen3 种状态作为屏幕样式portrait状态的改变100%由你决定。但一般来说,halfscreen描述的是肖像设备的状态。fullscreen描述横向设备并portrait描述视频高度高于宽度。

对于这5个部分,您可以将它们配置为不同的状态,这是很常见的。例如,在半屏状态下,屏幕很小,我们无法在其上附加很多小部件,但在全屏状态下。视频播放器容器构成了整个屏幕。我们可以在其上附加许多小部件以提供越来越多的功能。

对于这些部件、这些状态,您可以自定义它们的阴影、过渡和布局。其他服务可以ControlService根据context[ControlService.self]配置的显示样式以编程方式获取调用当前或关闭的方法。

图像

吐司覆盖

Toast Overlay 是一个简单的叠加层,您可以使用它在左侧弹出视图,该视图将在配置几秒钟后消失。

用法:添加视频播放器

比方说,我们将在这里编写视频场景中的玩家视图。我们需要导入VideoPlayerContainer,并为视频播放器或整个视频场景创建一个上下文。

import VideoPlayerContainer

struct ContentView: View {
    
    @StateObject var context = Context()
    
    var body: some View {
    }
}

现在,您需要创建 PlayerView 以使其在场景中可见。在这里,我们使用 Type PlayerWidget它是主容器,需要一个上下文实例来初始化它。

var body: some View {
    PlayerWidget(context)
}

VideoPlayerContainer 现在已附加到场景。但是你看不到它,因为我们没有做任何配置工作,也没有传递视频资源项来播放。让我们做更多的工作(指定帧并播放视频)

var body: some View {
    PlayerWidget(context)
        .frame(height: 300)
        .onAppear {

            /// play video
            let item = AVPlayerItem(url: Bundle.main.url(forResource: "demo", withExtension: "mp4")!)
            context[RenderService.self].player.replaceCurrentItem(with: item)
            context[RenderService.self].player.play()
        }
}

运行它,视频就会播放。现在,正如您在其他应用程序中看到的那样。我们想在它上面附加一些小部件,比如PlaybackButton中间的一个。

用途:编写小部件

正如我上面所说,我们需要编写一个 PlaybackButton 并将其附加到播放器视图的中心。首先,我们需要创建一个名为PlaybackButtonWidget的SwiftUI源文件并编写一个基本的UI

struct PlaybackButtonWidget: View {
    var body: some View {
    	Image(systemName: "play.fill")
            .resizable()
            .scaledToFit()
            .foregroundColor(.white)
            .frame(width: 50, height: 50)
            .disabled(!service.clickable)
            .onTapGesture {
                /// tap handler
            }
    }
}

这是显示“播放”图标的视图。现在,我们需要将其附加到播放器视图。

var body: some View {
    PlayerWidget(context)
        .frame(height: 300)
        .onAppear {

            /// add widgets to the center for halfscreen status
            let controlService = context[ControlService.self]
            controlService.configure(.halfScreen(center)) {[
                PlaybackButtonWidget()
            ]}

            /// play video
            let item = AVPlayerItem(url: Bundle.main.url(forResource: "demo", withExtension: "mp4")!)
            context[RenderService.self].player.replaceCurrentItem(with: item)
            context[RenderService.self].player.play()
        }
}

现在,您可以在中心看到一个图标,默认情况下,您可以点击屏幕以使其可见或不可见。但是,您可以看到我们没有填写使图标工作(播放和暂停)的逻辑代码。如何?

当我们创建玩家视图并传入上下文实例时,上下文实例将被放入环境中。因此,视频播放器容器内的所有小部件都可以访问上下文。我们不直接在 Widget 上访问上下文,而是更喜欢使用 Service 作为 ViewModel 来处理 Widget 的所有功能。

class PlaybackService: Service {
    
    private var rateObservation: NSKeyValueObservation?
    
    private var statusObservation: NSKeyValueObservation?
    
    @ViewState fileprivate var playOrPaused = false
    
    @ViewState fileprivate var clickable = false
    
    required init(_ context: Context) {
        super.init(context)
        
        let service = context[RenderService.self]
        rateObservation = service.player.observe(\.rate, options: [.old, .new, .initial]) { [weak self] player, change in
            self?.playOrPaused = player.rate > 0
        }
        
        statusObservation = service.player.observe(\.status, options: [.old, .new, .initial]) { [weak self] player, change in
            self?.clickable = player.status == .readyToPlay
        }
    }
    
    fileprivate func didClick() {
        
        let service = context[RenderService.self]
        if service.player.rate == 0 {
            service.player.play()
        } else {
            service.player.pause()
        }
    }
}

struct PlaybackWidget: View {
    var body: some View {
        WithService(PlaybackService.self) { service in
            Image(systemName: service.playOrPaused ? "pause.fill" : "play.fill")
                .resizable()
                .scaledToFit()
                .foregroundColor(.white)
                .frame(width: 50, height: 50)
                .disabled(!service.clickable)
                .onTapGesture {
                    service.didClick()
                }
        }
    }
}

正如您在上面看到的,这是一个完整的 Widget。

  • 我们使用fileprivate修饰符来标记仅对其所属的 Widget 可用的 API
  • 我们用来@ViewState标记能够触发SwiftUI更新机制的变量(如@Published、@State)
  • 我们使用WithService作为Widget的根View来确保任何@ViewState变量的更改都会使整个Widget参与更新机制
  • 我们使用@ViewState变量来条件在小部​​件中使用哪个图像。(ViewModel 的输出)
  • 我们调用服务方法来完成小部件的工作(ViewModel 的输入)

想法/错误/改进

欢迎报告问题,让我们一起改进😀

执照

VideoPlayerContainer 是根据 MIT 许可证发布的。有关详细信息,请参阅许可证。

GitHub

查看 Github