{"id":25809142,"url":"https://github.com/shayanbo/videoplayercontainer","last_synced_at":"2025-04-07T11:01:32.119Z","repository":{"id":176541023,"uuid":"658284482","full_name":"shayanbo/VideoPlayerContainer","owner":"shayanbo","description":"Flexible and extendable VideoPlayer for SwiftUI","archived":false,"fork":false,"pushed_at":"2024-12-01T01:11:39.000Z","size":21996,"stargazers_count":94,"open_issues_count":0,"forks_count":10,"subscribers_count":4,"default_branch":"main","last_synced_at":"2025-03-31T10:01:32.326Z","etag":null,"topics":["ios","swift","swiftui","videoplayer","visionpro"],"latest_commit_sha":null,"homepage":"","language":"Swift","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/shayanbo.png","metadata":{"files":{"readme":"README-CN.md","changelog":"CHANGELOG.md","contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null}},"created_at":"2023-06-25T10:02:58.000Z","updated_at":"2025-03-25T14:34:23.000Z","dependencies_parsed_at":"2025-02-27T23:09:38.874Z","dependency_job_id":"34da287b-645c-4881-8ca1-7d5fc1752a74","html_url":"https://github.com/shayanbo/VideoPlayerContainer","commit_stats":null,"previous_names":["mickeyhub/playerview"],"tags_count":2,"template":false,"template_full_name":null,"repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/shayanbo%2FVideoPlayerContainer","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/shayanbo%2FVideoPlayerContainer/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/shayanbo%2FVideoPlayerContainer/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/shayanbo%2FVideoPlayerContainer/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/shayanbo","download_url":"https://codeload.github.com/shayanbo/VideoPlayerContainer/tar.gz/refs/heads/main","host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":247640459,"owners_count":20971556,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2022-07-04T15:15:14.044Z","host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["ios","swift","swiftui","videoplayer","visionpro"],"created_at":"2025-02-27T22:52:34.374Z","updated_at":"2025-04-07T11:01:32.088Z","avatar_url":"https://github.com/shayanbo.png","language":"Swift","funding_links":[],"categories":[],"sub_categories":[],"readme":"# VideoPlayerContainer\n\nVideoPlayerContainer 是一个基于SwiftUI的视频播放组件. 相比于系统内置的[VideoPlayer](https://developer.apple.com/documentation/avkit/videoplayer), VideoPlayerContainer 提供了更多灵活的, 可扩展的特性. 基本可以覆盖市面上看到的常见视频app的使用. 比如Tik Tok 或者 Youtube.\n\n![Platform](https://img.shields.io/badge/platform-iOS|macOS|visionOS-orange.svg)\n![Version](https://img.shields.io/badge/version-16.0|13.0|1.0-green.svg)\n![Version](https://img.shields.io/badge/deps-CocoaPods|SwiftPM-purple.svg)\n\n## Showcase\n\n\u003cimg src='https://github.com/shayanbo/VideoPlayerContainer/assets/5426838/57a33161-0f59-4106-82f0-a81085c4e90e'\u003e\u003cbr/\u003e\n\u003cimg src='https://github.com/shayanbo/VideoPlayerContainer/assets/5426838/07ace6fd-8fec-4761-8ed7-793bb588bb48'\u003e\u003cbr/\u003e\n\u003ctable\u003e\n    \u003ctr\u003e\n        \u003cimg width=\"50%\" src='https://github.com/shayanbo/VideoPlayerContainer/assets/5426838/b85380ff-167a-42ba-bcb5-bf563dc90e87'\u003e\n        \u003cimg width=\"50%\" src='https://github.com/shayanbo/VideoPlayerContainer/assets/5426838/4df22d15-9d1b-4fcd-b381-01d05e223552'\u003e\n    \u003c/tr\u003e\n\u003c/table\u003e\n\u003cimg src='https://github.com/shayanbo/VideoPlayerContainer/assets/5426838/40740310-1694-45f3-ac76-ad94a12019a1'\u003e\u003cbr/\u003e\n\u003cimg src='https://github.com/shayanbo/VideoPlayerContainer/assets/5426838/e567ac32-f03b-4d68-9ecd-fc808c4cae63'\u003e\u003cbr/\u003e\n\u003cimg src='https://github.com/shayanbo/VideoPlayerContainer/assets/5426838/c778bf0f-51bd-4153-bfa3-2030edc6bb82'\u003e\u003cbr/\u003e\n\n\n## Example\n\n克隆仓库之后, 打开Xcode工程, 你可以看到有很多示例项目. 你可以分别运行他们来查看这个框架提供了哪些能力, 以及它是否可以很容易得实现你的需求.\n\n* [Youtube-Example](Youtube-Example)\n* [Bilibili-Example](Bilibili-Example)\n* [TikTok-Example](TikTok-Example)\n* [SystemVideoPlayer-Example](SystemVideoPlayer-Example)\n* [VideoNavigation-Example](VideoNavigation-Example)\n* [QuickTime-Example](QuickTime-Example)\n* [VisionPro-Example](VisionPro-Example)\n* [Test-Example](Test-Example)\n  \n## 安装\n\nVideoPlayerContainer 支持多种方法的集成方式\n\n#### 使用CocoaPods\n\n使用CocoaPods 集成 VideoPlayerContainer, 你需要将下面代码放到你工程中的 `Podfile`:\n\n```\npod 'VideoPlayerContainer', :git =\u003e 'https://github.com/shayanbo/VideoPlayerContainer.git'\n```\n\n#### 使用SwiftPM\n\n在工程的 `Package.swift` 中添加如下依赖:\n\n```\ndependencies: [\n    .package(url: \"https://github.com/shayanbo/VideoPlayerContainer.git\", .upToNextMajor(from: \"1.0.0\"))\n]\n```\n\n## 核心概念\n\n### Context (上下文)\n\n`Context` 是一个核心类, 他可以被 `VideoPlayerContainer` 内所有的 `Widget` 访问到, `Context` 内部持有一个服务定位器(service locator), 提供 `Service` 之间访问的能力. 可以通过context[Service.Type]获取其他 `Service` 实例. `Context` 保证缓存的 `Service` 实例最多只有一个. 除此之外. 内置的 `Service` 提供了扩展API可以方便的获取, 比如 `context.render`, `context.control` 等.\n\n### Widget (控件)\n\n`Widget` 本身就是 `VideoPlayerContainer` 中一个 `SwiftUI` 的 `View`, 他可以访问到 `Context` 对象, 绝大多数的情况下, 会为它编写一个专门的 `Service` 对象来处于逻辑和负责Service间通讯的工作. 通常我们会在 `Widget` 中使用 `WithService` 作为根视图来访问相应的 `Service`. 这样既能使用 `Service` 提供的方法, 还会在 `Service` 的State变化的时候, 自动刷新当前 `Widget`.\n\n### PlayerWidget (播放容器控件)\n\n`PlayerWidget` 是 `VideoPlayerContainer` 提供的播放容器, 内部持有了所有了内置 `Overlay`, 也持有了所有自定义的 `Widget`. 是使用该库需要构建的核心视图.\n\n### Service (服务)\n\n`Service` 代表了两个角色. 其一: 它作为MVVM架构的ViewModel, ViewModel 处理它所属的 `Widget` 的所有的 Output和Input. 其二: 它负责和其他 `Service` 之间的通讯. 我们鼓励大家在同一个源文件中编写 `Service` 和 `Widget`. 如此一来, 我们就可以使用 `fileprivate` 和 `private` 来区分哪些API是所属Widget专享的, 哪些API是提供给其他 `Service` 使用的.\n\n事实上, 存在两种 `Service`: **Widget Service**, **Non-Widget Service**. **Widget Service** 指的是那些被特定 `Widget` 使用的 `Service` while **Non-Widget Service** 指的是那些专门给其他 `Service`s 使用的 `Service`.\n\n### Property Wrappers (属性包装器)\n\n我们内置了3个重要的属性包装器, 确保你可以编写易读并且易测的代码.\n\n* **ViewState (视图状态)**: 它类似于Combine的Published. 你可以用它来标记 `Service` 中的 `State`.\n* **StateSync (状态同步)**: 它类似于 `ViewState`, 但是它是用于同步其他 `Service` 的 `State`. 举个例子, 当你想要你的 `Widget` 会随着其他 `Service` 的某个 `State` 变化而刷新的时候, `StateSync` 就是一个很好的选择.\n* **Dependency (外部依赖)**: 它是用于 `Service` 内部引入外部依赖使用的. 我们推荐大家使用这种方式引入外部依赖, 而不是直接内部创建并持有. 用这种方式的话. 你可以很容易在将外部依赖的实现通过 `Context.withDependency(_:factory:)` 替换掉. 这对**单元测试**来说, 非常实用.\n\n### Overlay (层)\n\n`Overlay` 指的是 `PlayerWidget` 内叠加布局的子容器. 每个子容器都有专门的 `Service` 来对外提供能力. 我们一共内置了5个 `Overlay`, 从下往上依次是: render, feature, plugin, control, and toast. 除此之外, 我们也允许使用者插入自定义的 `Overlay`.\n\n![image](https://github.com/shayanbo/VideoPlayerContainer/assets/5426838/9570d129-d8c4-4ebb-ac89-b8423a10cbf1)\n\n#### Render Overlay (播控渲染层)\n\n`Render Overlay` 位于 `PlayerWidget` 的最底层. 它对外提供了播控能力. 可以访问到 `AVPlayer` 和 `AVPlayerLayer`. 除此之外. 该层还内嵌了一个 `Gesture Overlay`. 对外提供手势控制的能力. 比如 [VisionPro-Example](VisionPro-Example) 中 `PlaybackWidget` 通过 `GestureService` 实现了双击暂停和播放, 以及 `SeekBarWidget` 使用 `GestureService` 实现水平左右滑动来快进和后退.\n\n#### Feature Overlay (面板层)\n\n`Feature overlay` 用于展示面板. 这个面板可以从上下左右四个方向出现. 而且我们提供了两种样式, 一种是覆盖式的展示, 不影响其他Overlay, 比如 [QuickTime-Example](QuickTime-Example) 中的播单 `PlaylistWidget`. 另一种就是挤压式的展示, 会把所有Overlay挤压到另一侧, 比如 [Youtube-Example](Youtube-Example) `CommentWidget` 中.\n\n#### Plugin Overlay (插件层)\n\n`Plugin Overlay` 是一个没有太多规则约束的控件容器. 当你想要展示一个控件, 这个控件不太适合其他层而且你也不想插入自定义层的时候, 那这个插件层可能就比较合适, 比如视频进度拖拽的预览控件 ([QuickTime-Example](QuickTime-Example)的 `SeekBarWidget` 和 `PreviewWidget` )或者是一个某个逻辑触发之后会展示一小会的控件.\n\n#### Control Overlay (控制层)\n\n`Control Overlay` 是最复杂的一层, 也是大部分 `Widget` 所在的一层. `Control Overlay` 被划分成5个区域: `左`, `右`, `上`, `下`, and `中`. 再继续讲述之前, 我们需要先介绍一个概念叫 `Status`: \n\n我们预定义了3个 `Status` 分别是 `halfscreen`, `fullscreen` 和 `portrait`. `Status` 表达了当前 `PlayerWidget` 所处的一种状态. 这个状态的变化百分百由使用者控制. 但是通常来讲, `halfscreen` 描述的是在竖屏设备下, 视频宽度大于高度的一种状态. 这种是比较常见的, 比如在Youtube的视频播放页等. `fullscreen` 描述的是一种在横屏设备下, `PlayerWidget` 占满整个屏幕的状态, 比如Youtube的全屏模式. `portrait` 描述的是在竖屏设备下, 视频的高度大于宽度的一种状态, 比如TikTok的视频.\n\n对于这5个区域, 以及每个区域不同的 `Status`, 我们都可以分别设置需要展示的 `Widget`s 以及布局. 举个例子, 在 `halfscreen` 状态, `PlayerWidget` 的显示区域比较小, 我们没法防止太多的 `Widget`, 但是在 `fullscreen` 状态. `PlayerWidget` 占满整个屏幕, 我们可以放置更多的 `Widget` 来提供更多的常驻在屏幕上的功能.\n\n除此之外, 对于这些不同的区域, 以及每个区域的不同状态, 你还可以自定义他们的阴影, 背景, 过渡动画 以及布局等. 其他 `Service` 也可以通过 `context.control` 来触发它的展示或者隐藏, 当然这个行为依赖于开发者自己设置的 `DisplayStyle`.\n\n![image](https://github.com/shayanbo/VideoPlayerContainer/assets/5426838/421a5401-5615-435b-8fed-f6ef4d8c860c)\n\n#### Toast Overlay (提示层)\n\n`Toast Overlay` 是一个相对简单的 `Overlay`, 正如它的名字一样, 他提供了一些Toast提示的服务. 支持连续多个Toast弹出, 旧的Toast会被顶到上面. 直接N秒后自动消失. 目前这个Toast出现和消失的Transition是不对外暴露的, 限定于从左侧入, 然后淡出. 其他的都是可配置的, 比如: 展示时长, 自定义Toast等.\n\n## 使用: 添加 VideoPlayer\n\n比如说, 我们正在视频播放页里面添加一个视频播放组件. 在这, 我们要先导入 `VideoPlayerContainer`, 然后为该视频播放页创建 `Context` 实例.\n\n```swift\nimport VideoPlayerContainer\n\nstruct ContentView: View {\n    \n    @StateObject var context = Context()\n    \n    var body: some View {\n    }\n}\n```\n\n现在, 你需要创建一个 `PlayerWidget` 放置到页面上. `PlayerWidget` 是本库的主要控件容器. 内部包含所有的 `Overlay`, 也会包含我们所有自定义的控件. `PlayerWidget`需要传入一个 `Context` 实例进行初始化.\n\n```swift\nvar body: some View {\n    PlayerWidget(context)\n}\n```\n\n`PlayerWidget` 现在被添加到页面上了. 但是你看不到它, 因为我们没有做任何配置, 也没有传入视频资源让它播放. 那么, 让我们进一步完成它吧 (设置frame, 播放视频).\n\n```swift\nvar body: some View {\n    PlayerWidget(context)\n        .frame(height: 300)\n        .onAppear {\n\n            /// play video\n            let item = AVPlayerItem(url: Bundle.main.url(forResource: \"demo\", withExtension: \"mp4\")!)\n            context.render.player.replaceCurrentItem(with: item)\n            context.render.player.play()\n        }\n}\n```\n\n运行, 我们能够看到视频开始播放了. 正如你在其他app上看到的那样, 我们希望可以在上面添加一下控件, 比如: 一个播控按钮.\n\n## 使用: 编写 Widgets\n\n就像上面说的那样, 我们需要编写一个播控按钮, 然后把它放到 `PlayerWidget` 的中央. 首先, 我们需要创建一个 `SwiftUI` 源文件叫做 `PlaybackButtonWidget` 然后编写基础的UI.\n\n```swift\nstruct PlaybackButtonWidget: View {\n    var body: some View {\n    \tImage(systemName: \"play.fill\")\n            .resizable()\n            .scaledToFit()\n            .foregroundColor(.white)\n            .frame(width: 50, height: 50)\n            .disabled(!service.clickable)\n            .onTapGesture {\n                /// tap handler\n            }\n    }\n}\n```\n\n这样我们就完成了一个播控 `Widget` 的UI部分, 他展示了一个播放图标. 现在我们要把它添加到 `PlayerWidget` 内. 这里我们选择添加到 `PlayerWidget` 的 `Control层` .\n\n```swift\nvar body: some View {\n    PlayerWidget(context)\n        .frame(height: 300)\n        .onAppear {\n\n            /// add widgets to the center for halfscreen status\n            context.control.configure(.halfScreen(center)) {[\n                PlaybackButtonWidget()\n            ]}\n\n            /// play video\n            let item = AVPlayerItem(url: Bundle.main.url(forResource: \"demo\", withExtension: \"mp4\")!)\n            context.render.player.replaceCurrentItem(with: item)\n            context.render.player.play()\n        }\n}\n```\n\n现在, 你可以在 `PlayerWidget` 的中央看到这个图标. 基于 `Control` 层的默认 `DisplayStyle`, 你可以点击 `Control` 层的空白区域来让该层显示或者隐藏. 但是当你点击播放按钮的时候, 你会发现并没有事情发生. 因为我们还没有编写事件响应代码. 怎么办?\n\n当我们创建一个 `PlayerWidget` 并且传入 `Context` 实例之后, 这个 `Context` 实例会被放入Environment. 因此, 所有在 `PlayerWidget` 的控件都能够访问到这个 `Context` 实例. 相较于在 `Widget` 内直接访问 `Context`, 我们更推荐使用 `WithService` 来访问自己的 `Service`, 并且该 `Service` 的State变动会自动更新该控件.\n\n```swift\nfileprivate class PlaybackService: Service {\n    \n    private var rateObservation: NSKeyValueObservation?\n    \n    private var statusObservation: NSKeyValueObservation?\n    \n    @ViewState var playOrPaused = false\n    \n    @ViewState var clickable = false\n    \n    required init(_ context: Context) {\n        super.init(context)\n        \n        rateObservation = context.render.player.observe(\\.rate, options: [.old, .new, .initial]) { [weak self] player, change in\n            self?.playOrPaused = player.rate \u003e 0\n        }\n        \n        statusObservation = context.render.player.observe(\\.status, options: [.old, .new, .initial]) { [weak self] player, change in\n            self?.clickable = player.status == .readyToPlay\n        }\n    }\n    \n    func didClick() {\n        if context.render.player.rate == 0 {\n            context.render.player.play()\n        } else {\n            context.render.player.pause()\n        }\n    }\n}\n\nstruct PlaybackWidget: View {\n    var body: some View {\n        WithService(PlaybackService.self) { service in\n            Image(systemName: service.playOrPaused ? \"pause.fill\" : \"play.fill\")\n                .resizable()\n                .scaledToFit()\n                .foregroundColor(.white)\n                .frame(width: 50, height: 50)\n                .disabled(!service.clickable)\n                .onTapGesture {\n                    service.didClick()\n                }\n        }\n    }\n}\n```\n\n上述就是一个完整的播控 `Widget`.\n\n* 我们使用 `fileprivate` 修饰符来标记API是 `Widget` 专享的方法.\n* 我们使用 `@ViewState` 来标记那些可以触发 `SwiftUI` 刷新机制的变量 (类似于 @Published, @State).\n* 我们使用 `WithService` 作为 `Widget` 的根视图来确保任何 `@ViewState` 变量的变化都会触发整个 `Widget` 的UI刷新.\n* 在 `Widget`中, 我们使用 `@ViewState` 变量来判断哪个图片需要被展示. (角色: ViewModel's Output).\n* 我们调用 `Service` 的方法来完成 `Widget` 的工作 (角色: ViewModel's Input).\n\n## Service中的访问修饰符的使用\n\n我们鼓励使用者在同一个源文件中编写 `Widget` 和对应的 `Service`. 这样, 我们就可以在 `Service` 中充分利用访问修饰符.\n\n1. 如果你正在编写一个只被 `Widget` 使用到的 **Widget Service**, 我们推荐使用 `fileprivate` 来修饰这个 `Service` 的class. 因为它只被同一个源文件中的 `Widget` 使用. 当然, 对于那些只在 `Service` 内部使用的变量和方法, 还是需要使用 `private` 来修饰.\n2. 如果你正在编写一个需要提供给其他 `Service`s 调用的 **Widget Service**, 我们推荐使用 `internal` 或者 `public` 来修饰这个 `Service` 的class. 因为其他的 `Service`s 需要在编译期间通过 `Context` 访问到你的 `Service`. 当然, 对于那些只在 `Service` 内部使用的变量和方法, 还是需要使用 `private` 来修饰. 对于那些只在所属的 `Widget` 内使用的变量和方法, 还是需要使用 `fileprivate` 来修饰.\n3. 如果你正在编写一个 **Non-Widget Service**. 我们推荐使用 `internal` 或者 `public` 来修饰这个 `Service` 的class. 因为其他的 `Service`s 需要在编译期间通过 `Context` 访问到你的 `Service`. 当然, 对于那些只在 `Service` 内部使用的变量和方法, 还是需要使用 `private` 来修饰.\n\n## Core 目录\n\nCore目录的源文件不仅可以用于该工程. 同样地, 他也适用于大部分其他场景. 当你在创建一个复杂页面或者模块的时候. 这些文件可以让你的代码更加可读和可测.\n\n## 想法 / 缺陷 / 改进\n\n任何问题都可以在Issue板块提出, 我们会及时沟通并且共同改进😀.\n\n## 开源协议\n\nVideoPlayerContainer 是基于 MIT 协议发布的开源框架. 更多细节在 [LICENSE](LICENSE).\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fshayanbo%2Fvideoplayercontainer","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fshayanbo%2Fvideoplayercontainer","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fshayanbo%2Fvideoplayercontainer/lists"}