mcpField
A
axiom-now-playing

axiom-now-playing

github.com/charleswiltgen開発1.2k165 回インストール今日更新
リポジトリ

axiom-now-playing について

iOSのNow Playing機能を実装・診断するためのSwiftスキルです。オーディオセッション、リモートコマンド、アートワーク、再生状態の更新と、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を入力して読み込まれたか確認します。

詳細

再生中(Now Playing)統合ガイド

目的: iOS 18 以降でよくある再生中(Now Playing)の問題を4つ防ぎます。情報が表示されない、コマンドが動作しない、アートワークの問題、状態同期の問題です。

Swift バージョン: Swift 6.0+ iOS バージョン: iOS 18+ Xcode: Xcode 16+

基本理念

「再生中(Now Playing)の対象となるには、AVAudioSession の有効化、リモートコマンドハンドラー、メタデータの公開という3つすべてが連携して動作する必要があります。どれか1つでも欠けると、システム全体が何も通知せずに機能しなくなります。再生中(Now Playing)の問題の90%は、APIのバグではなく、有効化の順序が間違っているか、コマンドハンドラーが欠けていることが原因です。」

WWDC 2022/110338 から得られる重要な知見: アプリは次の2つのシステムヒューリスティックを満たす必要があります。

  1. 少なくとも1つのリモートコマンドにハンドラーを登録する
  2. AVAudioSession をミックス不可のカテゴリで設定する

このスキルを使用する場面

✅ 次の場合にこのスキルを使用してください:

  • 再生中(Now Playing)の情報がロック画面やコントロールセンターに表示されない
  • 再生/一時停止/スキップボタンがグレー表示される、または反応しない
  • アルバムアートワークが表示されない、間違っている、または画像がちらつく
  • アプリを一時停止しているのにコントロールセンターに「再生中」と表示される、またはその逆
  • Apple Music や他のアプリが再生中(Now Playing)のステータスを「奪う」
  • 初めて再生中(Now Playing)を実装する
  • 既存の実装で再生中(Now Playing)の問題をデバッグする
  • CarPlay の再生中(Now Playing)を統合する(パターン6で説明)
  • MusicKit/Apple Music のコンテンツを扱う(パターン7で説明)
iOS 26 に関する注意

iOS 26 では、ロック画面とコントロールセンターの再生中(Now Playing)ウィジェットにLiquid Glass のビジュアルデザインが導入されます。これはシステムによる自動動作であり、コードの変更は不要です。このスキルのパターンは iOS 26 でも引き続き有効です。

❌ 次の場合はこのスキルを使用しないでください:

  • バックグラウンドオーディオの設定の詳細(AVFoundation スキルを参照)

関連スキル

  • swift-concurrency - @MainActor のパターン、クロージャ内の weak self、アートワークの非同期読み込みについて
  • memory-debugging - コマンドハンドラーの保持サイクルについて
  • avfoundation-ref - AVAudioSession の設定の詳細について

危険信号/アンチパターン

次のいずれかが見られる場合は、再生中(Now Playing)の設定ミスを疑ってください。

  • 情報が一時的に表示された後に消える(AVAudioSession が無効化されている)
  • シミュレーターではコマンドが動作するのに、デバイスでは動作しない(シミュレーターは異なるオーディオスタックを使用する)
  • アートワークがプレースホルダーから更新される(競合状態。必ずしも誤りではありません)
  • アートワークがまったく表示されない(形式/サイズの問題、または MPMediaItemArtwork ブロックが nil を返している)
  • バックグラウンドに移行した後、再生/一時停止状態が正しくない(再生レートの変化時に更新していない)
  • 別のアプリが再生中(Now Playing)を「奪う」(対象となるための要件を満たしていない)
  • 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 を含む再生中(Now Playing)アプリとして認識されませんパターン1
どのコマンドにもターゲットがないシステムがアプリを無視しますパターン2
コマンドにターゲットはあるが、isEnabled = falseUI がグレー表示されますパターン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分

症状
  • ロック画面に「再生中」情報が表示されない
  • ロックすると情報が一瞬表示された後、消える
  • フォアグラウンドでは動作するが、バックグラウンドでは消える
BAD Code
// ❌ WRONG — Category allows mixing, won't become Now Playing app
class PlayerService {
    func setupAudioSession() throws {
        try AVAudioSession.sharedInstance().setCategory(
            .playback,
            options: .mixWithOthers  // ❌ Mixable = not eligible for Now Playing
        )
        // Never called setActive()  // ❌ Session not activated
    }

    func play() {
        player.play()
        updateNowPlaying()  // ❌ Won't appear - session not active
    }
}
GOOD Code
// ✅ CORRECT — Non-mixable category, activated before playback
class PlayerService {
    func setupAudioSession() throws {
        try AVAudioSession.sharedInstance().setCategory(
            .playback,
            mode: .default,
            options: []  // ✅ No .mixWithOthers = eligible for Now Playing
        )
    }

    func play() async throws {
        // ✅ Activate BEFORE starting playback
        try AVAudioSession.sharedInstance().setActive(true)

        player.play()
        updateNowPlaying()  // ✅ Now appears correctly
    }

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

        // ✅ Deactivate AFTER stopping, with notify option
        try AVAudioSession.sharedInstance().setActive(
            false,
            options: .notifyOthersOnDeactivation
        )
    }
}
Info.plist の要件
<key>UIBackgroundModes</key>
<array>
    <string>audio</string>
</array>
確認
  • ロック画面に「再生中」のコントロールが表示される
  • アプリをバックグラウンドにしても情報が保持される
  • アプリを切り替えても維持される(別のアプリで再生を開始した場合を除く)

パターン 2:リモートコマンドの登録(コマンドが動作しない)

所要時間:15~20 分

症状
  • 再生/一時停止ボタンがグレー表示になる
  • ボタンは表示されるが、タップしても何も起こらない
  • スキップボタンが表示されない
  • コマンドが一度動作した後、動かなくなる
BAD Code
// ❌ WRONG — Missing targets and isEnabled
class PlayerService {
    func setupCommands() {
        let commandCenter = MPRemoteCommandCenter.shared()

        // ❌ Added target but forgot isEnabled
        commandCenter.playCommand.addTarget { _ in
            self.player.play()
            return .success
        }
        // playCommand.isEnabled defaults to false!

        // ❌ Never added pause handler

        // ❌ skipForward without preferredIntervals
        commandCenter.skipForwardCommand.addTarget { _ in
            return .success
        }
    }
}
GOOD Code
// ✅ CORRECT — Targets registered, enabled, with proper configuration
@MainActor
class PlayerService {
    private var commandTargets: [Any] = []  // Keep strong references

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

        // ✅ Play command - add target AND enable
        let playTarget = commandCenter.playCommand.addTarget { [weak self] _ in
            self?.player.play()
            self?.updateNowPlayingPlaybackState(isPlaying: true)
            return .success
        }
        commandCenter.playCommand.isEnabled = true
        commandTargets.append(playTarget)

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

        // ✅ Skip forward - set preferredIntervals BEFORE adding target
        commandCenter.skipForwardCommand.preferredIntervals = [15.0]
        let skipForwardTarget = commandCenter.skipForwardCommand.addTarget { [weak self] event in
            guard let skipEvent = event as? MPSkipIntervalCommandEvent else {
                return .commandFailed
            }
            self?.skip(by: skipEvent.interval)
            return .success
        }
        commandCenter.skipForwardCommand.isEnabled = true
        commandTargets.append(skipForwardTarget)

        // ✅ Skip backward
        commandCenter.skipBackwardCommand.preferredIntervals = [15.0]
        let skipBackwardTarget = commandCenter.skipBackwardCommand.addTarget { [weak self] event in
            guard let skipEvent = event as? MPSkipIntervalCommandEvent else {
                return .commandFailed
            }
            self?.skip(by: -skipEvent.interval)
            return .success
        }
        commandCenter.skipBackwardCommand.isEnabled = true
        commandTargets.append(skipBackwardTarget)
    }

    func teardownCommands() {
        let commandCenter = MPRemoteCommandCenter.shared()
        commandCenter.playCommand.removeTarget(nil)
        commandCenter.pauseCommand.removeTarget(nil)
        commandCenter.skipForwardCommand.removeTarget(nil)
        commandCenter.skipBackwardCommand.removeTarget(nil)
        commandTargets.removeAll()
    }

    deinit {
        teardownCommands()
    }
}
確認
  • コントロールセンターのボタンがグレー表示になっていない
  • 再生/一時停止をタップすると、実際に再生/一時停止する
  • スキップボタンが正しい間隔(15 秒)で表示される

パターン 3:アートワークの設定(アートワークの問題)

所要時間:15~25 分

症状
  • アートワークがまったく表示されない(汎用のプレースホルダーが表示される)
  • 現在のトラックと異なるアートワークが表示される
  • 画像が切り替わり、アートワークがちらつく
  • アートワークが表示された後、消える
BAD Code
// ❌ WRONG — MPMediaItemArtwork block can return nil, no size handling
func updateNowPlaying() {
    var nowPlayingInfo = [String: Any]()
    nowPlayingInfo[MPMediaItemPropertyTitle] = track.title

    // ❌ Storing UIImage directly (doesn't work)
    nowPlayingInfo[MPMediaItemPropertyArtwork] = image

    // ❌ Or: Block that ignores requested size
    let artwork = MPMediaItemArtwork(boundsSize: image.size) { _ in
        return self.cachedImage  // ❌ May be nil, ignores requested size
    }

    MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
}

// ❌ WRONG — Multiple rapid updates cause flickering
func loadArtwork(from url: URL) {
    // Request 1
    loadImage(url) { image in
        self.updateNowPlayingArtwork(image)  // Update 1
    }
    // Request 2 (cached) returns faster
    loadCachedImage(url) { image in
        self.updateNowPlayingArtwork(image)  // Update 2 - flicker!
    }
}
GOOD Code
// ✅ CORRECT — Proper MPMediaItemArtwork with 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 に分離された保存プロパティにアクセスすると、コンパイルエラーになります。UIImage は不変で、読み取りがスレッドセーフなため、nonisolated(unsafe) を使用するよりも画像の値を直接キャプチャするほうが適切です。

アートワークのサイズガイドライン
  • ロック画面:300x300 ポイント(600x600 @2x、900x900 @3x)
  • コントロールセンター:サイズはさまざまです
  • ベストプラクティス:600x600 ピクセル以上の画像を用意します
確認
  • ロック画面にアートワークが表示される
  • 現在のトラックに正しいアートワークが表示される
  • トラック変更時にちらつかない
  • バックグラウンドにしてもアートワークが保持される

パターン 4:再生状態の同期(状態同期の問題)

所要時間:10~20 分

症状
  • 実際には一時停止しているのに、コントロールセンターに「再生中」と表示される
  • プログレスバーが動かない、または予期せず飛ぶ
  • 再生時間の値が正しくない
  • スクラブ操作が正しく動作しない
BAD Code
// ❌ WRONG — Using playbackState (macOS only, ignored on iOS)
func updatePlaybackState(isPlaying: Bool) {
    MPNowPlayingInfoCenter.default().playbackState = isPlaying ? .playing : .paused
    // ❌ iOS ignores this property! Only macOS uses it.
}

// ❌ WRONG — Updating elapsed time on a timer (causes drift)
Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
    var info = MPNowPlayingInfoCenter.default().nowPlayingInfo ?? [:]
    info[MPNowPlayingInfoPropertyElapsedPlaybackTime] = self.player.currentTime().seconds
    MPNowPlayingInfoCenter.default().nowPlayingInfo = info
    // ❌ Every second creates jitter, system already infers from timestamp
}

// ❌ WRONG — Partial dictionary updates cause race conditions
func updateTitle() {
    var info = [String: Any]()
    info[MPMediaItemPropertyTitle] = track.title
    MPNowPlayingInfoCenter.default().nowPlayingInfo = info
    // ❌ Cleared all other values (artwork, duration, etc.)!
}
GOOD Code
// ✅ CORRECT — Use playbackRate for iOS, update at key moments only
@MainActor
class NowPlayingService {

    // ✅ Update when playback STARTS
    func playbackStarted(track: Track, player: AVPlayer) {
        var nowPlayingInfo = MPNowPlayingInfoCenter.default().nowPlayingInfo ?? [:]

        // ✅ Core metadata
        nowPlayingInfo[MPMediaItemPropertyTitle] = track.title
        nowPlayingInfo[MPMediaItemPropertyArtist] = track.artist
        nowPlayingInfo[MPMediaItemPropertyAlbumTitle] = track.album
        nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = player.currentItem?.duration.seconds ?? 0

        // ✅ Playback state via RATE (not playbackState property)
        nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = player.currentTime().seconds
        nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = 1.0  // Playing

        MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
    }

    // ✅ Update when playback PAUSES
    func playbackPaused(player: AVPlayer) {
        var nowPlayingInfo = MPNowPlayingInfoCenter.default().nowPlayingInfo ?? [:]

        // ✅ Update elapsed time AND rate together
        nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = player.currentTime().seconds
        nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = 0.0  // Paused

        MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
    }

    // ✅ Update when user SEEKS
    func userSeeked(to time: CMTime, player: AVPlayer) {
        var nowPlayingInfo = MPNowPlayingInfoCenter.default().nowPlayingInfo ?? [:]

        nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = time.seconds
        // ✅ Keep current rate (don't change playing/paused state)

        MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
    }

    // ✅ Update when track CHANGES
    func trackChanged(to newTrack: Track, player: AVPlayer) {
        // ✅ Full refresh of all metadata
        var nowPlayingInfo = [String: Any]()

        nowPlayingInfo[MPMediaItemPropertyTitle] = newTrack.title
        nowPlayingInfo[MPMediaItemPropertyArtist] = newTrack.artist
        nowPlayingInfo[MPMediaItemPropertyAlbumTitle] = newTrack.album
        nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = player.currentItem?.duration.seconds ?? 0
        nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = 0.0
        nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = player.rate

        MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo

        // Then load artwork asynchronously
        Task {
            await loadArtwork(for: newTrack)
        }
    }
}
「再生中」情報を更新するタイミング
イベント更新内容
再生開始すべてのメタデータ + 経過時間=現在の値 + 再生レート=1.0
再生の一時停止経過時間=現在の値 + 再生レート=0.0
ユーザーがシーク経過時間=新しい位置(再生レートは維持)
トラック変更すべてのメタデータ(新しいトラック)
再生レートの変更(2x、0.5x)再生レート=新しいレート
更新しないでください
  • タイマーでの更新(システムは経過時間、再生レート、タイムスタンプから推定します)
  • 経過時間の継続的な更新(表示が不安定になります)
  • 部分的な辞書の更新(ほかの値が失われます)

パターン 5:MPNowPlayingSession(iOS 16 以降に推奨される方法)

所要時間:20~30 分

MPNowPlayingSession を使用する場合
  • iOS 16 以降(iOS 16 から利用可能。それ以前は tvOS のみ)
  • 再生に AVPlayer を使用している
  • 再生状態を自動的に公開したい
  • 複数のプレーヤーを使用する(ピクチャ・イン・ピクチャのシナリオ)
BAD Code(手動の方法:エラーが発生しやすい)
// ❌ Manual updates are error-prone, easy to miss state changes
class OldStylePlayer {
    func play() {
        player.play()
        // Must remember to:
        updateNowPlayingElapsed()
        updateNowPlayingRate()
        // Easy to forget one...
    }
}
GOOD Code(MPNowPlayingSession)
// ✅ CORRECT — MPNowPlayingSession handles automatic publishing
@MainActor
class ModernPlayerService {
    private var player: AVPlayer
    private var session: MPNowPlayingSession?

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

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

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

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

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

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

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

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

        player.replaceCurrentItem(with: item)
        player.play()
        // ✅ No need to manually update elapsed time, rate, duration
        // MPNowPlayingSession publishes automatically!
    }
}
複数のセッション(ピクチャ・イン・ピクチャ)
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 を使用する場合:MPRemoteCommandCenter.shared() ではなく、session.remoteCommandCenter を使用します。

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

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

パターン 6:CarPlay との統合

CarPlay 固有の統合パターンについては、/skill axiom-now-playing-carplay を呼び出してください。

重要なポイント:CarPlay は iOS と同じ MPNowPlayingInfoCenter と MPRemoteCommandCenter を使用します。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 を使わないようユーザーに伝えればいい」 - UX として受け入れられず、星 1 つのレビューにつながります
  2. 「常に自分のアプリを再生中の状態にすればいい」 - 不可能です。対象となるかどうかはシステムが制御します
  3. 「Apple にバグを報告する」 - リリース前には間に合いません
根本原因

以下の理由で、アプリは対象となる資格を失います。

  • .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. 埋め込みアートワークの抽出(非同期)

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 時間削減
  • デザイナーの承認を妨げていた問題を解消
  • プロらしい UX を回復

よくある落とし穴

症状原因解決策修正にかかる時間
情報がまったく表示されないバックグラウンドモードが未設定Info.plist の UIBackgroundModes に audio を追加します2 分
情報がまったく表示されないAVAudioSession が有効化されていない再生前に setActive(true) を呼び出します5 分
情報がまったく表示されないコマンドハンドラーがない少なくとも 1 つのコマンドにターゲットを追加します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() を呼び出します15 分
playbackState が機能しないiOS 専用アプリplaybackState は macOS 専用です。iOS では playbackRate を使用します10 分
Siri のスキップで preferredIntervals が無視されるハンドラーで間隔がハードコードされているMPSkipIntervalCommandEvent の event.interval を使用します5 分
CarPlay:アプリが表示されないエンタイトルメントがないエンタイトルメントに com.apple.developer.carplay-audio を追加します5 分
CarPlay:カスタムボタンが表示されない設定するタイミングが誤っているtemplateApplicationScene(_:didConnect:) で設定します5 分
CarPlay:デバイスでは動作するが、シミュレーターでは動作しないデバッガーが接続されている安定してテストするには、デバッガーなしで実行します1 分
MusicKit:「再生中」の情報が誤っている自動設定されたデータを上書きしているApplicationMusicPlayer の使用時は nowPlayingInfo を設定しないでください5 分

エキスパート向けチェックリスト

「再生中」の情報を実装する前に
  • Info.plist の UIBackgroundModes に audio を追加した
  • AVAudioSession のカテゴリが .mixWithOthers を使用しない .playback である
  • 手動方式(MPNowPlayingInfoCenter)と自動方式(MPNowPlayingSession)のどちらを使うか決めた
AVAudioSession の設定
  • アプリ起動時に setCategory(.playback) を呼び出した
  • 再生開始前に setActive(true) を呼び出した
  • 停止時に setActive(false, options: .notifyOthersOnDeactivation) を呼び出した
  • 割り込み通知を処理した(電話の後に再有効化)
  • フォアグラウンド通知を処理した(バックグラウンドから復帰した後に再有効化)
リモートコマンド
  • 少なくとも 1 つのコマンドにターゲットを登録した
  • 登録済みのすべてのコマンドで 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 → 外部ディスプレイ → 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のパターンに基づく

コメント