WWDC Quick Look 💓 By SwiftGGTeam
Now Playing フレームワークに出会う

Now Playing フレームワークに出会う

元の動画を見る

ハイライト

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 には MusicContentPodcastContentMovieContent などの専用型があり、他の場面は 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 を作る: GenericContentduration: .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