Skip to content

Latest commit

 

History

History
283 lines (195 loc) · 16.1 KB

README-CN.md

File metadata and controls

283 lines (195 loc) · 16.1 KB

VideoPlayerContainer

VideoPlayerContainer 是一个基于SwiftUI的视频播放组件. 相比于系统内置的VideoPlayer, VideoPlayerContainer 提供了更多灵活的, 可扩展的特性. 基本可以覆盖市面上看到的常见视频app的使用. 比如Tik Tok 或者 Youtube.

Platform Version Version

Showcase






Example

克隆仓库之后, 打开Xcode工程, 你可以看到有很多示例项目. 你可以分别运行他们来查看这个框架提供了哪些能力, 以及它是否可以很容易得实现你的需求.

安装

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 中一个 SwiftUIView, 他可以访问到 Context 对象, 绝大多数的情况下, 会为它编写一个专门的 Service 对象来处于逻辑和负责Service间通讯的工作. 通常我们会在 Widget 中使用 WithService 作为根视图来访问相应的 Service. 这样既能使用 Service 提供的方法, 还会在 Service 的State变化的时候, 自动刷新当前 Widget.

PlayerWidget (播放容器控件)

PlayerWidgetVideoPlayerContainer 提供的播放容器, 内部持有了所有了内置 Overlay, 也持有了所有自定义的 Widget. 是使用该库需要构建的核心视图.

Service (服务)

Service 代表了两个角色. 其一: 它作为MVVM架构的ViewModel, ViewModel 处理它所属的 Widget 的所有的 Output和Input. 其二: 它负责和其他 Service 之间的通讯. 我们鼓励大家在同一个源文件中编写 ServiceWidget. 如此一来, 我们就可以使用 fileprivateprivate 来区分哪些API是所属Widget专享的, 哪些API是提供给其他 Service 使用的.

事实上, 存在两种 Service: Widget Service, Non-Widget Service. Widget Service 指的是那些被特定 Widget 使用的 Service while Non-Widget Service 指的是那些专门给其他 Services 使用的 Service.

Property Wrappers (属性包装器)

我们内置了3个重要的属性包装器, 确保你可以编写易读并且易测的代码.

  • ViewState (视图状态): 它类似于Combine的Published. 你可以用它来标记 Service 中的 State.
  • StateSync (状态同步): 它类似于 ViewState, 但是它是用于同步其他 ServiceState. 举个例子, 当你想要你的 Widget 会随着其他 Service 的某个 State 变化而刷新的时候, StateSync 就是一个很好的选择.
  • Dependency (外部依赖): 它是用于 Service 内部引入外部依赖使用的. 我们推荐大家使用这种方式引入外部依赖, 而不是直接内部创建并持有. 用这种方式的话. 你可以很容易在将外部依赖的实现通过 Context.withDependency(_:factory:) 替换掉. 这对单元测试来说, 非常实用.

Overlay (层)

Overlay 指的是 PlayerWidget 内叠加布局的子容器. 每个子容器都有专门的 Service 来对外提供能力. 我们一共内置了5个 Overlay, 从下往上依次是: render, feature, plugin, control, and toast. 除此之外, 我们也允许使用者插入自定义的 Overlay.

image

Render Overlay (播控渲染层)

Render Overlay 位于 PlayerWidget 的最底层. 它对外提供了播控能力. 可以访问到 AVPlayerAVPlayerLayer. 除此之外. 该层还内嵌了一个 Gesture Overlay. 对外提供手势控制的能力. 比如 VisionPro-ExamplePlaybackWidget 通过 GestureService 实现了双击暂停和播放, 以及 SeekBarWidget 使用 GestureService 实现水平左右滑动来快进和后退.

Feature Overlay (面板层)

Feature overlay 用于展示面板. 这个面板可以从上下左右四个方向出现. 而且我们提供了两种样式, 一种是覆盖式的展示, 不影响其他Overlay, 比如 QuickTime-Example 中的播单 PlaylistWidget. 另一种就是挤压式的展示, 会把所有Overlay挤压到另一侧, 比如 Youtube-Example CommentWidget 中.

Plugin Overlay (插件层)

Plugin Overlay 是一个没有太多规则约束的控件容器. 当你想要展示一个控件, 这个控件不太适合其他层而且你也不想插入自定义层的时候, 那这个插件层可能就比较合适, 比如视频进度拖拽的预览控件 (QuickTime-ExampleSeekBarWidgetPreviewWidget )或者是一个某个逻辑触发之后会展示一小会的控件.

Control Overlay (控制层)

Control Overlay 是最复杂的一层, 也是大部分 Widget 所在的一层. Control Overlay 被划分成5个区域: , , , , and . 再继续讲述之前, 我们需要先介绍一个概念叫 Status:

我们预定义了3个 Status 分别是 halfscreen, fullscreenportrait. Status 表达了当前 PlayerWidget 所处的一种状态. 这个状态的变化百分百由使用者控制. 但是通常来讲, halfscreen 描述的是在竖屏设备下, 视频宽度大于高度的一种状态. 这种是比较常见的, 比如在Youtube的视频播放页等. fullscreen 描述的是一种在横屏设备下, PlayerWidget 占满整个屏幕的状态, 比如Youtube的全屏模式. portrait 描述的是在竖屏设备下, 视频的高度大于宽度的一种状态, 比如TikTok的视频.

对于这5个区域, 以及每个区域不同的 Status, 我们都可以分别设置需要展示的 Widgets 以及布局. 举个例子, 在 halfscreen 状态, PlayerWidget 的显示区域比较小, 我们没法防止太多的 Widget, 但是在 fullscreen 状态. PlayerWidget 占满整个屏幕, 我们可以放置更多的 Widget 来提供更多的常驻在屏幕上的功能.

除此之外, 对于这些不同的区域, 以及每个区域的不同状态, 你还可以自定义他们的阴影, 背景, 过渡动画 以及布局等. 其他 Service 也可以通过 context.control 来触发它的展示或者隐藏, 当然这个行为依赖于开发者自己设置的 DisplayStyle.

image

Toast Overlay (提示层)

Toast Overlay 是一个相对简单的 Overlay, 正如它的名字一样, 他提供了一些Toast提示的服务. 支持连续多个Toast弹出, 旧的Toast会被顶到上面. 直接N秒后自动消失. 目前这个Toast出现和消失的Transition是不对外暴露的, 限定于从左侧入, 然后淡出. 其他的都是可配置的, 比如: 展示时长, 自定义Toast等.

使用: 添加 VideoPlayer

比如说, 我们正在视频播放页里面添加一个视频播放组件. 在这, 我们要先导入 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上看到的那样, 我们希望可以在上面添加一下控件, 比如: 一个播控按钮.

使用: 编写 Widgets

就像上面说的那样, 我们需要编写一个播控按钮, 然后把它放到 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 内. 这里我们选择添加到 PlayerWidgetControl层 .

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

Service中的访问修饰符的使用

我们鼓励使用者在同一个源文件中编写 Widget 和对应的 Service. 这样, 我们就可以在 Service 中充分利用访问修饰符.

  1. 如果你正在编写一个只被 Widget 使用到的 Widget Service, 我们推荐使用 fileprivate 来修饰这个 Service 的class. 因为它只被同一个源文件中的 Widget 使用. 当然, 对于那些只在 Service 内部使用的变量和方法, 还是需要使用 private 来修饰.
  2. 如果你正在编写一个需要提供给其他 Services 调用的 Widget Service, 我们推荐使用 internal 或者 public 来修饰这个 Service 的class. 因为其他的 Services 需要在编译期间通过 Context 访问到你的 Service. 当然, 对于那些只在 Service 内部使用的变量和方法, 还是需要使用 private 来修饰. 对于那些只在所属的 Widget 内使用的变量和方法, 还是需要使用 fileprivate 来修饰.
  3. 如果你正在编写一个 Non-Widget Service. 我们推荐使用 internal 或者 public 来修饰这个 Service 的class. 因为其他的 Services 需要在编译期间通过 Context 访问到你的 Service. 当然, 对于那些只在 Service 内部使用的变量和方法, 还是需要使用 private 来修饰.

Core 目录

Core目录的源文件不仅可以用于该工程. 同样地, 他也适用于大部分其他场景. 当你在创建一个复杂页面或者模块的时候. 这些文件可以让你的代码更加可读和可测.

想法 / 缺陷 / 改进

任何问题都可以在Issue板块提出, 我们会及时沟通并且共同改进😀.

开源协议

VideoPlayerContainer 是基于 MIT 协议发布的开源框架. 更多细节在 LICENSE.