ハイライト
Apple は NowPlaying フレームワークを導入し、
MPNowPlayingInfoCenterの辞書 API を宣言的プロトコルと@Observableで置き換えます。App のメディア再生は、ロック画面、コントロールセンター、Dynamic Island、CarPlay に自動同期できます。さらに Remote Media Sessions と Media Sharing Extensions により、サードパーティスピーカーなど外部デバイスの再生制御もシステム標準の画面に組み込めます。
主要内容
音声や動画の再生機能を作った開発者なら、誰でもこの状況を経験しています。ユーザーがイヤホンの一時停止ボタンを押す、あるいはロック画面から次の曲をタップする。そのとき App は複数の場所でイベントに応答しなければなりません。以前の方法では MPNowPlayingInfoCenter に辞書を詰め込みました。キーは文字列定数で、型は実行時チェックに頼り、状態更新も手動で発火します。コードは穴を叩き続けるように増え、ロック画面の進捗バーや一時停止ボタンは App 内部状態とずれがちでした。
NowPlaying フレームワークはこの流れを根本から書き換えます。
(01:00) 中核となる考え方は、再生モデルにプロトコルへ準拠させ、システムが Swift の @Observable を通じて状態変化を自動追跡し、すべてのシステム画面を自動更新する、というものです。ロック画面へ「いま 3 秒目を再生中」と手動で伝える必要はありません。モデル内の elapsedTime プロパティを更新するだけで、システムが自分で表示を更新します。
この設計は、長年の痛点だった状態同期を直接解決します。以前の辞書 API は命令的で、フィールドを 1 つ変えるたびに set し直す必要がありました。システムは何が変わったのか分からないため、全体を大きく更新するしかありませんでした。今は @Observable によりシステムが正確に差分を把握でき、変化した部分だけを更新できます。ロック画面や Dynamic Island のアニメーションもより滑らかになります。
(05:04) もう 1 つの大きな変化は、リモート再生デバイスへの対応です。App がスマートスピーカーを制御できる場合でも、以前はユーザーが iPhone に戻ると、そのデバイスが何を再生しているのかコントロールセンターに表示されませんでした。Remote Media Sessions を使うと、スピーカーの状態を APNs 経由で iPhone にプッシュでき、システムはコントロールセンターに同じ再生画面を表示します。体験はローカル再生と同じになります。
(10:31) 最後に Media Sharing Extensions があります。以前はキャストプロトコルに対応するには、App 内に対応 SDK を組み込む必要があり、バイナリサイズが増えました。今はプロトコル実装をシステムが管理します。App はシステムのデバイス選択画面を呼び出すだけで、ユーザーがデバイスを選ぶと再生ルーティングが自動で完了します。
詳細
ローカル再生をシステムの Now Playing 画面へ接続する
(01:56) 音声エンジンを参照し、現在再生中のものを表すプロパティを持つ再生モデルがあるとします。
import Observation
@Observable
final class PlayerModel {
let player: SoundPlayer
var sound: Sound { player.currentSound }
init(player: SoundPlayer) {
self.player = player
}
}
このモデルをシステム Now Playing に接続するには、MediaSessionRepresentable プロトコルに準拠させるだけです。
import NowPlaying
extension PlayerModel: MediaSessionRepresentable {
var id: String { "ambient-sound-session" }
var content: (any MediaContentRepresentable)? {
return GenericContent(
id: sound.id,
title: sound.name,
subtitle: sound.description,
type: .audio,
duration: .live,
artwork: Artwork(id: sound.id) { size in
let data = try await self.artworkData(size: size)
return try ArtworkRepresentation(data: data)
}
)
}
var playbackSnapshot: MediaPlaybackSnapshot? {
MediaPlaybackSnapshot(
state: player.isPlaying ? .playing() : .paused
)
}
var commands: [MediaCommand] {[
.play { self.player.play() },
.pause { self.player.pause() },
.previous { self.player.previous() },
.next { self.player.next() }
]}
}
ポイント:
idはセッション識別子であり、曲の識別子ではありません。曲を切り替えるたびに変えると、システムは別セッションとして扱い、Dynamic Island のアニメーションが途切れます。contentは再生中のコンテンツを記述します。NowPlaying にはMusicContent、PodcastContent、MovieContentなどの専用型があり、他の場面はGenericContentで扱えます。durationは.live、.continuous、または具体的な長さにできます。システムはこの値に基づいて進捗表示のスタイルを自動調整します。artworkは非同期クロージャを受け取ります。システムは特定サイズのアートワークが必要になったときに呼び出します。クロージャ内で同期的にブロックする処理は避けます。playbackSnapshotは現在の再生状態を返します。長さのあるコンテンツではelapsedTimeパラメータも追加します。commandsは App が対応する操作を定義します。各コマンドはクロージャに対応し、ユーザーがロック画面やコントロールセンターから操作したときにシステムが呼び出します。
(04:31) プロトコル準拠ができたら、MediaSession でモデルとシステムを接続します。
import NowPlaying
struct PlayerController {
let player: SoundPlayer
let model: PlayerModel
let session: MediaSession<PlayerModel>
init() {
self.player = SoundPlayer()
self.model = PlayerModel(player: player)
self.session = MediaSession(model)
}
}
ポイント:
MediaSessionは初期化時にモデルの観察を開始し、状態変化をシステム画面へ自動同期する- 初期化場所は音声エンジンの近くに置き、ライフサイクルを合わせるのが望ましい。エンジンを破棄するときは Session も解放する
リモート再生デバイスをシステムへ接続する
(05:04) App がスマートスピーカーを制御でき、スピーカーが自前サーバー経由で通信しているとします。このリモート再生状態を iPhone のコントロールセンターへ表示するには、APNs プッシュ通知、App Extension、そして RemoteMediaSessionRepresentable に準拠するモデルの 3 つが必要です。
データフローは、スピーカー状態の変化 -> 自前サーバー -> APNs で iPhone へプッシュ -> システムが App Extension を起動 -> Extension が更新済みのセッション表現を提供 -> システムがコントロールセンター UI を更新、という流れです。
(06:42) まず App Extension を作成します。
import ExtensionFoundation
import NowPlaying
@main
final class SampleAppExtension: @MainActor RemoteMediaSessionExtension {
var configuration: some AppExtensionConfiguration {
RemoteMediaSessionExtensionConfiguration(extension: self)
}
var extensionPoint: AppExtensionPoint {
AppExtensionPoint.Identifier(host: "com.apple.nowplaying", name: "remote-media")
}
func session(_ state: RemotePlayerState) async throws -> RemotePlayerModel {
RemotePlayerModel(state: state)
}
}
ポイント:
RemoteMediaSessionExtensionは Extension の入口プロトコルです。extensionPointは host に"com.apple.nowplaying"、name に"remote-media"を必ず使います。session(_:)はシステムが UI を更新したり操作を処理したりする必要があるときに呼び出されます。渡されるstateは APNs プッシュ payload から来ます。- Extension は必要に応じて起動されます。内部で長時間接続を維持したり大量のデータをキャッシュしたりしないでください。
(07:23) リモート再生モデルはローカルモデルと似ていますが、リモート特有のプロパティがいくつかあります。
import Observation
@Observable
@MainActor
final class RemotePlayerModel {
let client: ServerClient
var state: RemotePlayerState
init(state: RemotePlayerState) {
self.client = ServerClient(sessionID: state.sessionID)
self.state = state
}
}
(07:40) これを RemoteMediaSessionRepresentable に準拠させます。
import NowPlaying
extension RemotePlayerModel: @MainActor RemoteMediaSessionRepresentable {
var id: String { state.sessionID }
var content: (any MediaContentRepresentable)? {
GenericContent(
id: state.sound.id,
title: state.sound.name,
subtitle: state.sound.description,
type: .audio,
duration: .live,
artwork: Artwork(id: state.sound.id) { size in
let data = try await self.artworkData(size: size)
return try ArtworkRepresentation(data: data)
}
)
}
var playbackSnapshot: MediaPlaybackSnapshot? {
MediaPlaybackSnapshot(
state: state.isPlaying ? .playing() : .paused
)
}
var commands: [MediaCommand] {[
.play { try await self.client.send(.play) },
.pause { try await self.client.send(.pause) },
.previous { try await self.client.send(.previous) },
.next { try await self.client.send(.next) }
]}
var devices: [MediaDevice] {
state.devices.map { device in
MediaDevice(
id: device.id,
name: device.name,
type: .speaker,
capabilities: [
.absoluteVolume(device.volume) { volume in
// 音量変更をサーバーへ送信する
}
]
)
}
}
func update(_ state: RemotePlayerState) {
self.state = state
}
}
ポイント:
commands内のクロージャはローカルプレイヤーを直接操作するのではなく、サーバーへ指示を送ります。devicesは、このセッションに参加しているデバイスをシステムへ伝えます。各デバイスには安定した ID、名前、種類、音量制御などの能力が必要です。update(_:)は新しい APNs プッシュを受け取ったときに呼び出され、モデル状態を更新します。モデルは@Observableなので、NowPlaying は変化を自動検出し、システム UI を更新します。
Media Sharing Extensions
(10:31) Media Sharing Extensions は、iPhone から他のスピーカーやテレビへメディアを送るための API 群で、統一されたシステムデバイス選択画面を通じて動作します。
従来の方法では、各キャストプロトコルの SDK を App に組み込む必要がありました。Media Sharing Extensions はプロトコル実装をシステム層へ移すため、App は具体的な再生技術を気にしなくてよくなります。新しいプロトコルが追加されても、すでに統合済みの App は追加 SDK なしでそのまま利用できます。
核心アイデア
-
白色雑音や瞑想に特化した App を作る:
GenericContentとduration: .continuousで無限ループの環境音を表現します。ユーザーは App を開かなくても、ロック画面から一時停止や音の切り替えができます。入口 API:MediaSessionRepresentable+MediaSession。 -
既存の音楽 App の古い Now Playing 実装を置き換える:
MPNowPlayingInfoCenterの辞書呼び出しをMediaSessionRepresentableへ移行し、@Observableで状態不一致のバグを減らします。まず古いロジックを橋渡しする Adapter を作り、段階的に置き換えます。入口 API:MediaSession。 -
スマートスピーカーブランド向けの iOS 連携 App を作る:
RemoteMediaSessionExtensionと APNs により、ユーザーが iPhone のコントロールセンターから直接スピーカーを制御でき、Apple Music と同じようにネイティブな体験になります。スピーカー状態をリアルタイムに APNs へ同期するプッシュサーバーが必要です。入口 API:RemoteMediaSessionRepresentable+RemoteMediaSessionExtension。 -
複数デバイス切り替えに対応したポッドキャスト App を作る: ローカル再生には
MediaSession、リモートスピーカーにはRemoteMediaSessionを使い、Media Sharing Extensions によりシステムデバイス選択画面で再生先を切り替えられるようにします。入口 API:MediaSessionRepresentable+RemoteMediaSessionRepresentable+ Media Sharing Extensions。 -
CarPlay にサードパーティ音声コンテンツを表示する: NowPlaying フレームワークの統合は CarPlay 画面も自動的にカバーするため、CarPlay 専用コードは不要です。
MediaSessionRepresentableを適切に実装すれば、CarPlay の Now Playing カードに自動でコンテンツが表示されます。入口 API:MediaSession。
関連 Session
- Now Playing フレームワークに出会う - 宣言的なプロトコルと App Extension を使い、ローカルおよびリモートのメディア再生をシステムの Now Playing 画面へ接続する
- Swift の新機能 -
@Observableマクロの内部の仕組みとベストプラクティス。NowPlaying フレームワークが依存する状態観察モデルを理解する助けになる - SwiftUI の新機能 - SwiftUI とシステムフレームワークの統合方法。NowPlaying の宣言的設計は SwiftUI の考え方と一致している
- UIKit の新機能 - UIKit App が NowPlaying フレームワークと連携する方法と、現代的な UIKit における状態管理の指針
- MusicKit の新機能 - 音楽コンテンツ関連の API。NowPlaying フレームワークの
MusicContentなどのコンテンツ型と直接関係する
コメント
GitHub Issues · utterances