AVPlayer & SwiftUI Part 2: Player Controls

Chris Mash
5 min readJun 6, 2019

Update: Check out part 5 for SwiftUI’s own VideoPlayer view in iOS 14!

This article was written against Xcode 11 beta 3

Note: video playback on the simulator seems to not show the video, just play the audio, so use a real device if necessary!

In my last article I showed how to display a video in SwiftUI with AVPlayer. If you want to show a video to your users there’s a fair chance you’ll want them to be able to control the video in some way too, perhaps pausing playback or seeking to a different point.

So, building on the code we ended up with at the end of the last article, let’s see what we need to do to get some basic controls.

First, some tidy up

Firstly I’d like to address a shortcoming of where I left the code in the last article. The PlayerUIView class had a hardcoded URL in it for the video, which is pretty poor, so let’s fix that by instead creating the player outside the PlayerView.

Update ContentView’s body to pass a player into PlayerView:

var body: some View {
PlayerView(player: AVPlayer(url: URL(string: "https://bitdash-a.akamaihd.net/content/sintel/hls/playlist.m3u8")!))
}

Now update PlayerView to pass the player through to PlayerUIView:

struct PlayerView: UIViewRepresentable {
let player: AVPlayer
func updateUIView(_ uiView: UIView, context: UIViewRepresentableContext<PlayerView>) { } func makeUIView(context: Context) -> UIView {
return PlayerUIView(player: player)
}
}

Now update PlayerUIView’s initialiser to accept an AVPlayer (let’s just get rid of the frame parameter we had before, we don’t need it anyway!):

init(player: AVPlayer) {
super.init(frame: .zero)
playerLayer.player = player
layer.addSublayer(playerLayer)
}

Great, now we’ve tidied things up a bit we can move on to implementing some controls!

Some preparation

Let’s start by making a couple of new View structs: a PlayerControlsView and a PlayerContainerView, which will contain both the PlayerView and the PlayerControlsView:

struct PlayerContainerView : View {
private let player: AVPlayer
init(player: AVPlayer) {
self.player = player
}
var body: some View {
VStack {
PlayerView(player: player)
PlayerControlsView(player: player)
}
}
}

Nice, we can now replace ContentView’s body with the following:

var body: some View {
PlayerContainerView(player: AVPlayer(url: URL(string: "https://bitdash-a.akamaihd.net/content/sintel/hls/playlist.m3u8")!))
}

And if we throw in a struct to represent PlayerControlsView we’ll be able to compile and run:

struct PlayerControlsView : View {
let player: AVPlayer
var body: some View {
Text("TODO")
}
}

What you may not have noticed is that we no longer have a call to player.play() anywhere, so we just get a useless black box where the video should be.

A Play/Pause button

Let’s remedy that with a Play/Pause button! Update PlayerControlsView with the following:

struct PlayerControlsView : View {
@State var playerPaused = true
let player: AVPlayer var body: some View {
Button(action: {
self.playerPaused.toggle()
if self.playerPaused {
self.player.pause()
}
else {
self.player.play()
}
}) {
Image(systemName: playerPaused ? "play" : "pause")
}
}
}

What we’ve got here is a state var that we can use to trigger changes in the UI whenever we want to play/pause the video, thus allowing us to automatically update the image on the play/pause button.

Give that a run and when you tap the play button the video will begin playing (albeit after a fair amount of blackness, thanks Sintel…) and the button will change to the pause icon. Tapping it again will pause the video and change back to the play icon.

Seeking

Next up we’ll use a Slider to allow the user to seek to a certain point in the video. We’ll need a seek position state in order to get the value of the slider, so add this to the top of your PlayerControlsView struct:

@State var seekPos = 0.0

Now here’s a look at the Slider we want to create:

Slider(value: $seekPos, from: 0, through: 1, onEditingChanged: { _ in
guard
let item = self.player.currentItem else {
return
}
let targetTime = self.seekPos * item.duration.seconds
self.player.seek(to: CMTime(seconds: targetTime, preferredTimescale: 600))
})

We bind the Slider’s value to our seekPos state and tell it we want the min value to be 0 and the max value to be 1. We also specify an onEditingChanged closure, which will get called when the value changes. I have no idea what the parameter into that closure is, Apple’s docs currently say nothing about it, just that it’s a Bool…

In the closure we grab the currentItem from the player (if there is one) and then calculate what time we need to seek to based on the value from the slider (aka self.seekPos) and the duration of the video. Then we do the seek!

To get this View into our PlayerControlsView we’ll need to use an HStack to put it next to the Play/Pause button. We’ll also add some padding either side of the Play/Pause button and at the end of the Slider to make everything look just right:

struct PlayerControlsView : View {
@State var playerPaused = true
@State var seekPos = 0.0
let player: AVPlayer var body: some View {
HStack {
Button(action: {
self.playerPaused.toggle()
if self.playerPaused {
self.player.pause()
}
else {
self.player.play()
}
}) {
Image(systemName: playerPaused ? "play" : "pause")
.padding(.leading, 20)
.padding(.trailing, 20)
}
Slider(value: $seekPos, from: 0, through: 1, onEditingChanged: { _ in
guard
let item = self.player.currentItem else {
return
}

let targetTime = self.seekPos * item.duration.seconds
self.player.seek(to: CMTime(seconds: targetTime, preferredTimescale: 600))
})
.padding(.trailing, 20)
}
}
}

Wunderbar! Give that a run and you’ll be able to seek through the video to whichever point you want to watch.

A seek bar should also be a progress bar

What we really want the seek bar to do is to move along in time with the video’s progress so we know how far we’ve got through the video. That should be easy enough by adding an initialiser for PlayerControlsView like so:

init(player: AVPlayer) {
self.player = player
player.addPeriodicTimeObserver(forInterval: CMTime(seconds: 0.5, preferredTimescale: 600), queue: nil) { time in
guard let item = self.player.currentItem else {
return
}
self.seekPos = time.seconds / item.duration.seconds
}
}

Creating a periodic time observer means we can get alerted to the fact that the player’s progress has changed and update our seekPos var to reflect that, which should then in turn update the Slider.

Unfortunately this produces a ‘Segmentation fault: 11’ error when compiling, which isn’t ideal. I’ve raised this with Apple, perhaps some teething problems with the beta or perhaps I’m doing something stupid (though a crash in the compiler certainly isn’t right!).

Not the best way to end an article but for now that’s all I’ve got to share!

Update: Part 3 is available now!

--

--

Chris Mash

iOS developer since 2012, previously console games developer at Sony and Activision. Twitter: @CJMash