axiom-now-playing

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Now Playing Integration Guide

Now Playing 集成指南

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+
目的:解决iOS 18+上4类最常见的Now Playing问题:信息不显示、命令无响应、封面异常、状态不同步
Swift版本:Swift 6.0+ iOS版本: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:
  1. Register handlers for at least one remote command
  2. Configure AVAudioSession with a non-mixable category
"应用要具备Now Playing资格,需满足三个条件:AVAudioSession激活、远程命令处理器注册、元数据发布。缺少任意一项都会导致整个系统静默失效。90%的Now Playing问题源于激活顺序错误或命令处理器缺失,而非API本身的Bug。"
WWDC 2022/110338关键要点:应用必须满足两项系统规则:
  1. 为至少一个远程命令注册处理器
  2. 为AVAudioSession配置非可混音类别

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
  • Integrating CarPlay Now Playing (covered in Pattern 6)
  • Working with MusicKit/Apple Music content (covered in Pattern 7)
在以下场景使用本指南
  • 锁屏或控制中心不显示Now Playing信息
  • 播放/暂停/跳过按钮变灰或无响应
  • 专辑封面缺失、显示错误或在多张图片间闪烁
  • 控制中心显示“正在播放”但应用实际已暂停,或反之
  • Apple Music或其他应用“抢占”Now Playing状态
  • 首次实现Now Playing功能
  • 调试现有实现中的Now Playing问题
  • 集成CarPlay Now Playing(见模式6)
  • 处理MusicKit/Apple Music内容(见模式7)

iOS 26 Note

iOS 26说明

iOS 26 introduces Liquid Glass visual design for Lock Screen and Control Center Now Playing widgets. This is automatic system behavior — no code changes required. The patterns in this skill remain valid for iOS 26.
Do NOT use this skill for:
  • Background audio configuration details (see AVFoundation skill)
iOS 26为锁屏和控制中心的Now Playing小部件引入了Liquid Glass视觉设计。这是系统自动行为——无需修改代码。本指南中的实现模式对iOS 26依然有效。
请勿在以下场景使用本指南
  • 后台音频配置细节(请查看AVFoundation相关指南)

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

  • swift-concurrency - 用于@MainActor模式、闭包中的weak self、异步封面加载
  • memory-debugging - 用于排查命令处理器中的循环引用
  • avfoundation-ref - 用于AVAudioSession配置细节

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)
  • playbackState
    property doesn't update (iOS doesn't have
    playbackState
    , 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
如果出现以下任意情况,需怀疑Now Playing配置错误:
  • 信息短暂显示后消失(AVAudioSession已失活)
  • 命令在模拟器中正常工作但真机上无响应(模拟器的音频栈与真机不同)
  • 封面先显示占位符再更新(竞态条件,不一定是错误)
  • 封面始终不显示(格式/尺寸问题或MPMediaItemArtwork块返回nil)
  • 应用后台后播放状态显示错误(未在播放速率变化时更新状态)
  • 其他应用“抢占”Now Playing状态(未满足资格要求)
  • playbackState
    属性不更新(iOS没有
    playbackState
    属性,仅macOS支持!)
禁止做出以下假设:
  • “只要设置nowPlayingInfo就会生效”——必须同时配置AVAudioSession和命令处理器
  • “playbackState控制控制中心显示”——iOS忽略playbackState,使用playbackRate
  • “封面只需传入图片即可”——需要使用带尺寸处理器的MPMediaItemArtwork
  • “命令会自动启用”——必须添加目标并设置isEnabled = true
  • “每秒更新一次已播放时长”——系统会根据播放速率自动推断,手动更新会导致抖动

Mandatory First Steps (Pre-Diagnosis)

强制前置步骤(预诊断)

Run this code to understand current state before debugging:
swift
// 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:
ObservationDiagnosisPattern
Category is .ambient or has .mixWithOthersWon't become Now Playing appPattern 1
No commands have targetsSystem ignores appPattern 2
Commands have targets but isEnabled = falseUI grayed outPattern 2
Artwork is nilMPMediaItemArtwork block returning nilPattern 3
playbackRate is 0.0 when playingControl Center shows pausedPattern 4
Background mode "audio" not in Info.plistInfo disappears on lockPattern 1
调试前运行以下代码了解当前状态:
swift
// 1. 验证AVAudioSession配置
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))")
// 必须满足:.playback类别,且未设置.mixWithOthers选项

// 2. 验证后台模式
// Info.plist中必须包含:UIBackgroundModes = ["audio"]

// 3. 检查命令处理器是否已注册
let commandCenter = MPRemoteCommandCenter.shared()
print("Play enabled: \(commandCenter.playCommand.isEnabled)")
print("Pause enabled: \(commandCenter.pauseCommand.isEnabled)")
// 至少有一个命令已添加目标且isEnabled = true

// 4. 检查nowPlayingInfo字典
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!")
}
代码输出对应的诊断结论:
观察结果诊断结论对应模式
类别为.ambient或包含.mixWithOthers无法成为Now Playing应用模式1
无命令添加了目标处理器系统忽略该应用模式2
命令已添加目标但isEnabled = false控制中心按钮变灰模式2
Artwork为nilMPMediaItemArtwork块返回nil模式3
播放时playbackRate为0.0控制中心显示已暂停模式4
Info.plist中无后台模式"audio"锁屏后信息消失模式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 4c
├─ CarPlay specific issues?
│  ├─ App doesn't appear in CarPlay at all?
│  │  └─ Missing entitlement → Pattern 6 (Add com.apple.developer.carplay-audio)
│  ├─ Now Playing blank in CarPlay but works on iOS?
│  │  └─ Same root cause as iOS → Check Patterns 1-4
│  ├─ Custom buttons don't appear in CarPlay?
│  │  └─ Wrong configuration timing → Pattern 6 (Configure at templateApplicationScene)
│  └─ Works on device but not CarPlay simulator?
│     └─ Debugger interference → Pattern 6 (Run without debugger)
└─ Using MusicKit (ApplicationMusicPlayer)?
   ├─ Now Playing shows wrong info?
   │  └─ Overwriting automatic data → Pattern 7 (Don't set nowPlayingInfo manually)
   └─ Mixing MusicKit + own content?
      └─ Hybrid approach needed → Pattern 7 (Switch between players)

Now Playing功能异常?
├─ 信息完全不显示?
│  ├─ AVAudioSession类别为.ambient或包含.mixWithOthers?
│  │  └─ 模式1a(类别错误)
│  ├─ 未注册远程命令处理器?
│  │  └─ 模式2a(缺失处理器)
│  ├─ Info.plist中无后台模式"audio"?
│  │  └─ 模式1b(后台模式缺失)
│  └─ 从未调用AVAudioSession.setActive(true)?
│     └─ 模式1c(未激活会话)
├─ 信息短暂显示后消失?
│  ├─ 仅在锁屏时出现?
│  │  ├─ AVAudioSession过早失活?
│  │  │  └─ 模式1d(过早失活)
│  │  └─ 应用被挂起(无后台模式)?
│  │     └─ 模式1b(后台模式缺失)
│  └─ 切换应用时出现?
│     └─ 其他应用抢占Now Playing → 模式5
├─ 命令无响应?
│  ├─ 按钮变灰(已禁用)?
│  │  └─ command.isEnabled = false → 模式2b
│  ├─ 按钮可见但无响应?
│  │  ├─ 处理器未返回.success?
│  │  │  └─ 模式2c(处理器返回值错误)
│  │  └─ 使用了错误的命令中心(会话级 vs 全局共享)?
│  │     └─ 模式2d(命令中心错误)
│  └─ 快进/快退按钮不显示?
│     └─ 未设置preferredIntervals → 模式2e
├─ 封面异常?
│  ├─ 始终不显示?
│  │  ├─ MPMediaItemArtwork块返回nil?
│  │  │  └─ 模式3a(封面块错误)
│  │  └─ 图片格式/尺寸无效?
│  │     └─ 模式3b(图片格式错误)
│  ├─ 显示错误封面?
│  │  └─ 多源竞态条件 → 模式3c
│  └─ 封面闪烁?
│     └─ 短时间内多次更新 → 模式3d
├─ 状态不同步?
│  ├─ 实际已暂停但显示“正在播放”?
│  │  └─ 未更新playbackRate → 模式4a
│  ├─ 进度条卡住或跳变?
│  │  └─ 未在正确时机更新已播放时长 → 模式4b
│  └─ 时长显示错误?
│     └─ 未设置playbackDuration → 模式4c
├─ CarPlay专属问题?
│  ├─ 应用完全不显示在CarPlay中?
│  │  └─ 缺失权限 → 模式6(添加com.apple.developer.carplay-audio权限)
│  ├─ CarPlay中Now Playing空白但iOS上正常?
│  │  └─ 根因与iOS一致 → 检查模式1-4
│  ├─ 自定义按钮不显示在CarPlay中?
│  │  └─ 配置时机错误 → 模式6(在templateApplicationScene时配置)
│  └─ 真机上正常但CarPlay模拟器中异常?
│     └─ 调试器干扰 → 模式6(不附加调试器运行)
└─ 使用MusicKit(ApplicationMusicPlayer)?
   ├─ Now Playing显示错误信息?
   │  └─ 覆盖了自动发布的数据 → 模式7(不要手动设置nowPlayingInfo)
   └─ 混合使用MusicKit与自有内容?
      └─ 需要混合实现方案 → 模式7(切换播放器)

Pattern 1: AVAudioSession Configuration (Info Not Appearing)

模式1:AVAudioSession配置(信息不显示)

Time cost: 10-15 minutes
耗时:10-15分钟

Symptom

症状

  • Now Playing info never appears on Lock Screen
  • Info appears briefly then disappears on lock
  • Works in foreground, disappears in background
  • 锁屏上始终不显示Now Playing信息
  • 锁屏时信息短暂显示后消失
  • 前台正常,后台时信息消失

BAD Code

错误代码

swift
// ❌ 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
    }
}
swift
// ❌ 错误——类别允许混音,无法成为Now Playing应用
class PlayerService {
    func setupAudioSession() throws {
        try AVAudioSession.sharedInstance().setCategory(
            .playback,
            options: .mixWithOthers  // ❌ 可混音=不具备Now Playing资格
        )
        // 从未调用setActive()  // ❌ 会话未激活
    }

    func play() {
        player.play()
        updateNowPlaying()  // ❌ 不会显示——会话未激活
    }
}

GOOD Code

正确代码

swift
// ✅ 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
        )
    }
}
swift
// ✅ 正确——非混音类别,播放前激活会话
class PlayerService {
    func setupAudioSession() throws {
        try AVAudioSession.sharedInstance().setCategory(
            .playback,
            mode: .default,
            options: []  // ✅ 无.mixWithOthers=具备Now Playing资格
        )
    }

    func play() async throws {
        // ✅ 播放前激活会话
        try AVAudioSession.sharedInstance().setActive(true)

        player.play()
        updateNowPlaying()  // ✅ 现在会正确显示
    }

    func stop() async throws {
        player.pause()

        // ✅ 停止后失活会话,带通知其他应用的选项
        try AVAudioSession.sharedInstance().setActive(
            false,
            options: .notifyOthersOnDeactivation
        )
    }
}

Info.plist Requirement

Info.plist要求

xml
<key>UIBackgroundModes</key>
<array>
    <string>audio</string>
</array>
xml
<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)

  • 锁屏显示Now Playing控制项
  • 应用后台后信息依然保留
  • 切换应用后信息不消失(除非其他应用开始播放)

Pattern 2: Remote Command Registration (Commands Not Working)

模式2:远程命令注册(命令无响应)

Time cost: 15-20 minutes
耗时:15-20分钟

Symptom

症状

  • Play/pause buttons grayed out
  • Buttons visible but tapping does nothing
  • Skip buttons don't appear
  • Commands work once then stop
  • 播放/暂停按钮变灰
  • 按钮可见但点击无响应
  • 跳过按钮不显示
  • 命令仅能工作一次

BAD Code

错误代码

swift
// ❌ 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
        }
    }
}
swift
// ❌ 错误——缺失目标处理器和isEnabled设置
class PlayerService {
    func setupCommands() {
        let commandCenter = MPRemoteCommandCenter.shared()

        // ❌ 添加了目标但未设置isEnabled
        commandCenter.playCommand.addTarget { _ in
            self.player.play()
            return .success
        }
        // playCommand.isEnabled默认是false!

        // ❌ 从未添加暂停处理器

        // ❌ 快进命令未设置preferredIntervals
        commandCenter.skipForwardCommand.addTarget { _ in
            return .success
        }
    }
}

GOOD Code

正确代码

swift
// ✅ 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()
    }
}
swift
// ✅ 正确——注册目标处理器、启用命令、正确配置
@MainActor
class PlayerService {
    private var commandTargets: [Any] = []  // 保持强引用

    func setupCommands() {
        let commandCenter = MPRemoteCommandCenter.shared()

        // ✅ 播放命令——添加目标并启用
        let playTarget = commandCenter.playCommand.addTarget { [weak self] _ in
            self?.player.play()
            self?.updateNowPlayingPlaybackState(isPlaying: true)
            return .success
        }
        commandCenter.playCommand.isEnabled = true
        commandTargets.append(playTarget)

        // ✅ 暂停命令
        let pauseTarget = commandCenter.pauseCommand.addTarget { [weak self] _ in
            self?.player.pause()
            self?.updateNowPlayingPlaybackState(isPlaying: false)
            return .success
        }
        commandCenter.pauseCommand.isEnabled = true
        commandTargets.append(pauseTarget)

        // ✅ 快进命令——先设置preferredIntervals再添加目标
        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)

        // ✅ 快退命令
        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)

  • 控制中心按钮未变灰
  • 点击播放/暂停可实际控制播放
  • 跳过按钮显示且间隔正确(15秒)

Pattern 3: Artwork Configuration (Artwork Problems)

模式3:封面配置(封面异常)

Time cost: 15-25 minutes
耗时:15-25分钟

Symptom

症状

  • Artwork never appears (generic placeholder)
  • Wrong artwork for current track
  • Artwork flickers between images
  • Artwork appears then disappears
  • 封面始终不显示(仅显示通用占位符)
  • 当前曲目显示错误封面
  • 封面在多张图片间闪烁
  • 封面显示后消失

BAD Code

错误代码

swift
// ❌ 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!
    }
}
swift
// ❌ 错误——MPMediaItemArtwork块可能返回nil,无尺寸处理
func updateNowPlaying() {
    var nowPlayingInfo = [String: Any]()
    nowPlayingInfo[MPMediaItemPropertyTitle] = track.title

    // ❌ 直接存储UIImage(无效)
    nowPlayingInfo[MPMediaItemPropertyArtwork] = image

    // ❌ 或者:忽略请求尺寸的块
    let artwork = MPMediaItemArtwork(boundsSize: image.size) { _ in
        return self.cachedImage  // ❌ 可能为nil,忽略请求尺寸
    }

    MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
}

// ❌ 错误——多次快速更新导致闪烁
func loadArtwork(from url: URL) {
    // 请求1
    loadImage(url) { image in
        self.updateNowPlayingArtwork(image)  // 更新1
    }
    // 请求2(缓存)返回更快
    loadCachedImage(url) { image in
        self.updateNowPlayingArtwork(image)  // 更新2 - 闪烁!
    }
}

GOOD Code

正确代码

swift
// ✅ CORRECT — Proper MPMediaItemArtwork with value capture (Swift 6 compliant)
@MainActor
class NowPlayingService {
    private var currentArtworkURL: URL?

    func updateNowPlayingArtwork(_ image: UIImage, for trackURL: URL) {
        // ✅ Prevent race conditions - only update if still current track
        guard trackURL == currentArtworkURL else { return }

        // ✅ Create MPMediaItemArtwork with VALUE CAPTURE (not stored property)
        // This is Swift 6 strict concurrency compliant — UIImage is immutable
        // and safe to capture across isolation domains
        let artwork = MPMediaItemArtwork(boundsSize: image.size) { [image] requestedSize in
            // ✅ System calls this block from any thread
            // Captured value avoids "Main actor-isolated property" error
            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)
        }
    }
}
Why value capture, not
nonisolated(unsafe)
: The closure passed to
MPMediaItemArtwork
may be called by the system from any thread. Under Swift 6 strict concurrency, accessing
@MainActor
-isolated stored properties from this closure would cause a compile error. Capturing the image value directly is cleaner than using
nonisolated(unsafe)
because UIImage is immutable and thread-safe for reads.
swift
// ✅ 正确——符合Swift 6规范的MPMediaItemArtwork实现,使用值捕获
@MainActor
class NowPlayingService {
    private var currentArtworkURL: URL?

    func updateNowPlayingArtwork(_ image: UIImage, for trackURL: URL) {
        // ✅ 防止竞态条件——仅当当前曲目未变化时更新
        guard trackURL == currentArtworkURL else { return }

        // ✅ 使用值捕获创建MPMediaItemArtwork(符合Swift 6规范)
        // UIImage是不可变的,跨隔离域捕获安全
        let artwork = MPMediaItemArtwork(boundsSize: image.size) { [image] requestedSize in
            // ✅ 系统可能在任意线程调用此块
            // 捕获值可避免“Main actor-isolated property”错误
            return image
        }

        // ✅ 仅更新封面字段,保留其他值
        var nowPlayingInfo = MPNowPlayingInfoCenter.default().nowPlayingInfo ?? [:]
        nowPlayingInfo[MPMediaItemPropertyArtwork] = artwork
        MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
    }

    // ✅ 单入口点,优先级:内嵌 > 缓存 > 远程
    func loadArtwork(for track: Track) async {
        currentArtworkURL = track.artworkURL

        // 优先级1:文件内嵌封面(即时加载,无闪烁)
        if let embedded = await extractEmbeddedArtwork(track.fileURL) {
            updateNowPlayingArtwork(embedded, for: track.artworkURL)
            return
        }

        // 优先级2:已缓存封面(快速加载)
        if let cached = await loadFromCache(track.artworkURL) {
            updateNowPlayingArtwork(cached, for: track.artworkURL)
            return
        }

        // 优先级3:远程封面(慢速加载,但避免闪烁)
        // ✅ 先设置占位符,获取到真实图片后再更新一次
        if let remote = await downloadImage(track.artworkURL) {
            updateNowPlayingArtwork(remote, for: track.artworkURL)
        }
    }
}
为什么使用值捕获而非
nonisolated(unsafe)
:传递给
MPMediaItemArtwork
的闭包可能被系统在任意线程调用。在Swift 6严格并发规则下,从该闭包访问
@MainActor
隔离的存储属性会导致编译错误。直接捕获图片值比使用
nonisolated(unsafe)
更简洁,因为UIImage是不可变的,读取操作线程安全。

Artwork Size Guidelines

封面尺寸指南

  • Lock Screen: 300x300 points (600x600 @2x, 900x900 @3x)
  • Control Center: Various sizes
  • Best practice: Provide image at least 600x600 pixels
  • 锁屏:300x300点(@2x为600x600像素,@3x为900x900像素)
  • 控制中心:多种尺寸
  • 最佳实践:提供至少600x600像素的图片

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)

模式4:播放状态同步(状态不同步)

Time cost: 10-20 minutes
耗时:10-20分钟

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
  • 实际已暂停但控制中心显示“正在播放”
  • 进度条不移动或跳变
  • 时长显示错误
  • scrubbing(拖动进度条)功能异常

BAD Code

错误代码

swift
// ❌ 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.)!
}
swift
// ❌ 错误——使用playbackState(仅macOS支持,iOS忽略)
func updatePlaybackState(isPlaying: Bool) {
    MPNowPlayingInfoCenter.default().playbackState = isPlaying ? .playing : .paused
    // ❌ iOS忽略该属性!仅macOS使用
}

// ❌ 错误——定时更新已播放时长(导致漂移)
Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
    var info = MPNowPlayingInfoCenter.default().nowPlayingInfo ?? [:]
    info[MPNowPlayingInfoPropertyElapsedPlaybackTime] = self.player.currentTime().seconds
    MPNowPlayingInfoCenter.default().nowPlayingInfo = info
    // ❌ 每秒更新会导致抖动,系统可通过时间戳自动推断
}

// ❌ 错误——部分字典更新导致竞态条件
func updateTitle() {
    var info = [String: Any]()
    info[MPMediaItemPropertyTitle] = track.title
    MPNowPlayingInfoCenter.default().nowPlayingInfo = info
    // ❌ 清除了所有其他值(封面、时长等)!
}

GOOD Code

正确代码

swift
// ✅ 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)
        }
    }
}
swift
// ✅ 正确——iOS使用playbackRate,仅在关键时机更新
@MainActor
class NowPlayingService {

    // ✅ 播放开始时更新
    func playbackStarted(track: Track, player: AVPlayer) {
        var nowPlayingInfo = MPNowPlayingInfoCenter.default().nowPlayingInfo ?? [:]

        // ✅ 核心元数据
        nowPlayingInfo[MPMediaItemPropertyTitle] = track.title
        nowPlayingInfo[MPMediaItemPropertyArtist] = track.artist
        nowPlayingInfo[MPMediaItemPropertyAlbumTitle] = track.album
        nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = player.currentItem?.duration.seconds ?? 0

        // ✅ 通过RATE表示播放状态(而非playbackState属性)
        nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = player.currentTime().seconds
        nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = 1.0  // 正在播放

        MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
    }

    // ✅ 播放暂停时更新
    func playbackPaused(player: AVPlayer) {
        var nowPlayingInfo = MPNowPlayingInfoCenter.default().nowPlayingInfo ?? [:]

        // ✅ 同时更新已播放时长和速率
        nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = player.currentTime().seconds
        nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = 0.0  // 已暂停

        MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
    }

    // ✅ 用户拖动进度条时更新
    func userSeeked(to time: CMTime, player: AVPlayer) {
        var nowPlayingInfo = MPNowPlayingInfoCenter.default().nowPlayingInfo ?? [:]

        nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = time.seconds
        // ✅ 保留当前速率(不改变播放/暂停状态)

        MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
    }

    // ✅ 曲目切换时更新
    func trackChanged(to newTrack: Track, player: AVPlayer) {
        // ✅ 全量刷新所有元数据
        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

        // 然后异步加载封面
        Task {
            await loadArtwork(for: newTrack)
        }
    }
}

When to Update Now Playing Info

Now Playing信息更新时机

EventWhat to Update
Playback startsAll metadata + elapsed=current + rate=1.0
Playback pauseselapsed=current + rate=0.0
User seekselapsed=newPosition (keep rate)
Track changesAll metadata (new track)
Playback rate changes (2x, 0.5x)rate=newRate
事件更新内容
播放开始所有元数据 + 已播放时长=当前值 + 速率=1.0
播放暂停已播放时长=当前值 + 速率=0.0
用户拖动进度条已播放时长=新位置(保留速率)
曲目切换所有元数据(新曲目)
播放速率变化(2x、0.5x)速率=新值

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)

模式5:MPNowPlayingSession(iOS 16+推荐方案)

Time cost: 20-30 minutes
耗时:20-30分钟

When to Use MPNowPlayingSession

何时使用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)
  • iOS 16+(自iOS 16起可用,此前仅tvOS支持)
  • 使用AVPlayer进行播放
  • 希望自动发布播放状态
  • 多播放器场景(画中画)

BAD Code (Manual Approach - More Error-Prone)

错误代码(手动方案——易出错)

swift
// ❌ 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...
    }
}
swift
// ❌ 手动更新易出错,容易遗漏状态变化
class OldStylePlayer {
    func play() {
        player.play()
        // 必须记得:
        updateNowPlayingElapsed()
        updateNowPlayingRate()
        // 很容易遗漏其中一项...
    }
}

GOOD Code (MPNowPlayingSession)

正确代码(MPNowPlayingSession)

swift
// ✅ 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!
    }
}
swift
// ✅ 正确——MPNowPlayingSession自动处理发布
@MainActor
class ModernPlayerService {
    private var player: AVPlayer
    private var session: MPNowPlayingSession?

    init() {
        player = AVPlayer()
        setupSession()
    }

    func setupSession() {
        // ✅ 使用播放器创建会话
        session = MPNowPlayingSession(players: [player])

        // ✅ 启用自动发布以下内容:
        // - 时长
        // - 已播放时长
        // - 播放状态(速率)
        // - 播放进度
        session?.automaticallyPublishNowPlayingInfo = true

        // ✅ 在SESSION的命令中心注册命令(而非全局共享的)
        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

        // ✅ 尝试成为活跃的Now Playing会话
        session?.becomeActiveIfPossible { success in
            print("Became active Now Playing: \(success)")
        }
    }

    func play(track: Track) async {
        let item = AVPlayerItem(url: track.url)

        // ✅ 在播放器项上设置静态元数据(标题、封面)
        item.nowPlayingInfo = [
            MPMediaItemPropertyTitle: track.title,
            MPMediaItemPropertyArtist: track.artist,
            MPMediaItemPropertyArtwork: await createArtwork(for: track)
        ]

        player.replaceCurrentItem(with: item)
        player.play()
        // ✅ 无需手动更新已播放时长、速率、时长
        // MPNowPlayingSession会自动发布!
    }
}

Multiple Sessions (Picture-in-Picture)

多会话场景(画中画)

swift
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
        }
    }
}
swift
class MultiPlayerService {
    var mainSession: MPNowPlayingSession
    var pipSession: MPNowPlayingSession

    func pipDidExpand() {
        // ✅ 画中画展开为全屏时,提升PiP会话优先级
        pipSession.becomeActiveIfPossible { success in
            // PiP现在控制锁屏、控制中心
        }
    }

    func pipDidMinimize() {
        // ✅ 恢复为主会话
        mainSession.becomeActiveIfPossible { success in
            // 主播放器现在控制锁屏、控制中心
        }
    }
}

Critical Gotcha

关键注意事项

When using MPNowPlayingSession: Use
session.remoteCommandCenter
, NOT
MPRemoteCommandCenter.shared()
swift
// ❌ WRONG
let commandCenter = MPRemoteCommandCenter.shared()
commandCenter.playCommand.addTarget { _ in }

// ✅ CORRECT
session.remoteCommandCenter.playCommand.addTarget { _ in }

使用MPNowPlayingSession时:使用
session.remoteCommandCenter
,而非
MPRemoteCommandCenter.shared()
swift
// ❌ 错误
let commandCenter = MPRemoteCommandCenter.shared()
commandCenter.playCommand.addTarget { _ in }

// ✅ 正确
session.remoteCommandCenter.playCommand.addTarget { _ in }

Pattern 6: CarPlay Integration

模式6:CarPlay集成

For CarPlay-specific integration patterns, invoke
/skill axiom-now-playing-carplay
.
Key insight: CarPlay uses the SAME MPNowPlayingInfoCenter and MPRemoteCommandCenter as iOS. If your Now Playing works on iOS, it works in CarPlay with zero additional code.

CarPlay专属集成模式,请调用
/skill axiom-now-playing-carplay
核心要点:CarPlay与iOS使用相同的MPNowPlayingInfoCenter和MPRemoteCommandCenter。如果你的Now Playing在iOS上正常工作,那么在CarPlay上无需额外代码即可正常运行。

Pattern 7: MusicKit Integration (Apple Music)

模式7:MusicKit集成(Apple Music)

For MusicKit-specific integration patterns and hybrid app examples, invoke
/skill axiom-now-playing-musickit
.
Key insight: MusicKit's ApplicationMusicPlayer automatically publishes to MPNowPlayingInfoCenter. You don't need to manually update Now Playing info when playing Apple Music content.

MusicKit专属集成模式及混合应用示例,请调用
/skill axiom-now-playing-musickit
核心要点:MusicKit的ApplicationMusicPlayer会自动向MPNowPlayingInfoCenter发布数据。播放Apple Music内容时,无需手动更新Now Playing信息。

Pressure Scenarios

压力场景

Scenario 1: Apple Music Keeps Taking Over (24-Hour Launch Deadline)

场景1:Apple Music持续抢占Now Playing(24小时上线截止)

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
  • 应用明日上线
  • QA反馈:“Now Playing正常工作,但用户打开Apple Music后返回我们的应用,我们的控制项消失了”
  • 产品经理:“这是阻塞问题,用户会认为我们的应用坏了”
  • 距离代码冻结还有2小时

Rationalization Traps (DO NOT)

错误思路(禁止)

  1. "Just tell users not to use Apple Music" - Unacceptable UX, will get 1-star reviews
  2. "Force our app to always be Now Playing" - Impossible, system controls eligibility
  3. "File a bug with Apple" - Won't help before launch
  1. “告诉用户不要用Apple Music”——用户体验极差,会导致1星评价
  2. “强制我们的应用始终保持Now Playing状态”——不可能,系统控制资格
  3. “向苹果提交Bug”——上线前无法解决

Root Cause

根因

Your app loses eligibility because:
  • Using
    .mixWithOthers
    option (allows other apps to play simultaneously)
  • Not calling
    becomeActiveIfPossible()
    when returning to foreground
  • AVAudioSession deactivated when backgrounded
应用失去资格的原因:
  • 使用了
    .mixWithOthers
    选项(允许其他应用同时播放)
  • 回到前台时未调用
    becomeActiveIfPossible()
  • 后台时AVAudioSession已失活

Systematic Fix (30 minutes)

系统化修复(30分钟)

swift
// 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 }
    }
}
swift
// 1. 移除mixWithOthers选项
try AVAudioSession.sharedInstance().setCategory(.playback, options: [])

// 2. 回到前台时重新激活会话
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. 处理中断(来电、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 {
        // ✅ 中断结束后重新激活
        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 controls
致产品经理:已找到根因——我们的音频会话配置允许Apple Music抢占。
修复方案:对音频会话处理进行3处修改。
测试情况:已与Apple Music、Spotify、来电场景验证修复有效。
预计完成时间:20分钟全量回归测试。

致QA:请测试以下流程:
1. 在我们的应用中播放音频
2. 打开Apple Music,播放歌曲
3. 返回我们的应用,点击播放
4. 锁屏应显示我们的控制项

Time Saved

节省时间

  • 2-3 hours of debugging speculation
  • Launch delay avoided
  • QA confidence restored

  • 避免2-3小时的无目标调试
  • 避免上线延迟
  • 恢复QA信心

Scenario 2: Artwork Flickers Every Track Change

场景2:曲目切换时封面闪烁

Situation

场景

  • User feedback: "Album art keeps flashing when songs change"
  • Analytics show 3-4 artwork updates per track change
  • Designer: "This looks unprofessional"
  • 用户反馈:“切换歌曲时专辑封面持续闪烁”
  • 数据分析显示每切换一次曲目,封面更新3-4次
  • 设计师:“这看起来很不专业”

Root Cause

根因

Multiple artwork sources racing:
  1. Cache check (async)
  2. Remote URL fetch (async)
  3. Embedded artwork extraction (async)
All three complete at different times, each updating Now Playing
多封面源竞态:
  1. 缓存检查(异步)
  2. 远程URL获取(异步)
  3. 内嵌封面提取(异步)
三个操作完成时间不同,各自更新Now Playing信息

Fix (20 minutes)

修复(20分钟)

swift
// ✅ 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)
}
swift
// ✅ 单一数据源+任务取消
private var artworkTask: Task<Void, Never>?

func loadArtwork(for track: Track) {
    // 取消之前的封面加载任务
    artworkTask?.cancel()

    artworkTask = Task { @MainActor in
        // 立即清除之前的封面(可选)
        // updateNowPlayingArtwork(nil)

        // 等待最优可用封面
        let artwork = await loadBestArtwork(for: track)

        // 检查是否仍为当前曲目
        guard !Task.isCancelled else { return }

        // 单次更新
        updateNowPlayingArtwork(artwork, for: track.artworkURL)
    }
}

private func loadBestArtwork(for track: Track) async -> UIImage? {
    // 优先级:内嵌 > 缓存 > 远程
    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.
致设计师:已修复封面闪烁问题——每切换一次曲目,封面更新次数从3-4次减少到1次。
根因:多个异步源竞态更新封面。
解决方案:任务取消+优先级顺序(内嵌>缓存>远程)。
测试情况:已验证10次曲目切换,无闪烁。

Time Saved

节省时间

  • 1-2 hours investigating image caching
  • Designer approval unblocked
  • Professional UX restored

  • 避免1-2小时的图片缓存排查
  • 解除设计师阻塞
  • 恢复专业用户体验

Common Gotchas

常见陷阱

SymptomCauseSolutionTime to Fix
Info never appearsMissing background modeAdd
audio
to UIBackgroundModes in Info.plist
2 min
Info never appearsAVAudioSession not activatedCall
setActive(true)
before playback
5 min
Info never appearsNo command handlersAdd target to at least one command10 min
Info never appearsUsing
.mixWithOthers
Remove .mixWithOthers option5 min
Commands grayed out
isEnabled = false
Set
command.isEnabled = true
after adding target
5 min
Commands don't respondHandler returns wrong statusReturn
.success
from handler
5 min
Commands don't respondUsing shared command center with MPNowPlayingSessionUse
session.remoteCommandCenter
instead
10 min
Skip buttons missingNo preferredIntervalsSet
skipCommand.preferredIntervals = [15.0]
5 min
Artwork never appearsMPMediaItemArtwork block returns nilEnsure image is loaded before creating artwork15 min
Artwork flickersMultiple rapid updatesSingle source of truth with cancellation20 min
Wrong play/pause stateUsing
playbackState
property
Use
playbackRate
(1.0 = playing, 0.0 = paused)
10 min
Progress bar stuckNot updating on seekUpdate
elapsedPlaybackTime
after seek completes
10 min
Progress bar jumpsUpdating elapsed on timerDon't update on timer; system infers from rate10 min
Loses Now Playing to other appsSession not reactivated on foregroundCall
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 preferredIntervalsHardcoded interval in handlerUse
event.interval
from MPSkipIntervalCommandEvent
5 min
CarPlay: App doesn't appearMissing entitlementAdd
com.apple.developer.carplay-audio
to entitlements
5 min
CarPlay: Custom buttons don't appearConfigured at wrong timeConfigure at
templateApplicationScene(_:didConnect:)
5 min
CarPlay: Works on device, not simulatorDebugger attachedRun without debugger for reliable testing1 min
MusicKit: Now Playing wrongOverwriting automatic dataDon't set
nowPlayingInfo
when using ApplicationMusicPlayer
5 min

症状原因解决方案修复耗时
信息完全不显示缺失后台模式在Info.plist中添加
audio
到UIBackgroundModes
2分钟
信息完全不显示AVAudioSession未激活播放前调用
setActive(true)
5分钟
信息完全不显示无命令处理器为至少一个命令添加目标处理器10分钟
信息完全不显示使用
.mixWithOthers
移除.mixWithOthers选项5分钟
命令变灰
isEnabled = false
添加目标后设置
command.isEnabled = true
5分钟
命令无响应处理器返回错误状态处理器返回
.success
5分钟
命令无响应MPNowPlayingSession使用全局命令中心使用
session.remoteCommandCenter
替代
10分钟
跳过按钮不显示无preferredIntervals设置
skipCommand.preferredIntervals = [15.0]
5分钟
封面始终不显示MPMediaItemArtwork块返回nil确保创建封面时图片已加载完成15分钟
封面闪烁多次快速更新单一数据源+任务取消20分钟
播放/暂停状态错误使用
playbackState
属性
使用
playbackRate
(1.0=播放,0.0=暂停)
10分钟
进度条卡住拖动进度条后未更新拖动完成后更新
elapsedPlaybackTime
10分钟
进度条跳变定时更新已播放时长不要定时更新;系统自动推断10分钟
被其他应用抢占Now Playing前台时未重新激活会话前台时调用
becomeActiveIfPossible()
15分钟
playbackState
无效
iOS应用
playbackState
仅macOS支持;iOS使用
playbackRate
10分钟
Siri跳过忽略设置的间隔处理器中硬编码间隔使用MPSkipIntervalCommandEvent的
event.interval
5分钟
CarPlay:应用不显示缺失权限添加
com.apple.developer.carplay-audio
权限
5分钟
CarPlay:自定义按钮不显示配置时机错误
templateApplicationScene(_:didConnect:)
时配置
5分钟
CarPlay:真机正常模拟器异常附加调试器不附加调试器运行以获得可靠测试结果1分钟
MusicKit:Now Playing显示错误覆盖自动发布的数据使用ApplicationMusicPlayer时不要手动设置
nowPlayingInfo
5分钟

Expert Checklist

专家检查清单

Before Implementing Now Playing

实现Now Playing前

  • Added
    audio
    to UIBackgroundModes in Info.plist
  • AVAudioSession category is
    .playback
    without
    .mixWithOthers
  • Decided: Manual (MPNowPlayingInfoCenter) or Automatic (MPNowPlayingSession)?
  • 在Info.plist中添加
    audio
    到UIBackgroundModes
  • AVAudioSession类别为
    .playback
    且未设置
    .mixWithOthers
  • 确定实现方案:手动(MPNowPlayingInfoCenter)还是自动(MPNowPlayingSession)?

AVAudioSession Setup

AVAudioSession设置

  • 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)
  • 应用启动时调用
    setCategory(.playback)
  • 播放开始前调用
    setActive(true)
  • 停止播放时调用
    setActive(false, options: .notifyOthersOnDeactivation)
  • 处理中断通知(来电后重新激活)
  • 处理前台通知(后台返回后重新激活)

Remote Commands

远程命令

  • At least one command has target registered
  • All registered commands have
    isEnabled = true
  • Skip commands have
    preferredIntervals
    set
  • Handlers return
    .success
    on success
  • Using correct command center (session's vs shared)
  • Command targets stored to prevent deallocation
  • Commands removed in deinit
  • 至少一个命令已注册目标处理器
  • 所有已注册命令设置
    isEnabled = true
  • 跳过命令已设置
    preferredIntervals
  • 处理器成功时返回
    .success
  • 使用正确的命令中心(会话级vs全局共享)
  • 保存命令目标以避免被释放
  • 销毁时移除命令处理器

Now Playing Info

Now Playing信息

  • 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
    playbackState
    property (macOS only)
  • NOT updating elapsed time on a timer
  • 设置标题(
    MPMediaItemPropertyTitle
  • 设置时长(
    MPMediaItemPropertyPlaybackDuration
  • 播放/暂停/拖动进度条时设置已播放时长(
    MPNowPlayingInfoPropertyElapsedPlaybackTime
  • 设置播放速率(
    MPNowPlayingInfoPropertyPlaybackRate
    :1.0=播放,0.0=暂停)
  • 使用
    MPMediaItemArtwork(boundsSize:requestHandler:)
    创建封面
  • 不使用
    playbackState
    属性(仅macOS支持)
  • 不定时更新已播放时长

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
  • 图片至少600x600像素
  • MPMediaItemArtwork块永不返回nil(必要时返回占位符)
  • 单一数据源避免闪烁
  • 曲目切换时取消之前的封面加载任务

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
  • 锁屏显示正确信息
  • 控制中心显示正确信息
  • 播放/暂停按钮响应
  • 跳过按钮显示并响应
  • 进度条正常移动
  • 应用后台/前台切换后正常
  • 来电中断后正常
  • 其他应用播放音频后正常
  • 测试与Apple Music的冲突场景
  • 测试与Spotify的冲突场景

CarPlay (if applicable)

CarPlay(如适用)

  • Added
    com.apple.developer.carplay-audio
    entitlement
  • CPNowPlayingTemplate configured at
    templateApplicationScene(_:didConnect:)
  • Custom buttons (if any) configured with CPNowPlayingButton
  • Tested on CarPlay simulator (I/O → External Displays → CarPlay)
  • Tested in real vehicle (if available)
  • Tested both with and without debugger attached

  • 添加
    com.apple.developer.carplay-audio
    权限
  • templateApplicationScene(_:didConnect:)
    时配置CPNowPlayingTemplate
  • 自定义按钮(如有)使用CPNowPlayingButton配置
  • 在CarPlay模拟器中测试(I/O → External Displays → CarPlay)
  • 在真实车辆中测试(如有)
  • 测试附加/不附加调试器的情况

Resources

资源

WWDC: 2022-110338, 2017-251, 2019-501
Docs: /mediaplayer/mpnowplayinginfocenter, /mediaplayer/mpremotecommandcenter, /mediaplayer/mpnowplayingsession
Skills: axiom-avfoundation-ref, axiom-now-playing-carplay, axiom-now-playing-musickit

Last Updated: 2026-01-04 Status: iOS 18+ discipline skill covering Now Playing, CarPlay, and MusicKit integration Tested: Based on WWDC 2019-501, WWDC 2022-110338 patterns
WWDC:2022-110338, 2017-251, 2019-501
文档:/mediaplayer/mpnowplayinginfocenter, /mediaplayer/mpremotecommandcenter, /mediaplayer/mpnowplayingsession
技能:axiom-avfoundation-ref, axiom-now-playing-carplay, axiom-now-playing-musickit

最后更新:2026-01-04 状态:iOS 18+专属技能,覆盖Now Playing、CarPlay和MusicKit集成 测试依据:基于WWDC 2019-501、WWDC 2022-110338的实现模式