axiom-now-playing
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseNow 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:
- Register handlers for at least one remote command
- Configure AVAudioSession with a non-mixable category
"应用要具备Now Playing资格,需满足三个条件:AVAudioSession激活、远程命令处理器注册、元数据发布。缺少任意一项都会导致整个系统静默失效。90%的Now Playing问题源于激活顺序错误或命令处理器缺失,而非API本身的Bug。"
WWDC 2022/110338关键要点:应用必须满足两项系统规则:
- 为至少一个远程命令注册处理器
- 为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)
- property doesn't update (iOS doesn't have
playbackState, macOS only!)playbackState
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状态(未满足资格要求)
- 属性不更新(iOS没有
playbackState属性,仅macOS支持!)playbackState
禁止做出以下假设:
- “只要设置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:
| 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 |
调试前运行以下代码了解当前状态:
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为nil | MPMediaItemArtwork块返回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 : The closure passed to may be called by the system from any thread. Under Swift 6 strict concurrency, accessing -isolated stored properties from this closure would cause a compile error. Capturing the image value directly is cleaner than using because UIImage is immutable and thread-safe for reads.
nonisolated(unsafe)MPMediaItemArtwork@MainActornonisolated(unsafe)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)
}
}
}为什么使用值捕获而非:传递给的闭包可能被系统在任意线程调用。在Swift 6严格并发规则下,从该闭包访问隔离的存储属性会导致编译错误。直接捕获图片值比使用更简洁,因为UIImage是不可变的,读取操作线程安全。
nonisolated(unsafe)MPMediaItemArtwork@MainActornonisolated(unsafe)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信息更新时机
| 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 |
| 事件 | 更新内容 |
|---|---|
| 播放开始 | 所有元数据 + 已播放时长=当前值 + 速率=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 , NOT
session.remoteCommandCenterMPRemoteCommandCenter.shared()swift
// ❌ WRONG
let commandCenter = MPRemoteCommandCenter.shared()
commandCenter.playCommand.addTarget { _ in }
// ✅ CORRECT
session.remoteCommandCenter.playCommand.addTarget { _ in }使用MPNowPlayingSession时:使用,而非
session.remoteCommandCenterMPRemoteCommandCenter.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-carplayKey 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-musickitKey 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)
错误思路(禁止)
- "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
- “告诉用户不要用Apple Music”——用户体验极差,会导致1星评价
- “强制我们的应用始终保持Now Playing状态”——不可能,系统控制资格
- “向苹果提交Bug”——上线前无法解决
Root Cause
根因
Your app loses eligibility because:
- Using option (allows other apps to play simultaneously)
.mixWithOthers - Not calling when returning to foreground
becomeActiveIfPossible() - 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:
- Cache check (async)
- Remote URL fetch (async)
- Embedded artwork extraction (async)
All three complete at different times, each updating Now Playing
多封面源竞态:
- 缓存检查(异步)
- 远程URL获取(异步)
- 内嵌封面提取(异步)
三个操作完成时间不同,各自更新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
常见陷阱
| Symptom | Cause | Solution | Time to Fix |
|---|---|---|---|
| Info never appears | Missing background mode | Add | 2 min |
| Info never appears | AVAudioSession not activated | Call | 5 min |
| Info never appears | No command handlers | Add target to at least one command | 10 min |
| Info never appears | Using | Remove .mixWithOthers option | 5 min |
| Commands grayed out | | Set | 5 min |
| Commands don't respond | Handler returns wrong status | Return | 5 min |
| Commands don't respond | Using shared command center with MPNowPlayingSession | Use | 10 min |
| Skip buttons missing | No preferredIntervals | Set | 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 | Use | 10 min |
| Progress bar stuck | Not updating on seek | Update | 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 | 15 min |
| iOS-only app | | 10 min |
| Siri skip ignores preferredIntervals | Hardcoded interval in handler | Use | 5 min |
| CarPlay: App doesn't appear | Missing entitlement | Add | 5 min |
| CarPlay: Custom buttons don't appear | Configured at wrong time | Configure at | 5 min |
| CarPlay: Works on device, not simulator | Debugger attached | Run without debugger for reliable testing | 1 min |
| MusicKit: Now Playing wrong | Overwriting automatic data | Don't set | 5 min |
| 症状 | 原因 | 解决方案 | 修复耗时 |
|---|---|---|---|
| 信息完全不显示 | 缺失后台模式 | 在Info.plist中添加 | 2分钟 |
| 信息完全不显示 | AVAudioSession未激活 | 播放前调用 | 5分钟 |
| 信息完全不显示 | 无命令处理器 | 为至少一个命令添加目标处理器 | 10分钟 |
| 信息完全不显示 | 使用 | 移除.mixWithOthers选项 | 5分钟 |
| 命令变灰 | | 添加目标后设置 | 5分钟 |
| 命令无响应 | 处理器返回错误状态 | 处理器返回 | 5分钟 |
| 命令无响应 | MPNowPlayingSession使用全局命令中心 | 使用 | 10分钟 |
| 跳过按钮不显示 | 无preferredIntervals | 设置 | 5分钟 |
| 封面始终不显示 | MPMediaItemArtwork块返回nil | 确保创建封面时图片已加载完成 | 15分钟 |
| 封面闪烁 | 多次快速更新 | 单一数据源+任务取消 | 20分钟 |
| 播放/暂停状态错误 | 使用 | 使用 | 10分钟 |
| 进度条卡住 | 拖动进度条后未更新 | 拖动完成后更新 | 10分钟 |
| 进度条跳变 | 定时更新已播放时长 | 不要定时更新;系统自动推断 | 10分钟 |
| 被其他应用抢占Now Playing | 前台时未重新激活会话 | 前台时调用 | 15分钟 |
| iOS应用 | | 10分钟 |
| Siri跳过忽略设置的间隔 | 处理器中硬编码间隔 | 使用MPSkipIntervalCommandEvent的 | 5分钟 |
| CarPlay:应用不显示 | 缺失权限 | 添加 | 5分钟 |
| CarPlay:自定义按钮不显示 | 配置时机错误 | 在 | 5分钟 |
| CarPlay:真机正常模拟器异常 | 附加调试器 | 不附加调试器运行以获得可靠测试结果 | 1分钟 |
| MusicKit:Now Playing显示错误 | 覆盖自动发布的数据 | 使用ApplicationMusicPlayer时不要手动设置 | 5分钟 |
Expert Checklist
专家检查清单
Before Implementing Now Playing
实现Now Playing前
- Added to UIBackgroundModes in Info.plist
audio - AVAudioSession category is without
.playback.mixWithOthers - Decided: Manual (MPNowPlayingInfoCenter) or Automatic (MPNowPlayingSession)?
- 在Info.plist中添加到UIBackgroundModes
audio - AVAudioSession类别为且未设置
.playback.mixWithOthers - 确定实现方案:手动(MPNowPlayingInfoCenter)还是自动(MPNowPlayingSession)?
AVAudioSession Setup
AVAudioSession设置
- called at app launch
setCategory(.playback) - called before playback starts
setActive(true) - on stop
setActive(false, options: .notifyOthersOnDeactivation) - 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 set
preferredIntervals - Handlers return on success
.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 (: 1.0 = playing, 0.0 = paused)
MPNowPlayingInfoPropertyPlaybackRate - Artwork created with
MPMediaItemArtwork(boundsSize:requestHandler:) - NOT using property (macOS only)
playbackState - NOT updating elapsed time on a timer
- 设置标题()
MPMediaItemPropertyTitle - 设置时长()
MPMediaItemPropertyPlaybackDuration - 播放/暂停/拖动进度条时设置已播放时长()
MPNowPlayingInfoPropertyElapsedPlaybackTime - 设置播放速率(:1.0=播放,0.0=暂停)
MPNowPlayingInfoPropertyPlaybackRate - 使用创建封面
MPMediaItemArtwork(boundsSize:requestHandler:) - 不使用属性(仅macOS支持)
playbackState - 不定时更新已播放时长
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 entitlement
com.apple.developer.carplay-audio - 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 - 在时配置CPNowPlayingTemplate
templateApplicationScene(_:didConnect:) - 自定义按钮(如有)使用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的实现模式