Playing Videos in SwiftUI

As of WWDC 2020, we have a way to play videos in SwiftUI without bailing out to UIKit with UIViewRepresentable. At first glance, it’s pretty simple, as well:

import SwiftUI
import AVKit

struct VideoPlayerView: View {
let videoURL: URL

var body: some View {
let player = AVPlayer(url: videoURL)
VideoPlayer(player: player)
}
}

Et voila, you’re playing a video! You can overlay content on top of the video player pretty easily:

import SwiftUI
import AVKit

struct VideoPlayerView: View {
let videoURL: URL

var body: some View {
let player = AVPlayer(url: videoURL)
VideoPlayer(player: player) {
Text("Watermark")
}
}
}

Seems like we’re good to go, no?

Well, not quite. Let’s talk memory management.

VideoPlayerView is a struct – it’s immutable. SwiftUI allows us to mutate the state of our views with user interaction using things like @State, thanks to some Compiler Magic.

Every time some aspect of the state changes, SwiftUI calls the body getter again.

Spotted the catch yet?

We’re declaring the AVPlayer instance inside the body getter. That means it gets reinitalized every time body gets called. Not the best for something that’s streaming a video file over a network.

But wait, we’ve already mentioned the Compiler Magic we can use to persist state: @State! Let’s try:

import SwiftUI
import AVKit

struct VideoPlayerView: View {
let videoURL: URL
@State var player = AVPlayer(url: videoURL)

var body: some View {
VideoPlayer(player: player)
}
}

Whoops. We’ve got a problem – self isn’t available during initialization, so we can’t initialize the AVPlayer like that. Alright, we’ll write our own init:

import SwiftUI
import AVKit

struct VideoPlayerView: View {
let videoURL: URL
@State var player: AVPlayer

init(videoURL: URL) {
self.videoURL = videoURL
self._player = State(initialValue: AVPlayer(url: videoURL))
}

var body: some View {
VideoPlayer(player: player)
}
}

(I suppose we could drop the let videoURL: URL there, since we’re using it immediately instead of needing to store it, but for consistency’s sake I’m leaving it in.)

Okay, sounds good – except, hang on, @State is only intended for use with structs, and if we peek at AVPlayer it’s a class.

Okay, no worries, that’s what @StateObject is for, one more tweak:

import SwiftUI
import AVKit

struct VideoPlayerView: View {
let videoURL: URL
@StateObject var player: AVPlayer

init(videoURL: URL) {
self.videoURL = videoURL
self._player = StateObject(wrappedValue: AVPlayer(url: videoURL))
}

var body: some View {
VideoPlayer(player: player)
}
}

There, we should be good to go now, right? Right?

Alas, the compiler says no. AVPlayer doesn’t conform to ObservableObject, so we’re out of luck.

Fortunately, ObservableObject is pretty easy to conform to, and we can make our own wrapper.

import SwiftUI
import AVKit
import Combine

class PlayerHolder: ObservableObject {
let player: AVPlayer
init(videoURL: URL) {
player = AVPlayer(url: videoURL)
}
}

struct VideoPlayerView: View {
let videoURL: URL
@StateObject var playerHolder: PlayerHolder

init(videoURL: URL) {
self.videoURL = videoURL
self._player = StateObject(wrappedValue: PlayerHolder(videoURL: videoURL))
}

var body: some View {
VideoPlayer(player: playerHolder.player)
}
}

Phew. At long last, we’ve got a stable way to hold onto a single AVPlayer instance. And, as a bonus, we can do stuff with that reference:

import SwiftUI
import AVKit
import Combine

class PlayerHolder: ObservableObject {
let player: AVPlayer
init(videoURL: URL) {
player = AVPlayer(url: videoURL)
}
}

struct VideoPlayerView: View {
let videoURL: URL
@StateObject var playerHolder: PlayerHolder

init(videoURL: URL) {
self.videoURL = videoURL
self._player = StateObject(wrappedValue: PlayerHolder(videoURL: videoURL))
}

var body: some View {
VideoPlayer(player: playerHolder.player)
.onAppear {
playerHolder.player.play()
}
}
}

Will start playing the video as soon as the view opens. Similarly, you could add some Buttons, build your own UI on top of the video player, all sorts of fun.

And, from the other end, you can put more logic in the PlayerHolder, as well. Say you need some additional logic to get from a video ID to the actual URL that the AVPlayer can handle? Try something like this:

class PlayerHolder: ObservableObject {
@Published var player: AVPlayer? = nil
init(videoID: Video.ID) {
NetworkLayer.shared.lookupVideoInformation(videoID) { result in
self.player = AVPlayer(url: result.url)
}
}
}

(Now, that’s not the nice, Combine-y way to do it, but I’ll leave that as an exercise for the reader. Or possibly another post.)