Site banner Colonel's Pensieve

Keeping a Video's Audio Playing When the iPhone Locks

Raw MD

A video app built on a bare AVPlayerLayer should behave like YouTube Premium: press the side button and the sound carries on, with play/pause and track buttons on the lock screen. The usual setup gets you most of the way, but locking the phone still paused playback. Sending the app to the background by switching apps did not pause it, which made the bug easy to miss.

The usual setup (necessary, not sufficient)

  • UIBackgroundModes contains audio.
  • The audio session uses the playback category and is activated when playback starts:
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .moviePlayback)
// later, right before player.play()
try AVAudioSession.sharedInstance().setActive(true)
  • player.audiovisualBackgroundPlaybackPolicy = .continuesIfPossible.

With only this, switching to another app kept the audio going, but the side button paused it.

Why locking is different

iOS pauses a player that is still attached to a visible AVPlayerLayer when the app goes to the background. The long-standing fix is to set playerLayer.player = nil while in the background. The catch: the side button takes the app to the background almost at once, and by didEnterBackgroundNotification the player has already been paused. Detaching at willResignActiveNotification is early enough.

The fix

Keep weak references to every layer showing the player, and detach them when the app stops being active:

let layers = NSHashTable<AVPlayerLayer>.weakObjects()

NotificationCenter.default.addObserver(
    forName: UIApplication.willResignActiveNotification, object: nil, queue: .main
) { _ in
    MainActor.assumeIsolated {
        guard wantsToPlay else { return }
        for layer in layers.allObjects { layer.player = nil }
    }
}
NotificationCenter.default.addObserver(
    forName: UIApplication.didBecomeActiveNotification, object: nil, queue: .main
) { _ in
    MainActor.assumeIsolated {
        for layer in layers.allObjects { layer.player = player }
    }
}

Two more details made it reliable:

  • Decide by intent, not by state. By the time SwiftUI reports scenePhase == .background, timeControlStatus may already say paused, and the lock can skip the .inactive step. A wantsToPlay flag, set by Play and cleared only by the viewer’s own pause, tells you whether to keep going.
  • Undo a system pause. If iOS still pauses with AVPlayer.RateDidChangeReason.appBackgrounded, resume right away. Read the reason as a plain string before hopping to the main actor (device builds reject sending the non-Sendable Notification across):
NotificationCenter.default.addObserver(
    forName: AVPlayer.rateDidChangeNotification, object: player, queue: .main
) { note in
    let reason = (note.userInfo?["AVPlayerRateDidChangeReasonKey"] as? NSString).map(String.init)
    MainActor.assumeIsolated {
        guard player.rate == 0,
              reason == AVPlayer.RateDidChangeReason.appBackgrounded.rawValue,
              wantsToPlay else { return }
        player.play()
    }
}

Lock screen controls

MPNowPlayingInfoCenter holds the title, duration and elapsed time, and MPRemoteCommandCenter handles play, pause, next and previous. One trap with Swift 6: MediaPlayer calls the artwork closure (and may call command handlers) off the main thread. A closure created inside @MainActor code inherits that isolation and crashes at runtime with dispatch_assert_queue_fail. Build them in a nonisolated static function:

private nonisolated static func loadArtwork() -> MPMediaItemArtwork? {
    guard let image = UIImage(named: "Artwork") else { return nil }
    return MPMediaItemArtwork(boundsSize: image.size) { _ in image }
}

Testing notes

  • Switching apps in the Simulator does not reproduce the lock path. Test the side button on a real device.
  • Build for generic/platform=iOS too: the device build checks Swift concurrency more strictly than the Simulator build did.
  • Logging each rate change with its reason (os.Logger, filtered in Console.app) shows exactly what paused the player.

References

← Back to home