mcpField
A
axiom-now-playing

axiom-now-playing

github.com/charleswiltgen开发1.2k165 次安装今天更新
代码仓库

关于 axiom-now-playing

面向 iOS 开发者的 Now Playing 实现与诊断技能,覆盖音频会话资格、远程命令、封面和播放状态更新,并介绍 MPNowPlayingSession 的用法。

主要功能

  1. 01AVAudioSession 配置与激活排查
  2. 02远程命令注册与启用
  3. 03专辑封面配置与竞态处理
  4. 04播放状态和进度同步
  5. 05MPNowPlayingSession 使用模式

适用场景

  1. 01排查锁屏未显示播放信息
  2. 02修复控制中心按钮无响应
  3. 03诊断封面缺失或闪烁
  4. 04同步暂停、播放和进度状态

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/charleswiltgen/axiom --skill axiom-now-playing

安装后在 Claude Code 中输入/axiom-now-playing确认已加载。

详细介绍

正在播放集成指南

目的:避免 iOS 18+ 上最常见的 4 个“正在播放”问题:信息不显示、命令不起作用、封面问题和状态同步问题

Swift 版本:Swift 6.0+ iOS 版本:iOS 18+ Xcode:Xcode 16+

核心理念

“要符合‘正在播放’显示条件,必须同时满足三项:激活 AVAudioSession、处理远程命令以及发布元数据。其中任何一项缺失,都会导致整个系统悄无声息地失效。90% 的‘正在播放’问题源于激活顺序错误或缺少命令处理程序,而非 API 缺陷。”

WWDC 2022/110338 的关键见解:应用必须满足两项系统启发式要求:

  1. 为至少一个远程命令注册处理程序
  2. 将 AVAudioSession 配置为不可混音类别

何时使用此技能

✅ 在以下情况下使用此技能:

  • “正在播放”信息未显示在锁定屏幕或控制中心
  • 播放/暂停/跳过按钮呈灰色或没有响应
  • 专辑封面缺失、错误,或在不同图片间闪烁
  • 应用已暂停时,控制中心却显示“正在播放”,反之亦然
  • Apple Music 或其他应用“抢占”了“正在播放”状态
  • 首次实现“正在播放”功能
  • 调试现有实现中的“正在播放”问题
  • 集成 CarPlay“正在播放”功能(详见模式 6)
  • 使用 MusicKit/Apple Music 内容(详见模式 7)
iOS 26 说明

iOS 26 为锁定屏幕和控制中心的“正在播放”小组件引入了Liquid Glass 视觉设计。这是系统自动行为,无需更改代码。此技能中的模式仍适用于 iOS 26。

❌ 请勿在以下情况下使用此技能:

  • 配置后台音频的详细信息(请参阅 AVFoundation 技能)

相关技能

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

危险信号 / 反模式

如果发现以下任意情况,应怀疑“正在播放”配置不正确:

  • 信息短暂出现后消失(AVAudioSession 已停用)
  • 命令在模拟器上有效,但在设备上无效(模拟器使用不同的音频栈)
  • 封面先显示占位图,然后更新(竞态条件,不一定是错误)
  • 封面始终不显示(格式/尺寸问题,或 MPMediaItemArtwork 块返回 nil)
  • 进入后台后播放/暂停状态不正确(播放速率变化时未更新)
  • 其他应用“抢占”了“正在播放”(未满足资格要求)
  • playbackState 属性不更新(iOS 没有 playbackState,仅 macOS 有!)

禁止的假设:

  • “只要设置 nowPlayingInfo 就能正常工作”——还必须配置 AVAudioSession 并添加命令处理程序
  • “playbackState 控制控制中心”——iOS 会忽略 playbackState,而是使用 playbackRate
  • “有图片就能用作封面”——必须使用带尺寸处理程序的 MPMediaItemArtwork
  • “命令会自动启用”——必须添加目标并设置 isEnabled = true
  • “每秒更新一次已播放时间”——系统会根据速率推断,频繁更新会造成抖动

必须先执行的步骤(诊断前)

运行以下代码,先了解当前状态再进行调试:

// 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!")
}

此代码可提供以下信息:

观察结果诊断模式
类别为 .ambient 或包含 .mixWithOthers无法成为“正在播放”应用模式 1
没有命令设置目标系统会忽略该应用模式 2
命令已设置目标,但 isEnabled = false界面控件呈灰色模式 2
封面为 nilMPMediaItemArtwork 块返回 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)

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

耗时:10-15 分钟

症状
  • 锁定屏幕上始终不显示「正在播放」信息
  • 锁定屏幕时信息短暂出现,然后消失
  • 在前台运行正常,在后台运行时消失
错误代码
// ❌ 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
        )
    }
}
Info.plist 要求
<key>UIBackgroundModes</key>
<array>
    <string>audio</string>
</array>
验证
  • 锁定屏幕上显示「正在播放」控制项
  • 应用进入后台后,信息仍会保留
  • 切换应用后仍会保留(除非其他应用正在播放)

模式 2:远程命令注册(命令无法使用)

耗时:15-20 分钟

症状
  • 播放/暂停按钮显示为灰色
  • 按钮可见,但轻点后没有任何反应
  • 未显示跳过按钮
  • 命令生效一次后就失效
错误代码
// ❌ 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()
    }
}
验证
  • 控制中心中的按钮未显示为灰色
  • 轻点播放/暂停按钮后,确实会播放/暂停
  • 显示跳过按钮,且间隔正确(15 秒)

模式 3:封面配置(封面问题)

耗时:15-25 分钟

症状
  • 始终不显示封面(显示通用占位图)
  • 当前曲目显示了错误的封面
  • 封面在不同图片之间闪烁
  • 封面出现后消失
错误代码
// ❌ 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)
        }
    }
}

为什么要捕获值,而不是使用 nonisolated(unsafe):传递给 MPMediaItemArtwork 的闭包可能会由系统在任意线程上调用。在 Swift 6 严格并发模式下,从此闭包访问隔离于 @MainActor 的存储属性会导致编译错误。直接捕获图像值比使用 nonisolated(unsafe) 更简洁,因为 UIImage 不可变,且可安全地进行读取。

封面尺寸指南
  • 锁定屏幕:300x300 点(600x600 @2x,900x900 @3x)
  • 控制中心:尺寸不一
  • 最佳实践:提供至少 600x600 像素的图片
验证
  • 锁定屏幕上显示封面
  • 当前曲目显示正确的封面
  • 曲目切换时不闪烁
  • 应用进入后台后,封面仍会保留

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

耗时:10-20 分钟

症状
  • 实际已暂停,但控制中心显示「正在播放」
  • 进度条不移动或意外跳动
  • 时长显示错误
  • 拖动进度条无法正常工作
错误代码
// ❌ 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)
        }
    }
}
何时更新「正在播放」信息
事件更新内容
开始播放所有元数据 + elapsed=current + rate=1.0
暂停播放elapsed=current + rate=0.0
用户拖动进度条elapsed=newPosition(保持 rate 不变)
曲目切换所有元数据(新曲目)
播放速率变化(2x、0.5x)rate=newRate
不要更新
  • 在计时器触发时更新(系统会根据 elapsed、rate 和 timestamp 推算)
  • 持续更新经过时间(会导致抖动)
  • 部分字典(会丢失其他值)

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

耗时:20-30 分钟

何时使用 MPNowPlayingSession
  • 使用 iOS 16+(自 iOS 16 起提供,此前仅限 tvOS)
  • 使用 AVPlayer 播放
  • 希望自动发布播放状态
  • 使用多个播放器(画中画场景)
错误代码(手动方案——更容易出错)
// ❌ 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...
    }
}
正确代码(MPNowPlayingSession)
// ✅ CORRECT — MPNowPlayingSession handles automatic publishing
@MainActor
class ModernPlayerService {
    private var player: AVPlayer
    private var session: MPNowPlayingSession?

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

    func setupSession() {
        // ✅ Create session with player
        session = MPNowPlayingSession(players: [player])

        // ✅ Enable automatic publishing of:
        // - Duration
        // - Elapsed time
        // - Playback state (rate)
        // - Playback progress
        session?.automaticallyPublishNowPlayingInfo = true

        // ✅ Register commands on SESSION's command center (not shared)
        session?.remoteCommandCenter.playCommand.addTarget { [weak self] _ in
            self?.player.play()
            return .success
        }
        session?.remoteCommandCenter.playCommand.isEnabled = true

        session?.remoteCommandCenter.pauseCommand.addTarget { [weak self] _ in
            self?.player.pause()
            return .success
        }
        session?.remoteCommandCenter.pauseCommand.isEnabled = true

        // ✅ Try to become active Now Playing session
        session?.becomeActiveIfPossible { success in
            print("Became active Now Playing: \(success)")
        }
    }

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

        // ✅ Set static metadata on player item (title, artwork)
        item.nowPlayingInfo = [
            MPMediaItemPropertyTitle: track.title,
            MPMediaItemPropertyArtist: track.artist,
            MPMediaItemPropertyArtwork: await createArtwork(for: track)
        ]

        player.replaceCurrentItem(with: item)
        player.play()
        // ✅ No need to manually update elapsed time, rate, duration
        // MPNowPlayingSession publishes automatically!
    }
}
多个会话(画中画)
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
        }
    }
}
关键注意事项

使用 MPNowPlayingSession 时:使用 session.remoteCommandCenter,不要使用 MPRemoteCommandCenter.shared()

// ❌ WRONG
let commandCenter = MPRemoteCommandCenter.shared()
commandCenter.playCommand.addTarget { _ in }

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

模式 6:CarPlay 集成

如需了解 CarPlay 专属集成模式,请调用 /skill axiom-now-playing-carplay。

关键要点:CarPlay 使用的 MPNowPlayingInfoCenter 和 MPRemoteCommandCenter 与 iOS 完全相同。如果「正在播放」功能在 iOS 上正常工作,那么在 CarPlay 中也能正常工作,无需添加任何代码。


模式 7:MusicKit 集成(Apple Music)

如需了解 MusicKit 专属集成模式和混合应用示例,请调用 /skill axiom-now-playing-musickit。

关键要点:MusicKit 的 ApplicationMusicPlayer 会自动发布到 MPNowPlayingInfoCenter。播放 Apple Music 内容时,无需手动更新“正在播放”信息。


压力场景

场景 1:Apple Music 不断抢占控制权(距离上线还有 24 小时)
情况
  • 应用明天上线
  • QA 报告:“‘正在播放’功能正常,但用户打开 Apple Music 再返回我们的应用时,控制项就消失了”
  • 产品经理:“这是个阻碍,上线后用户会觉得我们的应用坏了”
  • 距离代码冻结还有 2 小时
合理化陷阱(请勿这样做)
  1. “就告诉用户别用 Apple Music”——用户体验不可接受,会收到 1 星评价
  2. “强制我们的应用始终显示为‘正在播放’”——不可能,系统会决定哪些应用符合控制资格
  3. “向 Apple 提交 Bug”——赶不上上线前解决
根本原因

你的应用失去了资格,原因如下:

  • 使用了 .mixWithOthers 选项(允许其他应用同时播放)
  • 返回前台时未调用 becomeActiveIfPossible()
  • 应用在后台时,AVAudioSession 已停用
系统性修复(30 分钟)
// 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
节省时间
  • 减少 2-3 小时的猜测性调试
  • 避免延迟上线
  • 恢复 QA 的信心

场景 2:每次切换曲目时专辑封面闪烁
情况
  • 用户反馈:“歌曲切换时专辑封面一直闪”
  • 分析数据显示,每次切换曲目都会更新专辑封面 3-4 次
  • 设计师:“看起来很不专业”
根本原因

多个专辑封面来源同时争抢:

  1. 缓存检查(异步)
  2. 远程 URL 获取(异步)
  3. 内嵌专辑封面提取(异步)

三者完成时间各不相同,都会更新“正在播放”信息

修复(20 分钟)
// ✅ 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.
节省时间
  • 减少 1-2 小时的图片缓存调查
  • 不再阻碍设计师审批
  • 恢复专业的用户体验

常见问题

症状原因解决方案修复时间
信息始终不显示缺少后台模式将 audio 添加到 Info.plist 中的 UIBackgroundModes2 min
信息始终不显示AVAudioSession 未激活在播放前调用 setActive(true)5 min
信息始终不显示没有命令处理程序至少为一个命令添加目标10 min
信息始终不显示使用了 .mixWithOthers移除 .mixWithOthers 选项5 min
命令呈灰色isEnabled = false添加目标后设置 command.isEnabled = true5 min
命令没有响应处理程序返回了错误状态从处理程序返回 .success5 min
命令没有响应使用了与 MPNowPlayingSession 共用的命令中心改用 session.remoteCommandCenter10 min
缺少跳转按钮未设置 preferredIntervals设置 skipCommand.preferredIntervals = [15.0]5 min
封面始终不显示MPMediaItemArtwork 块返回 nil确保在创建封面前已加载图像15 min
封面闪烁多次快速更新使用单一事实来源并进行取消20 min
播放/暂停状态错误使用了 playbackState 属性使用 playbackRate(1.0 = 正在播放,0.0 = 已暂停)10 min
进度条卡住跳转时未更新跳转完成后更新 elapsedPlaybackTime10 min
进度条跳动在计时器上更新 elapsed不要在计时器上更新;系统会根据速率推算10 min
当前播放信息被其他应用抢占前台时未重新激活会话前台时调用 becomeActiveIfPossible()15 min
playbackState 不起作用仅适用于 iOS 的应用playbackState 仅适用于 macOS;在 iOS 上使用 playbackRate10 min
Siri 跳转忽略 preferredIntervals处理程序中使用了硬编码间隔使用 MPSkipIntervalCommandEvent 中的 event.interval5 min
CarPlay:应用未显示缺少权利将 com.apple.developer.carplay-audio 添加到 entitlements5 min
CarPlay:自定义按钮未显示配置时间不正确在 templateApplicationScene(_:didConnect:) 中进行配置5 min
CarPlay:设备上正常,模拟器上不正常调试器已连接为确保测试可靠,请在不连接调试器的情况下运行1 min
MusicKit:当前播放信息错误覆盖了自动数据使用 ApplicationMusicPlayer 时,不要设置 nowPlayingInfo5 min

专家检查清单

实现当前播放功能之前
  • 已将 audio 添加到 Info.plist 中的 UIBackgroundModes
  • AVAudioSession 类别为 .playback,且未使用 .mixWithOthers
  • 已决定使用手动方式(MPNowPlayingInfoCenter)还是自动方式(MPNowPlayingSession)?
AVAudioSession 设置
  • 已在应用启动时调用 setCategory(.playback)
  • 已在开始播放前调用 setActive(true)
  • 停止时调用 setActive(false, options: .notifyOthersOnDeactivation)
  • 已处理中断通知(电话结束后重新激活)
  • 已处理前台通知(从后台返回后重新激活)
远程命令
  • 至少有一个命令已注册目标
  • 所有已注册的命令都设置了 isEnabled = true
  • 已为跳转命令设置 preferredIntervals
  • 处理程序成功时会返回 .success
  • 使用了正确的命令中心(会话的命令中心还是共用的命令中心)
  • 命令目标已存储,以防止被释放
  • 已在 deinit 中移除命令
当前播放信息
  • 已设置标题(MPMediaItemPropertyTitle)
  • 已设置时长(MPMediaItemPropertyPlaybackDuration)
  • 已在播放/暂停/跳转时设置经过时间(MPNowPlayingInfoPropertyElapsedPlaybackTime)
  • 已设置播放速率(MPNowPlayingInfoPropertyPlaybackRate:1.0 = 正在播放,0.0 = 已暂停)
  • 已使用 MPMediaItemArtwork(boundsSize:requestHandler:) 创建封面
  • 未使用 playbackState 属性(仅适用于 macOS)
  • 未在计时器上更新经过时间
封面图片
  • 图片至少为 600x600 像素
  • MPMediaItemArtwork 代码块永不返回 nil(必要时返回占位图片)
  • 使用唯一事实来源以避免闪烁
  • 切换曲目时取消之前的封面图片加载
测试
  • 锁定屏幕显示正确的信息
  • 控制中心显示正确的信息
  • 播放/暂停按钮可正常响应
  • 跳过按钮正常显示并响应
  • 进度条正确移动
  • 应用在后台/前台切换后仍能正常工作
  • 接听电话造成中断后仍能正常工作
  • 其他应用播放音频后仍能正常工作
  • 已测试与 Apple Music 的冲突
  • 已测试与 Spotify 的冲突
CarPlay(如适用)
  • 已添加 com.apple.developer.carplay-audio 权限
  • 已在 templateApplicationScene(_:didConnect:) 中配置 CPNowPlayingTemplate
  • 已使用 CPNowPlayingButton 配置自定义按钮(如有)
  • 已在 CarPlay 模拟器中测试(I/O → External Displays → CarPlay)
  • 已在真实车辆中测试(如有)
  • 已分别在连接和未连接调试器的情况下测试

资源

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 状态:涵盖 Now Playing、CarPlay 和 MusicKit 集成的 iOS 18+ 规范技能 测试依据:基于 WWDC 2019-501、WWDC 2022-110338 中的模式

评论

支持 Markdown