+++
title = "Keeping a Video's Audio Playing When the iPhone Locks"
date = 2026-10-14T00:00:00Z
author = "colosieve"
tags = ["ios", "avfoundation", "swiftui", "background-audio"]
draft = false
+++

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:

```swift
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:

```swift
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):

```swift
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:

```swift
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

- [Apple: Configuring your app for media playback](https://developer.apple.com/documentation/avfoundation/configuring-your-app-for-media-playback)
- [Apple Developer Forums: iOS 14 play audio from video in background](https://developer.apple.com/forums/thread/661694)
