axiom-now-playing by charleswiltgen/axiom
npx skills add https://github.com/charleswiltgen/axiom --skill axiom-now-playing目的:防止 iOS 18+ 上最常见的 4 种当前播放问题:信息不显示、命令不工作、专辑封面问题和状态同步问题
Swift 版本:Swift 6.0+ iOS 版本:iOS 18+ Xcode:Xcode 16+
"当前播放资格需要三件事协同工作:AVAudioSession 激活、远程命令处理程序和元数据发布。缺少其中任何一项都会静默地破坏整个系统。90% 的当前播放问题源于激活顺序不正确或缺少命令处理程序,而非 API 错误。"
来自 WWDC 2022/110338 的关键见解:应用必须满足两个系统启发式规则:
✅ 在以下情况使用此技能:
iOS 26 为锁定屏幕和控制中心的当前播放小组件引入了液态玻璃视觉设计。这是自动的系统行为——无需更改代码。此技能中的模式在 iOS 26 上仍然有效。
❌ 请勿将此技能用于:
广告位招租
在这里展示您的产品或服务
触达数万 AI 开发者,精准高效
如果你看到以下任何情况,请怀疑当前播放配置错误:
playbackState 属性不更新(iOS 没有 playbackState,仅 macOS 有!)禁止的假设:
在调试前运行此代码以了解当前状态:
// 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 类别,NOT .mixWithOthers 选项
// 2. 验证后台模式
// Info.plist 必须包含:UIBackgroundModes = ["audio"]
// 3. 检查命令处理程序是否已注册
let commandCenter = MPRemoteCommandCenter.shared()
print("Play enabled: \(commandCenter.playCommand.isEnabled)")
print("Pause enabled: \(commandCenter.pauseCommand.isEnabled)")
// 必须至少有一个命令具有目标 AND 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 | 不会成为当前播放应用 | 模式 1 |
| 没有命令具有目标 | 系统忽略应用 | 模式 2 |
| 命令有目标但 isEnabled = false | UI 呈灰色 | 模式 2 |
| 封面为 nil | MPMediaItemArtwork 块返回 nil | 模式 3 |
| 播放时 playbackRate 为 0.0 | 控制中心显示暂停 | 模式 4 |
| Info.plist 中没有后台模式 "audio" | 锁定后信息消失 | 模式 1 |
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)
时间成本:10-15 分钟
// ❌ 错误 — 类别允许混音,不会成为当前播放应用
class PlayerService {
func setupAudioSession() throws {
try AVAudioSession.sharedInstance().setCategory(
.playback,
options: .mixWithOthers // ❌ 可混音 = 不符合当前播放资格
)
// Never called setActive() // ❌ 会话未激活
}
func play() {
player.play()
updateNowPlaying() // ❌ 不会出现 - 会话未激活
}
}
// ✅ 正确 — 不可混音类别,播放前激活
class PlayerService {
func setupAudioSession() throws {
try AVAudioSession.sharedInstance().setCategory(
.playback,
mode: .default,
options: [] // ✅ 无 .mixWithOthers = 符合当前播放资格
)
}
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
)
}
}
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
</array>
时间成本:15-20 分钟
// ❌ 错误 — 缺少目标和 isEnabled
class PlayerService {
func setupCommands() {
let commandCenter = MPRemoteCommandCenter.shared()
// ❌ 添加了目标但忘记了 isEnabled
commandCenter.playCommand.addTarget { _ in
self.player.play()
return .success
}
// playCommand.isEnabled 默认为 false!
// ❌ 从未添加暂停处理程序
// ❌ skipForward 没有 preferredIntervals
commandCenter.skipForwardCommand.addTarget { _ in
return .success
}
}
}
// ✅ 正确 — 目标已注册、启用,具有正确配置
@MainActor
class PlayerService {
private var commandTargets: [Any] = [] // 保持强引用
func setupCommands() {
let commandCenter = MPRemoteCommandCenter.shared()
// ✅ 播放命令 - 添加目标 AND 启用
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()
}
}
时间成本:15-25 分钟
// ❌ 错误 — 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 - 闪烁!
}
}
// ✅ 正确 — 具有值捕获的正确 MPMediaItemArtwork(符合 Swift 6)
@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
// ✅ 系统从任何线程调用此块
// 捕获的值避免"主 actor 隔离属性"错误
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 是不可变的且读取是线程安全的。
时间成本:10-20 分钟
// ❌ 错误 — 使用 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
// ❌ 清除了所有其他值(封面、持续时间等)!
}
// ✅ 正确 — 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
// ✅ 通过速率播放状态(非 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)
}
}
}
| 事件 | 更新内容 |
|---|---|
| 播放开始 | 所有元数据 + elapsed=当前 + rate=1.0 |
| 播放暂停 | elapsed=当前 + rate=0.0 |
| 用户跳转 | elapsed=新位置(保持速率) |
| 曲目更改 | 所有元数据(新曲目) |
| 播放速率更改(2x,0.5x) | rate=新速率 |
时间成本:20-30 分钟
// ❌ 手动更新容易出错,容易错过状态更改
class OldStylePlayer {
func play() {
player.play()
// 必须记住:
updateNowPlayingElapsed()
updateNowPlayingRate()
// 很容易忘记一个...
}
}
// ✅ 正确 — 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?.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
// ✅ 尝试成为活动的当前播放会话
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 自动发布!
}
}
class MultiPlayerService {
var mainSession: MPNowPlayingSession
var pipSession: MPNowPlayingSession
func pipDidExpand() {
// ✅ 当画中画扩展到全屏时提升其会话
pipSession.becomeActiveIfPossible { success in
// 画中画现在控制锁定屏幕、控制中心
}
}
func pipDidMinimize() {
// ✅ 降级回主会话
mainSession.becomeActiveIfPossible { success in
// 主播放器现在控制锁定屏幕、控制中心
}
}
}
使用 MPNowPlayingSession 时:使用 session.remoteCommandCenter,而不是 MPRemoteCommandCenter.shared()
// ❌ 错误
let commandCenter = MPRemoteCommandCenter.shared()
commandCenter.playCommand.addTarget { _ in }
// ✅ 正确
session.remoteCommandCenter.playCommand.addTarget { _ in }
对于 CarPlay 特定的集成模式,请调用 /skill axiom-now-playing-carplay。
关键见解:CarPlay 使用与 iOS 相同的 MPNowPlayingInfoCenter 和 MPRemoteCommandCenter。如果你的当前播放在 iOS 上工作,它在 CarPlay 上无需额外代码即可工作。
对于 MusicKit 特定的集成模式和混合应用示例,请调用 /skill axiom-now-playing-musickit。
关键见解:MusicKit 的 ApplicationMusicPlayer 自动发布到 MPNowPlayingInfoCenter。播放 Apple Music 内容时,你无需手动更新当前播放信息。
你的应用失去资格是因为:
.mixWithOthers 选项(允许其他应用同时播放)becomeActiveIfPossible()// 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 }
}
}
致产品经理:找到根本原因 - 我们的音频会话配置允许 Apple Music 接管。
已实施修复:3 项音频会话处理更改。
测试:已使用 Apple Music、Spotify、电话验证修复。
预计完成时间:全面回归测试还需 20 分钟。
致 QA:请测试此流程:
1. 在我们的应用中播放音频
2. 打开 Apple Music,播放一首歌曲
3. 返回我们的应用,点击播放
4. 锁定屏幕应显示我们的控件
多个封面源竞争:
所有三个在不同时间完成,每个都更新当前播放
// ✅ 具有取消功能的单一事实来源
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)
}
致设计师:已修复封面闪烁 - 从每次曲目 3-4 次更新减少到 1 次。
根本原因:多个异步源竞争更新封面。
解决方案:任务取消 + 优先级顺序(嵌入 > 缓存 > 远程)。
测试:已验证 10 次曲目更改,零闪烁。
| 症状 | 原因 | 解决方案 | 修复时间 |
|---|---|---|---|
| 信息从未出现 | 缺少后台模式 | 在 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 分钟 |
| 当前播放被其他应用夺走 | 前台时会话未重新激活 | 前台时调用 becomeActiveIfPossible() |
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+
"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:
✅ Use this skill when :
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 :
If you see ANY of these, suspect Now Playing misconfiguration:
playbackState property doesn't update (iOS doesn't have playbackState, macOS only!)FORBIDDEN Assumptions:
Run this code to understand current state before debugging:
// 1. Verify AVAudioSession configuration
let session = AVAudioSession.sharedInstance()
print("Category: \(session.category.rawValue)")
print("Mode: \(session.mode.rawValue)")
print("Options: \(session.categoryOptions)")
print("Is active: \(try? session.setActive(true))")
// Must be: .playback category, NOT .mixWithOthers option
// 2. Verify background mode
// Info.plist must have: UIBackgroundModes = ["audio"]
// 3. Check command handlers are registered
let commandCenter = MPRemoteCommandCenter.shared()
print("Play enabled: \(commandCenter.playCommand.isEnabled)")
print("Pause enabled: \(commandCenter.pauseCommand.isEnabled)")
// Must have at least one command with target AND isEnabled = true
// 4. Check nowPlayingInfo dictionary
if let info = MPNowPlayingInfoCenter.default().nowPlayingInfo {
print("Title: \(info[MPMediaItemPropertyTitle] ?? "nil")")
print("Artwork: \(info[MPMediaItemPropertyArtwork] != nil)")
print("Duration: \(info[MPMediaItemPropertyPlaybackDuration] ?? "nil")")
print("Elapsed: \(info[MPNowPlayingInfoPropertyElapsedPlaybackTime] ?? "nil")")
print("Rate: \(info[MPNowPlayingInfoPropertyPlaybackRate] ?? "nil")")
} else {
print("No nowPlayingInfo set!")
}
What this tells you:
| Observation | Diagnosis | Pattern |
|---|---|---|
| Category is .ambient or has .mixWithOthers | Won't become Now Playing app | Pattern 1 |
| No commands have targets | System ignores app | Pattern 2 |
| Commands have targets but isEnabled = false | UI grayed out | Pattern 2 |
| Artwork is nil | MPMediaItemArtwork block returning nil | Pattern 3 |
| playbackRate is 0.0 when playing | Control Center shows paused | Pattern 4 |
| Background mode "audio" not in Info.plist | Info disappears on lock | Pattern 1 |
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)
Time cost : 10-15 minutes
// ❌ 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
}
}
// ✅ 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
)
}
}
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
</array>
Time cost : 15-20 minutes
// ❌ 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
}
}
}
// ✅ 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()
}
}
Time cost : 15-25 minutes
// ❌ 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!
}
}
// ✅ 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, notnonisolated(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.
Time cost : 10-20 minutes
// ❌ 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.)!
}
// ✅ 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)
}
}
}
| 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 |
Time cost : 20-30 minutes
// ❌ 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...
}
}
// ✅ 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!
}
}
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
}
}
}
When using MPNowPlayingSession : Use session.remoteCommandCenter, NOT MPRemoteCommandCenter.shared()
// ❌ WRONG
let commandCenter = MPRemoteCommandCenter.shared()
commandCenter.playCommand.addTarget { _ in }
// ✅ CORRECT
session.remoteCommandCenter.playCommand.addTarget { _ in }
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.
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.
Your app loses eligibility because:
.mixWithOthers option (allows other apps to play simultaneously)becomeActiveIfPossible() when returning to foreground// 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 }
}
}
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
Multiple artwork sources racing:
All three complete at different times, each updating Now Playing
// ✅ 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)
}
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.
| Symptom | Cause | Solution | Time to Fix |
|---|---|---|---|
| Info never appears | Missing background mode | Add audio to UIBackgroundModes in Info.plist | 2 min |
| Info never appears | AVAudioSession not activated | Call setActive(true) before playback | 5 min |
| Info never appears | No command handlers | Add target to at least one command | 10 min |
| Info never appears | Using .mixWithOthers | Remove .mixWithOthers option | 5 min |
| Commands grayed out |
audio to UIBackgroundModes in Info.plist.playback without .mixWithOtherssetCategory(.playback) called at app launchsetActive(true) called before playback startssetActive(false, options: .notifyOthersOnDeactivation) on stopisEnabled = truepreferredIntervals set.success on successMPMediaItemPropertyTitle)MPMediaItemPropertyPlaybackDuration)MPNowPlayingInfoPropertyElapsedPlaybackTime)MPNowPlayingInfoPropertyPlaybackRate: 1.0 = playing, 0.0 = paused)MPMediaItemArtwork(boundsSize:requestHandler:)playbackState property (macOS only)com.apple.developer.carplay-audio entitlementtemplateApplicationScene(_:didConnect:)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
Weekly Installs
90
Repository
GitHub Stars
606
First Seen
Jan 21, 2026
Security Audits
Gen Agent Trust HubPassSocketPassSnykPass
Installed on
opencode75
codex70
claude-code70
gemini-cli68
cursor68
github-copilot65
AI播客创作工具 - 使用inference.sh CLI快速生成AI驱动播客和音频内容
7,700 周安装
Salesforce SOQL查询专家:自然语言生成、优化与性能分析工具
468 周安装
文本转播客生成工具 - 一键将文章文档转换为高质量双主持人对话音频
472 周安装
专业简历生成器 - 使用 YAML 和 rendercv 创建精美 PDF 简历
479 周安装
TradingView量化投资分析系统 - 专业股票、加密货币、外汇行情分析与智能选股工具
476 周安装
Vitest 测试框架:现代 TypeScript/JavaScript 极速测试解决方案
471 周安装
React Native Testing Library (RNTL) 完整指南:v13/v14 版本差异、查询优先级与最佳实践
476 周安装
isEnabled = falseSet command.isEnabled = true after adding target |
| 5 min |
| Commands don't respond | Handler returns wrong status | Return .success from handler | 5 min |
| Commands don't respond | Using shared command center with MPNowPlayingSession | Use session.remoteCommandCenter instead | 10 min |
| Skip buttons missing | No preferredIntervals | Set skipCommand.preferredIntervals = [15.0] | 5 min |
| Artwork never appears | MPMediaItemArtwork block returns nil | Ensure image is loaded before creating artwork | 15 min |
| Artwork flickers | Multiple rapid updates | Single source of truth with cancellation | 20 min |
| Wrong play/pause state | Using playbackState property | Use playbackRate (1.0 = playing, 0.0 = paused) | 10 min |
| Progress bar stuck | Not updating on seek | Update elapsedPlaybackTime after seek completes | 10 min |
| Progress bar jumps | Updating elapsed on timer | Don't update on timer; system infers from rate | 10 min |
| Loses Now Playing to other apps | Session not reactivated on foreground | Call becomeActiveIfPossible() on foreground | 15 min |
playbackState doesn't work | iOS-only app | playbackState is macOS only; use playbackRate on iOS | 10 min |
| Siri skip ignores preferredIntervals | Hardcoded interval in handler | Use event.interval from MPSkipIntervalCommandEvent | 5 min |
| CarPlay : App doesn't appear | Missing entitlement | Add com.apple.developer.carplay-audio to entitlements | 5 min |
| CarPlay : Custom buttons don't appear | Configured at wrong time | Configure at templateApplicationScene(_:didConnect:) | 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 nowPlayingInfo when using ApplicationMusicPlayer | 5 min |