Keeping a Video's Audio Playing When the iPhone Locks
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)
UIBackgroundModescontainsaudio.- 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,timeControlStatusmay already say paused, and the lock can skip the.inactivestep. AwantsToPlayflag, 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-SendableNotificationacross):
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=iOStoo: 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.