https://github.com/shayanbo/videoplayercontainer
Flexible and extendable VideoPlayer for SwiftUI
https://github.com/shayanbo/videoplayercontainer
ios swift swiftui videoplayer visionpro
Last synced: 6 months ago
JSON representation
Flexible and extendable VideoPlayer for SwiftUI
- Host: GitHub
- URL: https://github.com/shayanbo/videoplayercontainer
- Owner: shayanbo
- License: mit
- Created: 2023-06-25T10:02:58.000Z (over 2 years ago)
- Default Branch: main
- Last Pushed: 2024-12-01T01:11:39.000Z (10 months ago)
- Last Synced: 2025-03-31T10:01:32.326Z (6 months ago)
- Topics: ios, swift, swiftui, videoplayer, visionpro
- Language: Swift
- Homepage:
- Size: 21 MB
- Stars: 94
- Watchers: 4
- Forks: 10
- Open Issues: 0
-
Metadata Files:
- Readme: README-CN.md
- Changelog: CHANGELOG.md
- License: LICENSE
Awesome Lists containing this project
README
# VideoPlayerContainer
VideoPlayerContainer 是一个基于SwiftUI的视频播放组件. 相比于系统内置的[VideoPlayer](https://developer.apple.com/documentation/avkit/videoplayer), VideoPlayerContainer 提供了更多灵活的, 可扩展的特性. 基本可以覆盖市面上看到的常见视频app的使用. 比如Tik Tok 或者 Youtube.


## Showcase
![]()
![]()
## Example
克隆仓库之后, 打开Xcode工程, 你可以看到有很多示例项目. 你可以分别运行他们来查看这个框架提供了哪些能力, 以及它是否可以很容易得实现你的需求.
* [Youtube-Example](Youtube-Example)
* [Bilibili-Example](Bilibili-Example)
* [TikTok-Example](TikTok-Example)
* [SystemVideoPlayer-Example](SystemVideoPlayer-Example)
* [VideoNavigation-Example](VideoNavigation-Example)
* [QuickTime-Example](QuickTime-Example)
* [VisionPro-Example](VisionPro-Example)
* [Test-Example](Test-Example)
## 安装VideoPlayerContainer 支持多种方法的集成方式
#### 使用CocoaPods
使用CocoaPods 集成 VideoPlayerContainer, 你需要将下面代码放到你工程中的 `Podfile`:
```
pod 'VideoPlayerContainer', :git => 'https://github.com/shayanbo/VideoPlayerContainer.git'
```#### 使用SwiftPM
在工程的 `Package.swift` 中添加如下依赖:
```
dependencies: [
.package(url: "https://github.com/shayanbo/VideoPlayerContainer.git", .upToNextMajor(from: "1.0.0"))
]
```## 核心概念
### Context (上下文)
`Context` 是一个核心类, 他可以被 `VideoPlayerContainer` 内所有的 `Widget` 访问到, `Context` 内部持有一个服务定位器(service locator), 提供 `Service` 之间访问的能力. 可以通过context[Service.Type]获取其他 `Service` 实例. `Context` 保证缓存的 `Service` 实例最多只有一个. 除此之外. 内置的 `Service` 提供了扩展API可以方便的获取, 比如 `context.render`, `context.control` 等.
### Widget (控件)
`Widget` 本身就是 `VideoPlayerContainer` 中一个 `SwiftUI` 的 `View`, 他可以访问到 `Context` 对象, 绝大多数的情况下, 会为它编写一个专门的 `Service` 对象来处于逻辑和负责Service间通讯的工作. 通常我们会在 `Widget` 中使用 `WithService` 作为根视图来访问相应的 `Service`. 这样既能使用 `Service` 提供的方法, 还会在 `Service` 的State变化的时候, 自动刷新当前 `Widget`.
### PlayerWidget (播放容器控件)
`PlayerWidget` 是 `VideoPlayerContainer` 提供的播放容器, 内部持有了所有了内置 `Overlay`, 也持有了所有自定义的 `Widget`. 是使用该库需要构建的核心视图.
### Service (服务)
`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`.
### Property Wrappers (属性包装器)
我们内置了3个重要的属性包装器, 确保你可以编写易读并且易测的代码.
* **ViewState (视图状态)**: 它类似于Combine的Published. 你可以用它来标记 `Service` 中的 `State`.
* **StateSync (状态同步)**: 它类似于 `ViewState`, 但是它是用于同步其他 `Service` 的 `State`. 举个例子, 当你想要你的 `Widget` 会随着其他 `Service` 的某个 `State` 变化而刷新的时候, `StateSync` 就是一个很好的选择.
* **Dependency (外部依赖)**: 它是用于 `Service` 内部引入外部依赖使用的. 我们推荐大家使用这种方式引入外部依赖, 而不是直接内部创建并持有. 用这种方式的话. 你可以很容易在将外部依赖的实现通过 `Context.withDependency(_:factory:)` 替换掉. 这对**单元测试**来说, 非常实用.### Overlay (层)
`Overlay` 指的是 `PlayerWidget` 内叠加布局的子容器. 每个子容器都有专门的 `Service` 来对外提供能力. 我们一共内置了5个 `Overlay`, 从下往上依次是: render, feature, plugin, control, and toast. 除此之外, 我们也允许使用者插入自定义的 `Overlay`.

#### Render Overlay (播控渲染层)
`Render Overlay` 位于 `PlayerWidget` 的最底层. 它对外提供了播控能力. 可以访问到 `AVPlayer` 和 `AVPlayerLayer`. 除此之外. 该层还内嵌了一个 `Gesture Overlay`. 对外提供手势控制的能力. 比如 [VisionPro-Example](VisionPro-Example) 中 `PlaybackWidget` 通过 `GestureService` 实现了双击暂停和播放, 以及 `SeekBarWidget` 使用 `GestureService` 实现水平左右滑动来快进和后退.
#### Feature Overlay (面板层)
`Feature overlay` 用于展示面板. 这个面板可以从上下左右四个方向出现. 而且我们提供了两种样式, 一种是覆盖式的展示, 不影响其他Overlay, 比如 [QuickTime-Example](QuickTime-Example) 中的播单 `PlaylistWidget`. 另一种就是挤压式的展示, 会把所有Overlay挤压到另一侧, 比如 [Youtube-Example](Youtube-Example) `CommentWidget` 中.
#### Plugin Overlay (插件层)
`Plugin Overlay` 是一个没有太多规则约束的控件容器. 当你想要展示一个控件, 这个控件不太适合其他层而且你也不想插入自定义层的时候, 那这个插件层可能就比较合适, 比如视频进度拖拽的预览控件 ([QuickTime-Example](QuickTime-Example)的 `SeekBarWidget` 和 `PreviewWidget` )或者是一个某个逻辑触发之后会展示一小会的控件.
#### Control Overlay (控制层)
`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 (提示层)
`Toast Overlay` 是一个相对简单的 `Overlay`, 正如它的名字一样, 他提供了一些Toast提示的服务. 支持连续多个Toast弹出, 旧的Toast会被顶到上面. 直接N秒后自动消失. 目前这个Toast出现和消失的Transition是不对外暴露的, 限定于从左侧入, 然后淡出. 其他的都是可配置的, 比如: 展示时长, 自定义Toast等.
## 使用: 添加 VideoPlayer
比如说, 我们正在视频播放页里面添加一个视频播放组件. 在这, 我们要先导入 `VideoPlayerContainer`, 然后为该视频播放页创建 `Context` 实例.
```swift
import VideoPlayerContainerstruct ContentView: View {
@StateObject var context = Context()
var body: some View {
}
}
```现在, 你需要创建一个 `PlayerWidget` 放置到页面上. `PlayerWidget` 是本库的主要控件容器. 内部包含所有的 `Overlay`, 也会包含我们所有自定义的控件. `PlayerWidget`需要传入一个 `Context` 实例进行初始化.
```swift
var body: some View {
PlayerWidget(context)
}
````PlayerWidget` 现在被添加到页面上了. 但是你看不到它, 因为我们没有做任何配置, 也没有传入视频资源让它播放. 那么, 让我们进一步完成它吧 (设置frame, 播放视频).
```swift
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上看到的那样, 我们希望可以在上面添加一下控件, 比如: 一个播控按钮.
## 使用: 编写 Widgets
就像上面说的那样, 我们需要编写一个播控按钮, 然后把它放到 `PlayerWidget` 的中央. 首先, 我们需要创建一个 `SwiftUI` 源文件叫做 `PlaybackButtonWidget` 然后编写基础的UI.
```swift
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层` .
```swift
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变动会自动更新该控件.
```swift
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).## Service中的访问修饰符的使用
我们鼓励使用者在同一个源文件中编写 `Widget` 和对应的 `Service`. 这样, 我们就可以在 `Service` 中充分利用访问修饰符.
1. 如果你正在编写一个只被 `Widget` 使用到的 **Widget Service**, 我们推荐使用 `fileprivate` 来修饰这个 `Service` 的class. 因为它只被同一个源文件中的 `Widget` 使用. 当然, 对于那些只在 `Service` 内部使用的变量和方法, 还是需要使用 `private` 来修饰.
2. 如果你正在编写一个需要提供给其他 `Service`s 调用的 **Widget Service**, 我们推荐使用 `internal` 或者 `public` 来修饰这个 `Service` 的class. 因为其他的 `Service`s 需要在编译期间通过 `Context` 访问到你的 `Service`. 当然, 对于那些只在 `Service` 内部使用的变量和方法, 还是需要使用 `private` 来修饰. 对于那些只在所属的 `Widget` 内使用的变量和方法, 还是需要使用 `fileprivate` 来修饰.
3. 如果你正在编写一个 **Non-Widget Service**. 我们推荐使用 `internal` 或者 `public` 来修饰这个 `Service` 的class. 因为其他的 `Service`s 需要在编译期间通过 `Context` 访问到你的 `Service`. 当然, 对于那些只在 `Service` 内部使用的变量和方法, 还是需要使用 `private` 来修饰.## Core 目录
Core目录的源文件不仅可以用于该工程. 同样地, 他也适用于大部分其他场景. 当你在创建一个复杂页面或者模块的时候. 这些文件可以让你的代码更加可读和可测.
## 想法 / 缺陷 / 改进
任何问题都可以在Issue板块提出, 我们会及时沟通并且共同改进😀.
## 开源协议
VideoPlayerContainer 是基于 MIT 协议发布的开源框架. 更多细节在 [LICENSE](LICENSE).