axiom-now-playing について
iOSのNow Playing機能を実装・診断するためのSwiftスキルです。オーディオセッション、リモートコマンド、アートワーク、再生状態の更新と、MPNowPlayingSessionの使い方を解説します。
主な機能
- 01AVAudioSessionの設定と有効化の診断
- 02リモートコマンドの登録と有効化
- 03アートワーク設定と競合への対処
- 04再生状態と進行状況の同期
- 05MPNowPlayingSessionの利用パターン
活用シーン
- 01ロック画面に再生情報が出ない問題の調査
- 02コントロールセンターのボタン無反応の修正
- 03アートワークの欠落やちらつきの診断
- 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つのリモートコマンドにハンドラーを登録する
- 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 = 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)
パターン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 時間です
やってはいけない合理化
- 「Apple Music を使わないようユーザーに伝えればいい」 - UX として受け入れられず、星 1 つのレビューにつながります
- 「常に自分のアプリを再生中の状態にすればいい」 - 不可能です。対象となるかどうかはシステムが制御します
- 「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 回更新されています
- デザイナー:「プロらしく見えません」
根本原因
複数のアートワーク取得元が競合しています。
- キャッシュの確認(非同期)
- リモート 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 時間削減
- デザイナーの承認を妨げていた問題を解消
- プロらしい 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のパターンに基づく