VideoPlayerContainer 是一个基于SwiftUI的视频播放组件. 相比于系统内置的VideoPlayer, VideoPlayerContainer 提供了更多灵活的, 可扩展的特性. 基本可以覆盖市面上看到的常见视频app的使用. 比如Tik Tok 或者 Youtube.
克隆仓库之后, 打开Xcode工程, 你可以看到有很多示例项目. 你可以分别运行他们来查看这个框架提供了哪些能力, 以及它是否可以很容易得实现你的需求.
- Youtube-Example
- Bilibili-Example
- TikTok-Example
- SystemVideoPlayer-Example
- VideoNavigation-Example
- QuickTime-Example
- VisionPro-Example
- Test-Example
VideoPlayerContainer 支持多种方法的集成方式
使用CocoaPods 集成 VideoPlayerContainer, 你需要将下面代码放到你工程中的 Podfile
:
pod 'VideoPlayerContainer', :git => 'https://github.com/shayanbo/VideoPlayerContainer.git'
在工程的 Package.swift
中添加如下依赖:
dependencies: [
.package(url: "https://github.com/shayanbo/VideoPlayerContainer.git", .upToNextMajor(from: "1.0.0"))
]
Context
是一个核心类, 他可以被 VideoPlayerContainer
内所有的 Widget
访问到, Context
内部持有一个服务定位器(service locator), 提供 Service
之间访问的能力. 可以通过context[Service.Type]获取其他 Service
实例. Context
保证缓存的 Service
实例最多只有一个. 除此之外. 内置的 Service
提供了扩展API可以方便的获取, 比如 context.render
, context.control
等.
Widget
本身就是 VideoPlayerContainer
中一个 SwiftUI
的 View
, 他可以访问到 Context
对象, 绝大多数的情况下, 会为它编写一个专门的 Service
对象来处于逻辑和负责Service间通讯的工作. 通常我们会在 Widget
中使用 WithService
作为根视图来访问相应的 Service
. 这样既能使用 Service
提供的方法, 还会在 Service
的State变化的时候, 自动刷新当前 Widget
.
PlayerWidget
是 VideoPlayerContainer
提供的播放容器, 内部持有了所有了内置 Overlay
, 也持有了所有自定义的 Widget
. 是使用该库需要构建的核心视图.
Service
代表了两个角色. 其一: 它作为MVVM架构的ViewModel, ViewModel 处理它所属的 Widget
的所有的 Output和Input. 其二: 它负责和其他 Service
之间的通讯. 我们鼓励大家在同一个源文件中编写 Service
和 Widget
. 如此一来, 我们就可以使用 fileprivate
和 private
来区分哪些API是所属Widget专享的, 哪些API是提供给其他 Service
使用的.
事实上, 存在两种 Service
: Widget Service, Non-Widget Service. Widget Service 指的是那些被特定 Widget
使用的 Service
while Non-Widget Service 指的是那些专门给其他 Service
s 使用的 Service
.
我们内置了3个重要的属性包装器, 确保你可以编写易读并且易测的代码.
- ViewState (视图状态): 它类似于Combine的Published. 你可以用它来标记
Service
中的State
. - StateSync (状态同步): 它类似于
ViewState
, 但是它是用于同步其他Service
的State
. 举个例子, 当你想要你的Widget
会随着其他Service
的某个State
变化而刷新的时候,StateSync
就是一个很好的选择. - Dependency (外部依赖): 它是用于
Service
内部引入外部依赖使用的. 我们推荐大家使用这种方式引入外部依赖, 而不是直接内部创建并持有. 用这种方式的话. 你可以很容易在将外部依赖的实现通过Context.withDependency(_:factory:)
替换掉. 这对单元测试来说, 非常实用.
Overlay
指的是 PlayerWidget
内叠加布局的子容器. 每个子容器都有专门的 Service
来对外提供能力. 我们一共内置了5个 Overlay
, 从下往上依次是: render, feature, plugin, control, and toast. 除此之外, 我们也允许使用者插入自定义的 Overlay
.
Render Overlay
位于 PlayerWidget
的最底层. 它对外提供了播控能力. 可以访问到 AVPlayer
和 AVPlayerLayer
. 除此之外. 该层还内嵌了一个 Gesture Overlay
. 对外提供手势控制的能力. 比如 VisionPro-Example 中 PlaybackWidget
通过 GestureService
实现了双击暂停和播放, 以及 SeekBarWidget
使用 GestureService
实现水平左右滑动来快进和后退.
Feature overlay
用于展示面板. 这个面板可以从上下左右四个方向出现. 而且我们提供了两种样式, 一种是覆盖式的展示, 不影响其他Overlay, 比如 QuickTime-Example 中的播单 PlaylistWidget
. 另一种就是挤压式的展示, 会把所有Overlay挤压到另一侧, 比如 Youtube-Example CommentWidget
中.
Plugin Overlay
是一个没有太多规则约束的控件容器. 当你想要展示一个控件, 这个控件不太适合其他层而且你也不想插入自定义层的时候, 那这个插件层可能就比较合适, 比如视频进度拖拽的预览控件 (QuickTime-Example的 SeekBarWidget
和 PreviewWidget
)或者是一个某个逻辑触发之后会展示一小会的控件.
Control Overlay
是最复杂的一层, 也是大部分 Widget
所在的一层. Control Overlay
被划分成5个区域: 左
, 右
, 上
, 下
, and 中
. 再继续讲述之前, 我们需要先介绍一个概念叫 Status
:
我们预定义了3个 Status
分别是 halfscreen
, fullscreen
和 portrait
. Status
表达了当前 PlayerWidget
所处的一种状态. 这个状态的变化百分百由使用者控制. 但是通常来讲, halfscreen
描述的是在竖屏设备下, 视频宽度大于高度的一种状态. 这种是比较常见的, 比如在Youtube的视频播放页等. fullscreen
描述的是一种在横屏设备下, PlayerWidget
占满整个屏幕的状态, 比如Youtube的全屏模式. portrait
描述的是在竖屏设备下, 视频的高度大于宽度的一种状态, 比如TikTok的视频.
对于这5个区域, 以及每个区域不同的 Status
, 我们都可以分别设置需要展示的 Widget
s 以及布局. 举个例子, 在 halfscreen
状态, PlayerWidget
的显示区域比较小, 我们没法防止太多的 Widget
, 但是在 fullscreen
状态. PlayerWidget
占满整个屏幕, 我们可以放置更多的 Widget
来提供更多的常驻在屏幕上的功能.
除此之外, 对于这些不同的区域, 以及每个区域的不同状态, 你还可以自定义他们的阴影, 背景, 过渡动画 以及布局等. 其他 Service
也可以通过 context.control
来触发它的展示或者隐藏, 当然这个行为依赖于开发者自己设置的 DisplayStyle
.
Toast Overlay
是一个相对简单的 Overlay
, 正如它的名字一样, 他提供了一些Toast提示的服务. 支持连续多个Toast弹出, 旧的Toast会被顶到上面. 直接N秒后自动消失. 目前这个Toast出现和消失的Transition是不对外暴露的, 限定于从左侧入, 然后淡出. 其他的都是可配置的, 比如: 展示时长, 自定义Toast等.
比如说, 我们正在视频播放页里面添加一个视频播放组件. 在这, 我们要先导入 VideoPlayerContainer
, 然后为该视频播放页创建 Context
实例.
import VideoPlayerContainer
struct ContentView: View {
@StateObject var context = Context()
var body: some View {
}
}
现在, 你需要创建一个 PlayerWidget
放置到页面上. PlayerWidget
是本库的主要控件容器. 内部包含所有的 Overlay
, 也会包含我们所有自定义的控件. PlayerWidget
需要传入一个 Context
实例进行初始化.
var body: some View {
PlayerWidget(context)
}
PlayerWidget
现在被添加到页面上了. 但是你看不到它, 因为我们没有做任何配置, 也没有传入视频资源让它播放. 那么, 让我们进一步完成它吧 (设置frame, 播放视频).
var body: some View {
PlayerWidget(context)
.frame(height: 300)
.onAppear {
/// play video
let item = AVPlayerItem(url: Bundle.main.url(forResource: "demo", withExtension: "mp4")!)
context.render.player.replaceCurrentItem(with: item)
context.render.player.play()
}
}
运行, 我们能够看到视频开始播放了. 正如你在其他app上看到的那样, 我们希望可以在上面添加一下控件, 比如: 一个播控按钮.
就像上面说的那样, 我们需要编写一个播控按钮, 然后把它放到 PlayerWidget
的中央. 首先, 我们需要创建一个 SwiftUI
源文件叫做 PlaybackButtonWidget
然后编写基础的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
}
}
}
这样我们就完成了一个播控 Widget
的UI部分, 他展示了一个播放图标. 现在我们要把它添加到 PlayerWidget
内. 这里我们选择添加到 PlayerWidget
的 Control层
.
var body: some View {
PlayerWidget(context)
.frame(height: 300)
.onAppear {
/// add widgets to the center for halfscreen status
context.control.configure(.halfScreen(center)) {[
PlaybackButtonWidget()
]}
/// play video
let item = AVPlayerItem(url: Bundle.main.url(forResource: "demo", withExtension: "mp4")!)
context.render.player.replaceCurrentItem(with: item)
context.render.player.play()
}
}
现在, 你可以在 PlayerWidget
的中央看到这个图标. 基于 Control
层的默认 DisplayStyle
, 你可以点击 Control
层的空白区域来让该层显示或者隐藏. 但是当你点击播放按钮的时候, 你会发现并没有事情发生. 因为我们还没有编写事件响应代码. 怎么办?
当我们创建一个 PlayerWidget
并且传入 Context
实例之后, 这个 Context
实例会被放入Environment. 因此, 所有在 PlayerWidget
的控件都能够访问到这个 Context
实例. 相较于在 Widget
内直接访问 Context
, 我们更推荐使用 WithService
来访问自己的 Service
, 并且该 Service
的State变动会自动更新该控件.
fileprivate class PlaybackService: Service {
private var rateObservation: NSKeyValueObservation?
private var statusObservation: NSKeyValueObservation?
@ViewState var playOrPaused = false
@ViewState var clickable = false
required init(_ context: Context) {
super.init(context)
rateObservation = context.render.player.observe(\.rate, options: [.old, .new, .initial]) { [weak self] player, change in
self?.playOrPaused = player.rate > 0
}
statusObservation = context.render.player.observe(\.status, options: [.old, .new, .initial]) { [weak self] player, change in
self?.clickable = player.status == .readyToPlay
}
}
func didClick() {
if context.render.player.rate == 0 {
context.render.player.play()
} else {
context.render.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
修饰符来标记API是Widget
专享的方法. - 我们使用
@ViewState
来标记那些可以触发SwiftUI
刷新机制的变量 (类似于 @Published, @State). - 我们使用
WithService
作为Widget
的根视图来确保任何@ViewState
变量的变化都会触发整个Widget
的UI刷新. - 在
Widget
中, 我们使用@ViewState
变量来判断哪个图片需要被展示. (角色: ViewModel's Output). - 我们调用
Service
的方法来完成Widget
的工作 (角色: ViewModel's Input).
我们鼓励使用者在同一个源文件中编写 Widget
和对应的 Service
. 这样, 我们就可以在 Service
中充分利用访问修饰符.
- 如果你正在编写一个只被
Widget
使用到的 Widget Service, 我们推荐使用fileprivate
来修饰这个Service
的class. 因为它只被同一个源文件中的Widget
使用. 当然, 对于那些只在Service
内部使用的变量和方法, 还是需要使用private
来修饰. - 如果你正在编写一个需要提供给其他
Service
s 调用的 Widget Service, 我们推荐使用internal
或者public
来修饰这个Service
的class. 因为其他的Service
s 需要在编译期间通过Context
访问到你的Service
. 当然, 对于那些只在Service
内部使用的变量和方法, 还是需要使用private
来修饰. 对于那些只在所属的Widget
内使用的变量和方法, 还是需要使用fileprivate
来修饰. - 如果你正在编写一个 Non-Widget Service. 我们推荐使用
internal
或者public
来修饰这个Service
的class. 因为其他的Service
s 需要在编译期间通过Context
访问到你的Service
. 当然, 对于那些只在Service
内部使用的变量和方法, 还是需要使用private
来修饰.
Core目录的源文件不仅可以用于该工程. 同样地, 他也适用于大部分其他场景. 当你在创建一个复杂页面或者模块的时候. 这些文件可以让你的代码更加可读和可测.
任何问题都可以在Issue板块提出, 我们会及时沟通并且共同改进😀.
VideoPlayerContainer 是基于 MIT 协议发布的开源框架. 更多细节在 LICENSE.