VideoPlayerContainer is a video player component for SwiftUI. Compared with the system built-in one VideoPlayer, the VideoPlayerContainer provides much more flexible and extendable features that are able to cover most of the common scenarios that you can see on the app such as Tik Tok or Youtube.
After cloning the repo and opening up the Xcode project, you can see multiple schemes as examples. Run it respectively to feel what abilities this framework can offer and how easy to use this framework to meet your demands.
- Youtube-Example
- Bilibili-Example
- TikTok-Example
- SystemVideoPlayer-Example
- VideoNavigation-Example
- QuickTime-Example
- VisionPro-Example
- Test-Example
VideoPlayerContainer supports multiple methods for installing the library in a project.
To integrate VideoPlayerContainer into your Xcode project using CocoaPods, specify it in your Podfile
:
pod 'VideoPlayerContainer', :git => 'https://github.com/shayanbo/VideoPlayerContainer.git'
Once you have your Swift package set up, adding VideoPlayerContainer as a dependency is as easy as adding it to the dependencies value of your Package.swift.
dependencies: [
.package(url: "https://github.com/shayanbo/VideoPlayerContainer.git", .upToNextMajor(from: "1.0.0"))
]
Context is the core class and is fully accessible from all of Widget
s in the VideoPlayerContainer
, it holds a service locator which we can use to fetch other Service
s to borrow expertise from other Widget
s. Adapters can access other Service
instance by context[Service.type]
. Context
cache at a maximum of one Service
instance for each Service Type
. Besides, the built-in Service
can be accessible by handy way such as context.render
, context.control
and so on.
Widget
is literally a SwiftUI View that's inside the VideoPlayerContainer
which means it can access the Context
and in most cases, it has a specific Service
to handle all of its logic code and to communicate with other Service
s. Generally, we use WithService
as the root view of the Widget
to access Service
instance in Widget
. This way, not only can we access Service
's APIs, but also the Widget
updates upon the State
s of Service
changes.
PlayerWidget
is the container which holding all of built-in Overlay
s and also the customized Widget
s. It's the core View of VideoPlayerContainer
.
Service
represents two roles, one is the ViewModel in MVVM architecture, ViewModel handles all of the Output and Input for View. Another role is responsible for communicating with other Service
s. We encourage people to write Service
and Widget
in one source file. This way, we can use fileprivate
, and private
to distinguish which APIs are used only for its Widget
and which APIs are open to other Service
s.
Actually, there're two kinds of Service
: Widget Service, Non-Widget Service. Widget Service is the Service
used by a specific Widget
while Non-Widget Service is the Service
used by other Service
s.
There're three property wrappers provided to enable you author readable and testable code.
- ViewState: It's like the built-in Published. you can use it to mark state defined in
Service
. - StateSync: It's like
ViewState
, but it's used to sync state from otherService
. For example, when you want yourWidget
to refresh itself when otherService
's state changes, this is a right propertyWrapper to use. - Dependency: It's used for
Service
to introduce external abilities. In this way, you can easily change its implementation by callingContext.withDependency(_:factory:)
. This's really useful for Unit Test.
Overlay
is the sub-container inside the VideoPlayerContainer
layer by layer and it's the place where widgets sit. We have 5 built-in overlays, from bottom to top, these are render
, feature
, plugin
, control
, and toast
. In addition, we allow adopters to insert their own Overlay
s.
Render overlay
is sitting at the far bottom of the container. It provides playback service and gesture services. Adapters can access AVPlayer
and AVPlayerLayer
instance. Besides, there's one overlay called GestureOverlay
embbed in the Render Overlay
. It provides the control over gestures. For example, PlaybackWidget
in VisionPro-Example support double-tap to pause and play by using GestureService
, and SeekbarWidget
support dragging horizontally to control the progress by using GestureService
.
Feature overlay
is used to slide in and out a panel from 4 directions (left
, right
, top
, bottom
). We provide two styles as well: cover
or squeeze
. cover
literally display panel without having any affect on other Overlay
s like QuickTime-Example's PlaylistWidget
, while squeeze
display panels with squeezing Overlay
s to other side. Youtube-Example's CommentWidget
.
Plugin Overlay
is a sub-container without many constraints on it. When you want to show up a widget that's not suitable for other overlays and you don't want to insert your own custom overlay, that's the right place for you, like a thumbnail preview widget for the seek bar on dragging (QuickTime-Example's SeekbarWidget
and PreviewWidget
) or a simple widget that's visible only in a short time after being triggered.
Control overlay
is the most sophisticated overlay and the place where most work will be done. The Control Overlay
is divided into 5 parts: left
, right
, top
, bottom
, and center
. Before going on, please allow me introduce a concept called Status
:
We predefined 3 statuses describing the environment of PlayerWidget
. These are halfscreen
, fullscreen
, and portrait
. The status changes are 100% decided by you. But generally, halfscreen
describes the status of the portrait device that video's width is greater than it's height. fullscreen
describes the landscape device that PlayerWidget
fill up the whole screen, and portrait
describes the status of the portrait device that the video's height is greater than the width.
For these 5 parts, you can configure them for different statuses which is quite common. For example, in halfscreen
status, the screen is small and we can't attach many widgets to it but in fullscreen status. The video player container makes up the whole screen. We can attach many widgets to it to provide more and more functions.
For these parts, for these statuses, you can customize their shadow, transition, and layout. and other services can fetch the ControlService
by context.control
to call present or dismiss programmatically depending on the DisplayStyle
configured.
Toast Overlay
is a relatively simple Overlay
that you can use to pop up view on the left side which will be disappeared after a few seconds configured. It supports a few customization like customizing the Toast Widget.
Let's say, we are going to author a player view in a video scene, here. We need to import VideoPlayerContainer
, and create a Context
for the player view or the whole video scene.
import VideoPlayerContainer
struct ContentView: View {
@StateObject var context = Context()
var body: some View {
}
}
Now, you need to create the PlayerWidget
to make it visible on the scene. It's the main container holding all of Overlays
and Widgets
. It requires a context instance to initialize it.
var body: some View {
PlayerWidget(context)
}
The PlayerWidget
is now attached to the scene. But you can't see it because we never do any configuration work and also don't pass the video resource item to play. Let's do some more work (specify the frame and play a video).
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()
}
}
Run it, and the video will be playing. Now, as you can see in other apps. We want to attach some widgets to it, like a button in the center to play or pause the video.
As I said above, we need to write a playback control button and attach it to the center of the PlayerWidget
. First of all, we need to create a SwiftUI source file named PlaybackWidget
and author some basic UI code.
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
}
}
}
This is a view showing a Play icon. Now, we need to attach it to the PlayerWidget
. Here, we add it to the Control Overlay
.
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()
}
}
Now, you can see an play icon in the center. Based on default DisplayStyle
which is auto
, you can tap the blank area to hide or show the Control Overlay
. However, when you tap the play icon, you will find nothing happens since we don't populate the logic code to make the Widget
work as expected (play and pause). How?
When we created the PlayerWidget
and passed in the Context
instance, the Context
instance will be put in the environment. Thus, all of the Widget
s inside the PlayerWidget
will have access to the Context
. Instead of accessing Context
directly inside the Widget
, we prefer using WithService
as the root View of the Widget
to access the Service
instance. It offers an abilities that get the Widget
update when the State
of Service
changes.
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)
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
}
}
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()
}
}
}
}
As you can see above, it's a completed Widget
.
- We use
fileprivate
modifier to mark APIs that's only available for its belongingWidget
. - We use
@ViewState
to mark the variable that's able to trigger theSwiftUI
update mechanism (like @Published, @State). - We use
WithService
as theWidget
's root View to make sure any@ViewState
variable changes will make the wholeWidget
involved in the update mechanism. - We use
@ViewState
variable to condition which image to use in the Widget. (ViewModel's Output). - We call
Service
method to complete theWidget
's work (ViewModel's Input).
We encourage adopters to author Widget
and its Service
in the same source file. In this way, we can make full use of access modifiers on Service
.
- If you are creating a Widget Service that is only used by its
Widget
,fileprivate
is better to modify theService
class. Since it's only able to be accessed by theWidget
in the same source file. Also, keep usingprivate
to modify those properties and methods that are used only inside theService
. - If you are creating a Widget Service that offers some API for other
Service
s,internal
orpublic
is better to modify theService
class. Since otherService
s have to access yourService
Type in the compilation time. Also, keep usingprivate
to modify those properties and methods that are used only inside theService
and usingfileprivate
to modify those properties and methods that are used only by itsWidget
. - If you are creating a Non-Widget Service that offers some API for other
Service
s,internal
orpublic
is better to modify theService
class. Since otherService
s have to access yourService
Type in the compilation time. Also, keep usingprivate
to modify those properties and methods that are used only inside theService
.
The source files in the Core folder are not only fitting for this project. but also most of the other requirements. When you are creating a complex scene or module. These core files are really useful and able to make your code more readable and testable.
Feel free to report issues and let's improve it together 😀.
VideoPlayerContainer is released under the MIT license. See LICENSE for details.