SwiftUI Play YouTube Video
Reading time: 6 min
This recipe shows how to play a YouTube video in SwiftUI.
The end result looks like this:
This component is available as a Swift Package in this repo.
This recipe shows only a part of the YouTubePlayer
component's functionality, namely that to load a video, play and pause it. This is to keep the recipe short and to the point, illustrating the most important facets. The component itself offers 15 different actions and multiple state updates, as well as the ability to enqueue playlists.
The Web View
Playing YouTube videos boils down to loading specific HTML code into a WebView, and then evaluating various JavaScript commands to, e.g, play or pause the video. Fortunately, such a component already exists - SwiftUI WebView perfectly fits the purpose (and you can learn more about it this recipe).
Let's start by adding the basic HTML code to load, as well as the ability to render it in the WebView:
import SwiftUIWebView
struct YouTubePlayer: View {
// Config of the web view custom-tailored for YouTube video playing
private static let webViewConfig = WebViewConfig(javaScriptEnabled: true,
allowsBackForwardNavigationGestures: false,
allowsInlineMediaPlayback: true,
mediaPlaybackRequiresUserAction: false,
isScrollEnabled: false,
isOpaque: true,
backgroundColor: .clear)
// This is the basic HTML we'll load in the WebView and fill with video ID
private static let playerHTML = """
<!DOCTYPE html>
<html>
<head>
<style>
* { margin: 0; padding: 0; }
html, body { width: 100%; height: 100%; }
</style>
</head>
<body>
<div id="player"></div>
<script src="https://www.youtube.com/iframe_api"></script>
<script>
var player;
YT.ready(function() {
player = new YT.Player('player', %@);
window.location.href = 'ytplayer://onYouTubeIframeAPIReady';
});
function onReady(event) {
window.location.href = 'ytplayer://onReady?data=' + event.data;
}
function onStateChange(event) {
window.location.href = 'ytplayer://onStateChange?data=' + event.data;
}
function onPlaybackQualityChange(event) {
window.location.href = 'ytplayer://onPlaybackQualityChange?data=' + event.data;
}
function onPlayerError(event) {
window.location.href = 'ytplayer://onError?data=' + event.data;
}
</script>
</body>
</html>
"""
@State private var webViewAction = WebViewAction.idle
@State private var webViewState = WebViewState.empty
public var body: some View {
WebView(config: YouTubePlayer.webViewConfig,
action: $webViewAction,
state: $webViewState)
}
}
Modelling the behaviour
Just like in the WebView recipe, we'll use the action/state binding model to model YouTubePlayer
's behaviour:
- action that is changed externally and informs the view to update itself: e.g, you'd set its value to
.play
to have the view start or resume playing a video. - state that is changed internally and tells the rest of the app to update accordingly: e.g, the player can inform the rest of the app of if it's currently loading a video, what quality is it playing in, etc. This is easier to code and maintain than having a larger number of bindings for each individual state property.
Here are the action and state models for YouTubePlayer
. YouTubePlayerAction
captures the commands you can issue to the player, while YouTubePlayerState
contains all the information that the player publishes about its internal state.
enum YouTubePlayerAction {
case idle, // idle is always needed as actions need an empty state
loadID(string),
play,
pause
}
enum YouTubePlayerStatus: String, Equatable {
case unstarted = "-1",
ended = "0",
playing = "1",
paused = "2",
buffering = "3",
queued = "4"
}
struct YouTubePlayerState: Equatable {
public internal(set) var ready: Bool
public internal(set) var status: YouTubePlayerStatus
public static let empty = YouTubePlayerState(ready: false,
status: .unstarted)
}
Loading a Video
To implement the loadID
command, first add this method to YouTubePlayer
that injects the video ID into playerHTML
:
private func loadVideo(id: String) {
var params = ["height": "100%" as AnyObject,
"width": "100%" as AnyObject,
"events": ["onReady": "onReady" as AnyObject,
"onStateChange": "onStateChange" as AnyObject,
"onPlaybackQualityChange": "onPlaybackQualityChange" as AnyObject,
"onError": "onPlayerError" as AnyObject
] as AnyObject,
"playerVars": playerVars as AnyObject,
"videoId": id as AnyObject
] as AnyObject
let rawHTMLString = YouTubePlayer.playerHTML
let jsonParameters = try? String(data: JSONSerialization.data(withJSONObject: params, options: .prettyPrinted), encoding: .utf8)
let htmlString = rawHTMLString.replacingOccurrences(of: "%@", with: jsonParameters)
webViewAction = .loadHTML(htmlString)
}
Then, add the @Binding
s to capture player action and state to the top of YouTubePlayer
declaration:
@Binding var action: YouTubePlayerAction
@Binding var state: YouTubePlayerState
Lastly, add this onChange
modifier to the WebView
in the body
, so that we can respond to action changes:
WebView(config: YouTubePlayer.webViewConfig,
action: $webViewAction,
state: $webViewState)
.onChange(of: action) { value in
if value == .idle {
return
}
switch value {
case .idle:
break
case .loadID(let id):
loadVideo(id: id)
default:
break
}
action = .idle // always reset the action after consuming it
}
Playing and pausing
Playing and pausing videos involves evaluating JavaScript code within the WebView
. Start off by adding this helper method to YouTubePlayer
that does exactly that:
private func evaluatePlayerCommand(_ command: String, callback: ((Any?) -> Void)? = nil) {
let fullCommand = "player.\(command);"
webViewAction = .evaluateJS(fullCommand) { result in
switch result {
case .success(let value):
callback?(value)
case .failure(let error):
if (error as NSError).code == 5 { // ignore Void return
callback?(nil)
} else {
onError(error)
}
}
}
}
Now you can update the switch
in onChange
to handle play
and pause
actions:
case .play:
evaluatePlayerCommand("playVideo()")
case .pause:
evaluatePlayerCommand("pauseVideo()")
Publishing state updates
The last thing we'll do is to let other views know the state of our YouTubePlayer
. This is a tad tricky as it requires us to intercept when the WebView
loads a URL with scheme ytplayer, find a certain query component in its URL and then update the state based on it.
To start off, add this enum to capture all the events we'll monitor:
enum YouTubePlayerEvents: String, Equatable {
case ready = "onReady",
statusChange = "onStateChange"
}
Then, add this extension method to extract query key-value pairs from a URL:
private extension URL {
var queryStringComponents: [String: AnyObject] {
var dict = [String: AnyObject]()
if let query = self.query {
for pair in query.components(separatedBy: "&") {
let components = pair.components(separatedBy: "=")
if (components.count > 1) {
dict[components[0]] = components[1] as AnyObject?
}
}
}
return dict
}
}
Now, for the main part, add a custom scheme handler to your WebView
that will handle custom YouTube player URLs, extract data from them and update the state:
WebView(config: YouTubePlayer.webViewConfig,
action: $webViewAction,
state: $webViewState,
schemeHandlers: ["ytplayer": { url in
let data = eventURL.queryStringComponents()["data"] as? String
if let host = eventURL.host,
let event = YouTubePlayerEvents(rawValue: host) {
switch event {
case .ready:
var newState = state
newState.ready = true
state = newState
case .statusChange:
if let newStatus = YouTubePlayerStatus(rawValue: data!) {
var newState = state
newState.status = newStatus
state = newState
}
}
}
}])
.onChange(of: action) { value in
// ...
}
Sample usage
struct YouTubeTest: View {
@State private var action = YouTubePlayerAction.idle
@State private var state = YouTubePlayerState.empty
private var buttonText: String {
switch state.status {
case .playing:
return "Pause"
case .unstarted, .ended, .paused:
return "Play"
case .buffering, .queued:
return "Wait"
}
}
var body: some View {
VStack {
HStack {
Button("Load") {
action = .loadID("v1PBptSDIh8")
}
Button(buttonText) {
if state.status != .playing {
action = .play
} else {
action = .pause
}
}
}
YouTubePlayer(action: $action, state: $state)
Spacer()
}
}
}