Resuming AVPlayer after being interrupted

As part of a pet-project, I've been building a media player which internally uses AVPlayer. One of my goals is to build a component that could potentially easily be re-used while it handles most of the groundwork around AVPlayer, such as (but not limited to) retrieving metadata, progress info, and handling interruptions. While working on the latter, I ran into a little snag, which made me write this post in the hopes of maybe helping someone who might encounter the same issue.

Enabling background audio

If you're building an audio player, you'll most likely want to support playing audio in the background. In order for your application to do so, you need to switch on Background Modes via your target's Capabilities tab in Xcode and select Audio, AirPlay and Picture in Picture, or manually add an audio entry to the UIBackgroundModes entry in your Info.plist. Furthermore, you'll need to activate AVAudioSession.

try? AVAudioSession.sharedInstance().setCategory(AVAudioSessionCategoryPlayback, mode: AVAudioSessionModeDefault)  
try? AVAudioSession.sharedInstance().setActive(true)  

If you start playing audio via your AVPlayer instance and background your app, audio should continue to play. Hurray! Onto handling interruptions.

Handling interruptions

An interruption in iOS land can be anything from an incoming call to a different app starting media playback. It's your responsibility to handle these scenarios correctly. Luckily, this isn't too difficult.

First, you'll need to register an observer for the NSNotification.Name.AVAudioSessionInterruption notification.

NotificationCenter.default.addObserver(self, selector: #selector(handleInterruption(notification:)), name: .AVAudioSessionInterruption, object: nil)  

Second, you'll need to implement the method that will be called on your observer when the notification is fired:

@objc func handleInterruption(notification: Notification) {
    guard let userInfo = notification.userInfo,
        let typeInt = userInfo[AVAudioSessionInterruptionTypeKey] as? UInt
        let type = AVAudioSessionInterruptionType(rawValue: typeInt) else {
            return
    }

    switch type {
    case .began:
        // Pause your player

    case .ended:
        if let optionInt = userInfo[AVAudioSessionInterruptionOptionKey] as? UInt {
            let options = AVAudioSessionInterruptionOptions(rawValue: optionInt)
            if options.contains(.shouldResume) {
                // Resume your player
            }
        }
    }
}

This notification is actually fired twice; once when the interruption begins and once when the interruption has ended. Hence, you need to check for which scenario you're receiving the notification and handle both cases appropriately.

For the latter case, the notification's userInfo dictionary contains an additional value indicating whether or not playback inside your application should be resumed. For instance, when playback is interrupted because of an incoming call, this value will usually indicate you should resume playback. If the reason for the interruption is because the user switched to a different app and started playback of some other media, this will usually indicate playback should not be resumed.

Now, onto the thing that made me actually write this post...

Background interruptions

So far, everything worked as you'd expect. When a call came in, audio would fade out nicely before receiving the notification, which made the player pause. When the call finished, audio playback would resume. However, when the application would run in the background, playback would not resume after the call had finished, despite the notification's payload indicating it should.

While scouring Apple's documentation on AVPlayer, I came across the following note.

Note

Starting in iOS 10, the system will deactivate the audio session of most apps in response to the app process being suspended. When the app starts running again, it will receive an interruption notification that its audio session has been deactivated by the system. This notification is necessarily delayed in time because it can only be delivered once the app is running again. If your app's audio session was suspended for this reason, the userInfo dictionary will contain the AVAudioSessionInterruptionWasSuspendedKey key with a value of YES.

If your audio session is configured to be non-mixable (the default behavior for the AVAudioSessionCategoryPlayback, AVAudioSessionCategoryPlayAndRecord, AVAudioSessionCategorySoloAmbient, and AVAudioSessionCategoryMultiRoute categories), it's recommended that you deactivate your audio session if you're not actively using audio when you go into the background. Doing so will avoid having your audio session deactivated by the system (and receiving this somewhat confusing notification).

After inspecting the notification payload, it did not seem to include an entry for the AVAudioSessionInterruptionWasSuspendedKey key, so I was still kind of baffled as to why this would happen.

But then it hit me; usually, a backgrounded media app can be controlled from both the lockscreen and command center via an interface provided by the system, which is driven by the app if it implements MPRemoteCommandCenter. I hadn't yet implemented this functionality for my player, but I figured maybe the resume command sent by the system would use the mechanism, too. To test this theory, I added the following bit code to my player:

let center = MPRemoteCommandCenter.shared()

center.playCommand.addTarget { event -> MPRemoteCommandHandlerStatus in  
    switch self.state {
    case .ready, .paused:
        self.play()
        return .success
    case .initial, .stopped:
        return .noActionableNowPlayingItem
    case .buffering, .failed, .playing:
        return .commandFailed
    }
}

center.pauseCommand.addTarget { event -> MPRemoteCommandHandlerStatus in  
    switch self.state {
    case .playing:
        self.pause()
        return .success
    case .initial, .stopped:
        return .noActionableNowPlayingItem
    case .ready, .paused, .buffering, .failed:
        return .commandFailed
    }
}

And that seemed to do the trick! Even when the app was playing in the background and playback was interrupted due to an incoming call, it would now resume after that call had finished, and everything was good in the world again. Right? Well, sort of. As it turns out, my theory was only half-right.

While setting a breakpoint in either method, I noticed none of the two methods was actually called after an interruption. It seemed that while adding support for MPRemoteCommandCenter did fix this issue, ultimately it was more of a side effect rather than the implementation itself. Basically, any application that hooks into MPRemoteCommandCenter seems to automatically register itself for receiving background events, even if the application only implements a single command that isn't used for controlling actual playback. While playback continuation after an interruption isn't actually handled by MPRemoteCommandCenter, it does seem to rely on the same registration for background events.

Before the introduction of MPRemoteCommandCenter, applications needed to explicitly register and unregister for receiving such events. My suspicion was that MPRemoteCommandCenter basically does the same thing under the hood. To confirm this suspicion, I made sure my application was unregistered from MPRemoteCommandCenter, uncommented the last bit of code and did a fresh run, which broke the functionality again. After that, I added the following line of code to my app delegate:

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {  
    application.beginReceivingRemoteControlEvents()

    return true
}

This indeed worked, too. The documentation on that method even explicitly states that MPRemoteCommandCenter handles this part for you and you really shouldn't call this method yourself, so you're probably better off sticking with MPRemoteCommandCenter.

In iOS 7.1 and later, use the shared MPRemoteCommandCenter object to register for remote control events. You do not need to call this method when using the shared command center object.

This method starts the delivery of remote control events using the responder chain. Remote-control events originate as commands issued by headsets and external accessories that are intended to control multimedia presented by an app. To stop the reception of remote-control events, you must call endReceivingRemoteControlEvents().

So there you have it. Handling interruptions for your media application is pretty straightforward, though it does take a bit of extra effort to make it work correctly for backgrounded applications. Hopefully, this was of help to someone. If not, at least we learned that I'm really bad at writing concise blog posts 🤷‍♂️

Erik van der Wal

Erik van der Wal

I love building things with Swift, Objective-C, Ruby and tinkering with technologies like Golang and Elixir.

  • The Netherlands
comments powered by Disqus