Now Playing Integration Guide
Purpose: Prevent the 4 most common Now Playing issues on iOS 18+: info not appearing, commands not working, artwork problems, and state sync issues
Swift Version: Swift 6.0+ iOS Version: iOS 18+ Xcode: Xcode 16+
Core Philosophy
"Now Playing eligibility requires THREE things working together: AVAudioSession activation, remote command handlers, and metadata publishing. Missing ANY of these silently breaks the entire system. 90% of Now Playing issues stem from incorrect activation order or missing command handlers, not API bugs."
Key Insight from WWDC 2022/110338: Apps must meet two system heuristics:
- Register handlers for at least one remote command
- Configure AVAudioSession with a non-mixable category
When to Use This Skill
✅ Use this skill when:
- Now Playing info doesn't appear on Lock Screen or Control Center
- Play/pause/skip buttons are grayed out or don't respond
- Album artwork is missing, wrong, or flickers between images
- Control Center shows "Playing" when app is paused, or vice versa
- Apple Music or other apps "steal" Now Playing status
- Implementing Now Playing for the first time
- Debugging Now Playing issues in existing implementation
❌ Do NOT use this skill for:
- CarPlay integration (see separate CarPlay skill)
- Background audio configuration details (see AVFoundation skill)
- MusicKit Apple Music integration (see MusicKit skill)
Related Skills
- swift-concurrency - For @MainActor patterns, weak self in closures, async artwork loading
- memory-debugging - For retain cycles in command handlers
- avfoundation-ref - For AVAudioSession configuration details
Example Prompts
"Now Playing info shows briefly when playback starts, then disappears when I lock the screen. What's wrong?"
- Skill covers: AVAudioSession not remaining active, background mode not configured, session deactivated too early
"Play/pause buttons in Control Center are grayed out or don't respond to taps when my app is playing audio."
- Skill covers: Command handlers not registered, commands not enabled, wrong command center (should use session's not shared)
"Album artwork never appears, or shows wrong artwork, or flickers between different images."
- Skill covers: MPMediaItemArtwork initialization, image size requirements, race conditions with multiple artwork sources
"Control Center shows 'Playing' when my app is actually paused, or vice versa. How do I keep them in sync?"
- Skill covers: playbackRate updates, when to update nowPlayingInfo, race conditions with partial dictionary updates
"I'm using MPNowPlayingInfoCenter but sometimes Apple Music takes over and my app loses Now Playing control."
- Skill covers: Session eligibility requirements, AVAudioSession category conflicts, becomeActiveIfPossible()
Red Flags / Anti-Patterns
If you see ANY of these, suspect Now Playing misconfiguration:
- Info appears briefly then disappears (AVAudioSession deactivated)
- Commands work in simulator but not on device (simulator has different audio stack)
- Artwork shows placeholder then updates (race condition, not necessarily wrong)
- Artwork never appears (format/size issue or MPMediaItemArtwork block returning nil)
- Play/pause state incorrect after backgrounding (not updating on playback rate changes)
- Another app "steals" Now Playing (didn't meet eligibility requirements)
playbackStateproperty doesn't update (iOS doesn't haveplaybackState, macOS only!)
FORBIDDEN Assumptions:
- "Just set nowPlayingInfo and it works" - Must have AVAudioSession + command handlers
- "playbackState controls Control Center" - iOS ignores playbackState, uses playbackRate
- "Artwork just needs an image" - Needs proper MPMediaItemArtwork with size handler
- "Commands enable themselves" - Must add target AND set isEnabled = true
- "Update elapsed time every second" - System infers from rate, causes jitter
Mandatory First Steps (Pre-Diagnosis)
Run this code to understand current state before debugging:
// 1. Verify AVAudioSession configuration
let session = AVAudioSession.sharedInstance()
print("Category: \(session.category.rawValue)")
print("Mode: \(session.mode.rawValue)")
print("Options: \(session.categoryOptions)")
print("Is active: \(try? session.setActive(true))")
// Must be: .playback category, NOT .mixWithOthers option
// 2. Verify background mode
// Info.plist must have: UIBackgroundModes = ["audio"]
// 3. Check command handlers are registered
let commandCenter = MPRemoteCommandCenter.shared()
print("Play enabled: \(commandCenter.playCommand.isEnabled)")
print("Pause enabled: \(commandCenter.pauseCommand.isEnabled)")
// Must have at least one command with target AND isEnabled = true
// 4. Check nowPlayingInfo dictionary
if let info = MPNowPlayingInfoCenter.default().nowPlayingInfo {
print("Title: \(info[MPMediaItemPropertyTitle] ?? "nil")")
print("Artwork: \(info[MPMediaItemPropertyArtwork] != nil)")
print("Duration: \(info[MPMediaItemPropertyPlaybackDuration] ?? "nil")")
print("Elapsed: \(info[MPNowPlayingInfoPropertyElapsedPlaybackTime] ?? "nil")")
print("Rate: \(info[MPNowPlayingInfoPropertyPlaybackRate] ?? "nil")")
} else {
print("No nowPlayingInfo set!")
}What this tells you:
| Observation | Diagnosis | Pattern |
|---|---|---|
| Category is .ambient or has .mixWithOthers | Won't become Now Playing app | Pattern 1 |
| No commands have targets | System ignores app | Pattern 2 |
| Commands have targets but isEnabled = false | UI grayed out | Pattern 2 |
| Artwork is nil | MPMediaItemArtwork block returning nil | Pattern 3 |
| playbackRate is 0.0 when playing | Control Center shows paused | Pattern 4 |
| Background mode "audio" not in Info.plist | Info disappears on lock | Pattern 1 |
Decision Tree
Now Playing not working?
├─ Info never appears at all?
│ ├─ AVAudioSession category .ambient or .mixWithOthers?
│ │ └─ Pattern 1a (Wrong Category)
│ ├─ No remote command handlers registered?
│ │ └─ Pattern 2a (Missing Handlers)
│ ├─ Background mode "audio" not in Info.plist?
│ │ └─ Pattern 1b (Background Mode)
│ └─ AVAudioSession.setActive(true) never called?
│ └─ Pattern 1c (Not Activated)
│
├─ Info appears briefly, then disappears?
│ ├─ On lock screen specifically?
│ │ ├─ AVAudioSession deactivated too early?
│ │ │ └─ Pattern 1d (Early Deactivation)
│ │ └─ App suspended (no background mode)?
│ │ └─ Pattern 1b (Background Mode)
│ └─ When switching apps?
│ └─ Another app claiming Now Playing → Pattern 5
│
├─ Commands not responding?
│ ├─ Buttons grayed out (disabled)?
│ │ └─ command.isEnabled = false → Pattern 2b
│ ├─ Buttons visible but no response?
│ │ ├─ Handler not returning .success?
│ │ │ └─ Pattern 2c (Handler Return)
│ │ └─ Using wrong command center (session vs shared)?
│ │ └─ Pattern 2d (Command Center)
│ └─ Skip forward/backward not showing?
│ └─ preferredIntervals not set → Pattern 2e
│
├─ Artwork problems?
│ ├─ Never appears?
│ │ ├─ MPMediaItemArtwork block returning nil?
│ │ │ └─ Pattern 3a (Artwork Block)
│ │ └─ Image format/size invalid?
│ │ └─ Pattern 3b (Image Format)
│ ├─ Wrong artwork showing?
│ │ └─ Race condition between sources → Pattern 3c
│ └─ Artwork flickering?
│ └─ Multiple updates in rapid succession → Pattern 3d
│
└─ State sync issues?
├─ Shows "Playing" when paused?
│ └─ playbackRate not updated → Pattern 4a
├─ Progress bar stuck or jumping?
│ └─ elapsedTime not updated at right moments → Pattern 4b
└─ Duration wrong?
└─ Not setting playbackDuration → Pattern 4cPattern 1: AVAudioSession Configuration (Info Not Appearing)
Time cost: 10-15 minutes
Symptom
- Now Playing info never appears on Lock Screen
- Info appears briefly then disappears on lock
- Works in foreground, disappears in background
BAD Code
// ❌ WRONG — Category allows mixing, won't become Now Playing app
class PlayerService {
func setupAudioSession() throws {
try AVAudioSession.sharedInstance().setCategory(
.playback,
options: .mixWithOthers // ❌ Mixable = not eligible for Now Playing
)
// Never called setActive() // ❌ Session not activated
}
func play() {
player.play()
updateNowPlaying() // ❌ Won't appear - session not active
}
}GOOD Code
// ✅ CORRECT — Non-mixable category, activated before playback
class PlayerService {
func setupAudioSession() throws {
try AVAudioSession.sharedInstance().setCategory(
.playback,
mode: .default,
options: [] // ✅ No .mixWithOthers = eligible for Now Playing
)
}
func play() async throws {
// ✅ Activate BEFORE starting playback
try AVAudioSession.sharedInstance().setActive(true)
player.play()
updateNowPlaying() // ✅ Now appears correctly
}
func stop() async throws {
player.pause()
// ✅ Deactivate AFTER stopping, with notify option
try AVAudioSession.sharedInstance().setActive(
false,
options: .notifyOthersOnDeactivation
)
}
}Info.plist Requirement
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
</array>Verification
- Lock screen shows Now Playing controls
- Info persists when app backgrounded
- Survives app switch (unless another app plays)
Pattern 2: Remote Command Registration (Commands Not Working)
Time cost: 15-20 minutes
Symptom
- Play/pause buttons grayed out
- Buttons visible but tapping does nothing
- Skip buttons don't appear
- Commands work once then stop
BAD Code
// ❌ WRONG — Missing targets and isEnabled
class PlayerService {
func setupCommands() {
let commandCenter = MPRemoteCommandCenter.shared()
// ❌ Added target but forgot isEnabled
commandCenter.playCommand.addTarget { _ in
self.player.play()
return .success
}
// playCommand.isEnabled defaults to false!
// ❌ Never added pause handler
// ❌ skipForward without preferredIntervals
commandCenter.skipForwardCommand.addTarget { _ in
return .success
}
}
}GOOD Code
// ✅ CORRECT — Targets registered, enabled, with proper configuration
@MainActor
class PlayerService {
private var commandTargets: [Any] = [] // Keep strong references
func setupCommands() {
let commandCenter = MPRemoteCommandCenter.shared()
// ✅ Play command - add target AND enable
let playTarget = commandCenter.playCommand.addTarget { [weak self] _ in
self?.player.play()
self?.updateNowPlayingPlaybackState(isPlaying: true)
return .success
}
commandCenter.playCommand.isEnabled = true
commandTargets.append(playTarget)
// ✅ Pause command
let pauseTarget = commandCenter.pauseCommand.addTarget { [weak self] _ in
self?.player.pause()
self?.updateNowPlayingPlaybackState(isPlaying: false)
return .success
}
commandCenter.pauseCommand.isEnabled = true
commandTargets.append(pauseTarget)
// ✅ Skip forward - set preferredIntervals BEFORE adding target
commandCenter.skipForwardCommand.preferredIntervals = [15.0]
let skipForwardTarget = commandCenter.skipForwardCommand.addTarget { [weak self] event in
guard let skipEvent = event as? MPSkipIntervalCommandEvent else {
return .commandFailed
}
self?.skip(by: skipEvent.interval)
return .success
}
commandCenter.skipForwardCommand.isEnabled = true
commandTargets.append(skipForwardTarget)
// ✅ Skip backward
commandCenter.skipBackwardCommand.preferredIntervals = [15.0]
let skipBackwardTarget = commandCenter.skipBackwardCommand.addTarget { [weak self] event in
guard let skipEvent = event as? MPSkipIntervalCommandEvent else {
return .commandFailed
}
self?.skip(by: -skipEvent.interval)
return .success
}
commandCenter.skipBackwardCommand.isEnabled = true
commandTargets.append(skipBackwardTarget)
}
func teardownCommands() {
let commandCenter = MPRemoteCommandCenter.shared()
commandCenter.playCommand.removeTarget(nil)
commandCenter.pauseCommand.removeTarget(nil)
commandCenter.skipForwardCommand.removeTarget(nil)
commandCenter.skipBackwardCommand.removeTarget(nil)
commandTargets.removeAll()
}
deinit {
teardownCommands()
}
}Verification
- Buttons not grayed out in Control Center
- Tapping play/pause actually plays/pauses
- Skip buttons show with correct interval (15s)
Pattern 3: Artwork Configuration (Artwork Problems)
Time cost: 15-25 minutes
Symptom
- Artwork never appears (generic placeholder)
- Wrong artwork for current track
- Artwork flickers between images
- Artwork appears then disappears
BAD Code
// ❌ WRONG — MPMediaItemArtwork block can return nil, no size handling
func updateNowPlaying() {
var nowPlayingInfo = [String: Any]()
nowPlayingInfo[MPMediaItemPropertyTitle] = track.title
// ❌ Storing UIImage directly (doesn't work)
nowPlayingInfo[MPMediaItemPropertyArtwork] = image
// ❌ Or: Block that ignores requested size
let artwork = MPMediaItemArtwork(boundsSize: image.size) { _ in
return self.cachedImage // ❌ May be nil, ignores requested size
}
MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
}
// ❌ WRONG — Multiple rapid updates cause flickering
func loadArtwork(from url: URL) {
// Request 1
loadImage(url) { image in
self.updateNowPlayingArtwork(image) // Update 1
}
// Request 2 (cached) returns faster
loadCachedImage(url) { image in
self.updateNowPlayingArtwork(image) // Update 2 - flicker!
}
}GOOD Code
// ✅ CORRECT — Proper MPMediaItemArtwork with size handling
@MainActor
class NowPlayingService {
private var currentArtworkURL: URL?
private var artworkImage: UIImage?
func updateNowPlayingArtwork(_ image: UIImage, for trackURL: URL) {
// ✅ Prevent race conditions - only update if still current track
guard trackURL == currentArtworkURL else { return }
artworkImage = image
// ✅ Create MPMediaItemArtwork with proper size handler
let artwork = MPMediaItemArtwork(boundsSize: image.size) { [weak self] requestedSize in
// ✅ System calls this block with various sizes (300x300, 600x600, etc.)
guard let image = self?.artworkImage else { return UIImage() }
// ✅ Return image at requested size (or let system scale)
// For best quality, pre-render at common sizes
return image
}
// ✅ Update only artwork key, preserve other values
var nowPlayingInfo = MPNowPlayingInfoCenter.default().nowPlayingInfo ?? [:]
nowPlayingInfo[MPMediaItemPropertyArtwork] = artwork
MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
}
// ✅ Single entry point with priority: embedded > cached > remote
func loadArtwork(for track: Track) async {
currentArtworkURL = track.artworkURL
// Priority 1: Embedded in file (immediate, no flicker)
if let embedded = await extractEmbeddedArtwork(track.fileURL) {
updateNowPlayingArtwork(embedded, for: track.artworkURL)
return
}
// Priority 2: Already cached (fast)
if let cached = await loadFromCache(track.artworkURL) {
updateNowPlayingArtwork(cached, for: track.artworkURL)
return
}
// Priority 3: Remote (slow, but don't flicker)
// ✅ Set placeholder first, then update once with real image
if let remote = await downloadImage(track.artworkURL) {
updateNowPlayingArtwork(remote, for: track.artworkURL)
}
}
}Artwork Size Guidelines
- Lock Screen: 300x300 points (600x600 @2x, 900x900 @3x)
- Control Center: Various sizes
- Best practice: Provide image at least 600x600 pixels
Verification
- Artwork appears on Lock Screen
- Correct artwork for current track
- No flickering when track changes
- Artwork persists after backgrounding
Pattern 4: Playback State Synchronization (State Sync Issues)
Time cost: 10-20 minutes
Symptom
- Control Center shows "Playing" when actually paused
- Progress bar doesn't move or jumps unexpectedly
- Duration shows wrong value
- Scrubbing doesn't work correctly
BAD Code
// ❌ WRONG — Using playbackState (macOS only, ignored on iOS)
func updatePlaybackState(isPlaying: Bool) {
MPNowPlayingInfoCenter.default().playbackState = isPlaying ? .playing : .paused
// ❌ iOS ignores this property! Only macOS uses it.
}
// ❌ WRONG — Updating elapsed time on a timer (causes drift)
Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
var info = MPNowPlayingInfoCenter.default().nowPlayingInfo ?? [:]
info[MPNowPlayingInfoPropertyElapsedPlaybackTime] = self.player.currentTime().seconds
MPNowPlayingInfoCenter.default().nowPlayingInfo = info
// ❌ Every second creates jitter, system already infers from timestamp
}
// ❌ WRONG — Partial dictionary updates cause race conditions
func updateTitle() {
var info = [String: Any]()
info[MPMediaItemPropertyTitle] = track.title
MPNowPlayingInfoCenter.default().nowPlayingInfo = info
// ❌ Cleared all other values (artwork, duration, etc.)!
}GOOD Code
// ✅ CORRECT — Use playbackRate for iOS, update at key moments only
@MainActor
class NowPlayingService {
// ✅ Update when playback STARTS
func playbackStarted(track: Track, player: AVPlayer) {
var nowPlayingInfo = MPNowPlayingInfoCenter.default().nowPlayingInfo ?? [:]
// ✅ Core metadata
nowPlayingInfo[MPMediaItemPropertyTitle] = track.title
nowPlayingInfo[MPMediaItemPropertyArtist] = track.artist
nowPlayingInfo[MPMediaItemPropertyAlbumTitle] = track.album
nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = player.currentItem?.duration.seconds ?? 0
// ✅ Playback state via RATE (not playbackState property)
nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = player.currentTime().seconds
nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = 1.0 // Playing
MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
}
// ✅ Update when playback PAUSES
func playbackPaused(player: AVPlayer) {
var nowPlayingInfo = MPNowPlayingInfoCenter.default().nowPlayingInfo ?? [:]
// ✅ Update elapsed time AND rate together
nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = player.currentTime().seconds
nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = 0.0 // Paused
MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
}
// ✅ Update when user SEEKS
func userSeeked(to time: CMTime, player: AVPlayer) {
var nowPlayingInfo = MPNowPlayingInfoCenter.default().nowPlayingInfo ?? [:]
nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = time.seconds
// ✅ Keep current rate (don't change playing/paused state)
MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
}
// ✅ Update when track CHANGES
func trackChanged(to newTrack: Track, player: AVPlayer) {
// ✅ Full refresh of all metadata
var nowPlayingInfo = [String: Any]()
nowPlayingInfo[MPMediaItemPropertyTitle] = newTrack.title
nowPlayingInfo[MPMediaItemPropertyArtist] = newTrack.artist
nowPlayingInfo[MPMediaItemPropertyAlbumTitle] = newTrack.album
nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = player.currentItem?.duration.seconds ?? 0
nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = 0.0
nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = player.rate
MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
// Then load artwork asynchronously
Task {
await loadArtwork(for: newTrack)
}
}
}When to Update Now Playing Info
| Event | What to Update |
|---|---|
| Playback starts | All metadata + elapsed=current + rate=1.0 |
| Playback pauses | elapsed=current + rate=0.0 |
| User seeks | elapsed=newPosition (keep rate) |
| Track changes | All metadata (new track) |
| Playback rate changes (2x, 0.5x) | rate=newRate |
DO NOT Update
- On a timer (system infers from elapsed + rate + timestamp)
- Elapsed time continuously (causes jitter)
- Partial dictionaries (loses other values)
Pattern 5: MPNowPlayingSession (iOS 16+ Recommended Approach)
Time cost: 20-30 minutes
When to Use MPNowPlayingSession
- iOS 16+ (available since iOS 16, previously tvOS only)
- Using AVPlayer for playback
- Want automatic publishing of playback state
- Multiple players (Picture-in-Picture scenarios)
BAD Code (Manual Approach - More Error-Prone)
// ❌ Manual updates are error-prone, easy to miss state changes
class OldStylePlayer {
func play() {
player.play()
// Must remember to:
updateNowPlayingElapsed()
updateNowPlayingRate()
// Easy to forget one...
}
}GOOD Code (MPNowPlayingSession)
// ✅ CORRECT — MPNowPlayingSession handles automatic publishing
@MainActor
class ModernPlayerService {
private var player: AVPlayer
private var session: MPNowPlayingSession?
init() {
player = AVPlayer()
setupSession()
}
func setupSession() {
// ✅ Create session with player
session = MPNowPlayingSession(players: [player])
// ✅ Enable automatic publishing of:
// - Duration
// - Elapsed time
// - Playback state (rate)
// - Playback progress
session?.automaticallyPublishNowPlayingInfo = true
// ✅ Register commands on SESSION's command center (not shared)
session?.remoteCommandCenter.playCommand.addTarget { [weak self] _ in
self?.player.play()
return .success
}
session?.remoteCommandCenter.playCommand.isEnabled = true
session?.remoteCommandCenter.pauseCommand.addTarget { [weak self] _ in
self?.player.pause()
return .success
}
session?.remoteCommandCenter.pauseCommand.isEnabled = true
// ✅ Try to become active Now Playing session
session?.becomeActiveIfPossible { success in
print("Became active Now Playing: \(success)")
}
}
func play(track: Track) async {
let item = AVPlayerItem(url: track.url)
// ✅ Set static metadata on player item (title, artwork)
item.nowPlayingInfo = [
MPMediaItemPropertyTitle: track.title,
MPMediaItemPropertyArtist: track.artist,
MPMediaItemPropertyArtwork: await createArtwork(for: track)
]
player.replaceCurrentItem(with: item)
player.play()
// ✅ No need to manually update elapsed time, rate, duration
// MPNowPlayingSession publishes automatically!
}
}Multiple Sessions (Picture-in-Picture)
class MultiPlayerService {
var mainSession: MPNowPlayingSession
var pipSession: MPNowPlayingSession
func pipDidExpand() {
// ✅ Promote PiP session when it expands to full screen
pipSession.becomeActiveIfPossible { success in
// PiP now controls Lock Screen, Control Center
}
}
func pipDidMinimize() {
// ✅ Demote back to main session
mainSession.becomeActiveIfPossible { success in
// Main player now controls Lock Screen, Control Center
}
}
}Critical Gotcha
When using MPNowPlayingSession: Use session.remoteCommandCenter, NOT MPRemoteCommandCenter.shared()
// ❌ WRONG
let commandCenter = MPRemoteCommandCenter.shared()
commandCenter.playCommand.addTarget { _ in }
// ✅ CORRECT
session.remoteCommandCenter.playCommand.addTarget { _ in }Pressure Scenarios
Scenario 1: Apple Music Keeps Taking Over (24-Hour Launch Deadline)
Situation
- App launching tomorrow
- QA reports: "Now Playing works, but when user opens Apple Music then returns to our app, our controls disappear"
- Product manager: "This is a blocker, users will think our app is broken"
- You're 2 hours from code freeze
Rationalization Traps (DO NOT)
- "Just tell users not to use Apple Music" - Unacceptable UX, will get 1-star reviews
- "Force our app to always be Now Playing" - Impossible, system controls eligibility
- "File a bug with Apple" - Won't help before launch
Root Cause
Your app loses eligibility because:
- Using
.mixWithOthersoption (allows other apps to play simultaneously) - Not calling
becomeActiveIfPossible()when returning to foreground - AVAudioSession deactivated when backgrounded
Systematic Fix (30 minutes)
// 1. Remove mixWithOthers
try AVAudioSession.sharedInstance().setCategory(.playback, options: [])
// 2. Reactivate when returning to foreground
NotificationCenter.default.addObserver(
forName: UIApplication.willEnterForegroundNotification,
object: nil,
queue: .main
) { [weak self] _ in
guard self?.isPlaying == true else { return }
do {
try AVAudioSession.sharedInstance().setActive(true)
self?.session?.becomeActiveIfPossible { _ in }
} catch {
print("Failed to reactivate audio session: \(error)")
}
}
// 3. Handle interruptions (phone call, Siri)
NotificationCenter.default.addObserver(
forName: AVAudioSession.interruptionNotification,
object: nil,
queue: .main
) { [weak self] notification in
guard let info = notification.userInfo,
let typeValue = info[AVAudioSessionInterruptionTypeKey] as? UInt,
let type = AVAudioSession.InterruptionType(rawValue: typeValue) else {
return
}
if type == .ended {
// ✅ Reactivate after interruption
try? AVAudioSession.sharedInstance().setActive(true)
self?.session?.becomeActiveIfPossible { _ in }
}
}Communication Template
To PM: Found root cause - our audio session config allowed Apple Music to take over.
Fix implemented: 3 changes to audio session handling.
Testing: Verified fix with Apple Music, Spotify, phone calls.
ETA: 20 more minutes for full regression test.
To QA: Please test this flow:
1. Play audio in our app
2. Open Apple Music, play a song
3. Return to our app, tap play
4. Lock screen should show OUR controlsTime Saved
- 2-3 hours of debugging speculation
- Launch delay avoided
- QA confidence restored
Scenario 2: Artwork Flickers Every Track Change
Situation
- User feedback: "Album art keeps flashing when songs change"
- Analytics show 3-4 artwork updates per track change
- Designer: "This looks unprofessional"
Root Cause
Multiple artwork sources racing:
- Cache check (async)
- Remote URL fetch (async)
- Embedded artwork extraction (async)
All three complete at different times, each updating Now Playing
Fix (20 minutes)
// ✅ Single-source-of-truth with cancellation
private var artworkTask: Task<Void, Never>?
func loadArtwork(for track: Track) {
// Cancel previous artwork load
artworkTask?.cancel()
artworkTask = Task { @MainActor in
// Clear previous artwork immediately (optional)
// updateNowPlayingArtwork(nil)
// Wait for best available artwork
let artwork = await loadBestArtwork(for: track)
// Check if still current track
guard !Task.isCancelled else { return }
// Single update
updateNowPlayingArtwork(artwork, for: track.artworkURL)
}
}
private func loadBestArtwork(for track: Track) async -> UIImage? {
// Priority order: embedded > cached > remote
if let embedded = await extractEmbeddedArtwork(track) {
return embedded
}
if let cached = await loadFromCache(track.artworkURL) {
return cached
}
return await downloadImage(track.artworkURL)
}Communication Template
To Designer: Fixed artwork flicker - reduced from 3-4 updates to 1 per track.
Root cause: Multiple async sources racing to update artwork.
Solution: Task cancellation + priority order (embedded > cached > remote).
Testing: Verified with 10 track changes, zero flicker.Time Saved
- 1-2 hours investigating image caching
- Designer approval unblocked
- Professional UX restored
Common Gotchas
| Symptom | Cause | Solution | Time to Fix |
|---|---|---|---|
| Info never appears | Missing background mode | Add audio to UIBackgroundModes in Info.plist | 2 min |
| Info never appears | AVAudioSession not activated | Call setActive(true) before playback | 5 min |
| Info never appears | No command handlers | Add target to at least one command | 10 min |
| Info never appears | Using .mixWithOthers | Remove .mixWithOthers option | 5 min |
| Commands grayed out | isEnabled = false | Set command.isEnabled = true after adding target | 5 min |
| Commands don't respond | Handler returns wrong status | Return .success from handler | 5 min |
| Commands don't respond | Using shared command center with MPNowPlayingSession | Use session.remoteCommandCenter instead | 10 min |
| Skip buttons missing | No preferredIntervals | Set skipCommand.preferredIntervals = [15.0] | 5 min |
| Artwork never appears | MPMediaItemArtwork block returns nil | Ensure image is loaded before creating artwork | 15 min |
| Artwork flickers | Multiple rapid updates | Single source of truth with cancellation | 20 min |
| Wrong play/pause state | Using playbackState property | Use playbackRate (1.0 = playing, 0.0 = paused) | 10 min |
| Progress bar stuck | Not updating on seek | Update elapsedPlaybackTime after seek completes | 10 min |
| Progress bar jumps | Updating elapsed on timer | Don't update on timer; system infers from rate | 10 min |
| Loses Now Playing to other apps | Session not reactivated on foreground | Call becomeActiveIfPossible() on foreground | 15 min |
playbackState doesn't work | iOS-only app | playbackState is macOS only; use playbackRate on iOS | 10 min |
| Siri skip ignores preferredIntervals | Hardcoded interval in handler | Use event.interval from MPSkipIntervalCommandEvent | 5 min |
Expert Checklist
Before Implementing Now Playing
- [ ] Added
audioto UIBackgroundModes in Info.plist - [ ] AVAudioSession category is
.playbackwithout.mixWithOthers - [ ] Decided: Manual (MPNowPlayingInfoCenter) or Automatic (MPNowPlayingSession)?
AVAudioSession Setup
- [ ]
setCategory(.playback)called at app launch - [ ]
setActive(true)called before playback starts - [ ]
setActive(false, options: .notifyOthersOnDeactivation)on stop - [ ] Interruption notification handled (reactivate after phone call)
- [ ] Foreground notification handled (reactivate after background)
Remote Commands
- [ ] At least one command has target registered
- [ ] All registered commands have
isEnabled = true - [ ] Skip commands have
preferredIntervalsset - [ ] Handlers return
.successon success - [ ] Using correct command center (session's vs shared)
- [ ] Command targets stored to prevent deallocation
- [ ] Commands removed in deinit
Now Playing Info
- [ ] Title set (
MPMediaItemPropertyTitle) - [ ] Duration set (
MPMediaItemPropertyPlaybackDuration) - [ ] Elapsed time set at play/pause/seek (
MPNowPlayingInfoPropertyElapsedPlaybackTime) - [ ] Playback rate set (
MPNowPlayingInfoPropertyPlaybackRate: 1.0 = playing, 0.0 = paused) - [ ] Artwork created with
MPMediaItemArtwork(boundsSize:requestHandler:) - [ ] NOT using
playbackStateproperty (macOS only) - [ ] NOT updating elapsed time on a timer
Artwork
- [ ] Image at least 600x600 pixels
- [ ] MPMediaItemArtwork block never returns nil (return placeholder if needed)
- [ ] Single source of truth prevents flickering
- [ ] Previous artwork load cancelled on track change
Testing
- [ ] Lock screen shows correct info
- [ ] Control Center shows correct info
- [ ] Play/pause buttons respond
- [ ] Skip buttons show and respond
- [ ] Progress bar moves correctly
- [ ] Survives app background/foreground
- [ ] Survives phone call interruption
- [ ] Survives other app playing audio
- [ ] Tested with Apple Music conflict
- [ ] Tested with Spotify conflict
WWDC Sessions
- WWDC 2022/110338: Explore media metadata publishing and playback interactions - MPNowPlayingSession (iOS 16+)
- WWDC 2017/251: Now Playing and Remote Commands on tvOS - Fundamentals
- WWDC 2019/501: Delivering Intuitive Media Playback with AVKit - AVKit integration
Apple Documentation
Last Updated: 2025-12-07 Status: iOS 18+ discipline skill covering all 4 common Now Playing issues Tested: Based on WWDC 2019/501, WWDC 2022/110338 patterns