Reading time: 6 min

This recipe shows how to play a YouTube video in SwiftUI. The end result looks like this:

Preview iOS

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:

  1. 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.
  2. 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 @Bindings 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()
    }
  }
}

Next Post Previous Post