PLAY DEVELOPERS BLOG

HuluやTVerなどの日本最大級の動画配信を支える株式会社PLAYが運営するテックブログです。

HuluやTVerなどの日本最大級の動画配信を支える株式会社PLAYが運営するテックブログです。

【iOS 27】Now Playingフレームワークでロック画面に再生情報を表示する

こんにちは。ビデオマーケット事業部 開発統括グループの石井です。

普段から動画配信サービス「ビデオマーケット」の開発に携わっていることもあり、iOS 27で新たに追加されたNow Playingフレームワークを試してみました。

Now Playingフレームワークは、ロック画面・コントロールセンター・Dynamic Island・CarPlay・Apple Watchに表示される再生情報や再生・停止などの操作を扱うためのフレームワークです。

これまでは、MPNowPlayingInfoCenterやMPRemoteCommandCenterで再生情報やリモートコマンドを設定していましたが、新フレームワークでは、型安全な方式で実装できるようになりました。

対応プラットフォームはiOS / iPadOS / Mac Catalyst / macOS / tvOS / visionOS / watchOSで、今回はiOS 27で検証します。

※本記事はiOS 27 / Xcode 27のBeta版を使用して検証しています。正式リリースまでにAPIの仕様や挙動が変更される可能性があります。

何が変わったのか

一番大きな変更点は、辞書に直接値を詰める方式から、型安全な方式での実装に変わったことです。

従来の実装

MPNowPlayingInfoCenter.default().nowPlayingInfo = [
    MPMediaItemPropertyTitle: "サンプル動画",
    MPMediaItemPropertyPlaybackDuration: 600.0,
    MPNowPlayingInfoPropertyElapsedPlaybackTime: 12.0,
    MPNowPlayingInfoPropertyPlaybackRate: 1.0,
]

従来は、再生情報を文字列キーの辞書としてnowPlayingInfoに設定します。キーを間違えてもコンパイル時には気づきにくく、設定する項目もコードから把握しづらいところがありました。

Now Playingフレームワークでは、MediaSessionRepresentableに準拠した型を用意し、@Observableを付けてMediaSessionに渡します。

contentやplaybackSnapshotなどのプロパティを更新すると、その変更をMediaSessionが検知し、ロック画面やコントロールセンターなどのNow Playing表示に反映します。

主な違いをまとめると次のとおりです。

従来 Now Playing
更新のしかた nowPlayingInfo に辞書を設定・更新 @Observable な型を渡し、プロパティを更新
項目の指定 MPMediaItemPropertyTitle などの文字列キー メディア種別ごとの構造体
長さ durationなど複数のプロパティで表現 MediaDuration が .finite / .live / .continuous
アートワーク MPMediaItemArtwork(boundsSize:requestHandler:) Artwork(id:) の非同期クロージャ。加えて AnimatedArtwork
コマンド MPRemoteCommandCenter の各コマンド + isEnabled commands: [MediaCommand] の配列

実装する

今回は、動画を再生するだけのシンプルなアプリにNow Playingフレームワークを組み込んでみます。

実装するのはid / content / playbackSnapshot / commandsの4つの項目です。

型を作る

contentやplaybackSnapshotなどの変更をMediaSessionが検知できるよう、MediaSessionRepresentableに準拠する型に@Observableを付けます。

import NowPlaying

@Observable
final class PlayerMediaSession: MediaSessionRepresentable {
    let id: String

    var content: (any MediaContentRepresentable)?
    var playbackSnapshot: MediaPlaybackSnapshot?
    var commands: [MediaCommand] { [] }

    private let player: AVPlayer

    init(player: AVPlayer) {
        self.player = player
        ....
    }
}

content — 何を再生しているか

コンテンツは種別ごとに型が分かれています。動画なら MovieContent かTVShowContent です。

content = MovieContent(
    id: "movie-001",
    title: "サンプル動画",
    duration: .finite(600),
    artwork: Artwork(id: "artwork-001") { size in
        // 要求されたサイズに合わせて画像を返す
        try await loadArtwork(size: size)
    }
)

連続ドラマなど、番組名と話数を分けて持ちたい場合は TVShowContent を使います。

content = TVShowContent(
    id: "episode-012",
    episodeTitle: "第XX話",    // エピソードに関する情報
    showName: "サンプルドラマ",  // 番組・シリーズに関する情報
    duration: .finite(1440),
    artwork: artwork
)

contentには、再生する動画の情報を設定します。 新しいNow Playingでは、MovieContentやTVShowContentなど、コンテンツの種類に応じた型が用意されており、今回は単体の動画を再生するため、MovieContentを使用しました。

用意されているコンテンツ型は MusicContent / PodcastContent / MovieContent / TVShowContent / BookContent / RadioContent / HomeMediaContent / GenericContent の8つで、いずれも MediaContentRepresentable に準拠しています。 共通のプロパティとして genre / isExplicit / isExcludedFromSuggestions / appEntityIdentifiers / animatedArtwork を持ちます。

artworkには、ロック画面やコントロールセンターに表示する画像を設定します。 新しいArtworkでは画像そのものを直接渡すのではなく、システムから画像が必要になったタイミングで、画像データを返すクロージャを登録します。 Artworkのクロージャにはシステムが必要とするサイズも渡されるため、そのサイズに応じた画像を返すこともできます。 今回の検証ではサイズによる出し分けは行わず、常に同じ画像を返すようにしています。

playbackSnapshot — いまどこを再生しているか

playbackSnapshotには、現在の再生状態や再生位置を設定します。

private func updatePlaybackSnapshot() {
    let rate = player.rate
    let currentTime = player.currentTime().seconds

    playbackSnapshot = MediaPlaybackSnapshot(
        state: rate > 0 ? .playing(rate: rate) : .paused,
        defaultPlaybackRate: 1.0,
        elapsedTime: currentTime,
        timestamp: .now
    )
}

従来は、再生位置や再生速度などをnowPlayingInfoの各プロパティとして設定していました。

新しいNow Playingでは、再生状態やタイミングをMediaPlaybackSnapshotにまとめて設定します。

一時停止やシークなどで状態が変わった場合は、その時点のplaybackSnapshotを更新します。

func pause() {
    player.pause()

    playbackSnapshot = MediaPlaybackSnapshot(
        state: .paused,
        defaultPlaybackRate: 1.0,
        elapsedTime: player.currentTime().seconds,
        timestamp: .now
    )
}

commands — 何を操作できるか

従来は、MPRemoteCommandCenterを使ってPlay、Pause、スキップ、シークなどの操作をそれぞれ設定していました。 新しいNow Playingでは、これらの操作をcommandsにまとめて設定できます。

var commands: [MediaCommand] {
    [
        .play { [weak self] in
            self?.play()
        },
        .pause { [weak self] in
            self?.pause()
        },
        .togglePlayPause { [weak self] in
            self?.togglePlayPause()
        },
        .skipForward(preferredIntervals: [15]) { [weak self] interval in
            self?.skip(by: interval)
        },
        .skipBackward(preferredIntervals: [15]) { [weak self] interval in
            self?.skip(by: -interval)
        },
        .seekToPosition { [weak self] position in
            self?.seek(to: position)
        },
    ]
}

コマンドを無効にしたい場合は.enabled(false)を付けます。

.changePlaybackRate { [weak self] rate in
    self?.player.rate = Float(rate)
}
.enabled(canChangeRate)

使えるコマンドは play / pause / stop / togglePlayPause、next / previous、 skipForward / skipBackward、seekToPosition / seekForward / seekBackward、 changePlaybackRate / changeRepeatMode / changeShuffleMode、feedback です。

今回は15秒送り・15秒戻しを指定したところ、実際にロック画面にも15秒送り・戻しのボタンが表示されました。

ロック画面に出す

最後に、MediaSessionRepresentableに準拠して作成したPlayerMediaSessionをMediaSessionに渡します。

requestToBecomeApplicationPrimary()を呼び出すことで、アプリのPrimary Sessionとして公開します。

let mediaSession = MediaSession(playerMediaSession)
try await mediaSession.requestToBecomeApplicationPrimary()

今回のローカル動画再生では、requestToBecomeApplicationPrimary()のみでロック画面とコントロールセンターへの表示を確認できました。

requestToBecomeSystemPrimary()についても検証しましたが、ローカル再生では必須ではないため、最終的な実装では使用していません。

ロック画面 コントロールセンター

また、音声を再生する場合は、要求の前に AVAudioSession を有効にしておきます。

try AVAudioSession.sharedInstance().setCategory(.playback)
try AVAudioSession.sharedInstance().setActive(true)

let mediaSession = MediaSession(playerMediaSession)
try await mediaSession.requestToBecomeApplicationPrimary()

終了時は忘れずに解除しておきます。

try AVAudioSession.sharedInstance().setActive(
    false,
    options: .notifyOthersOnDeactivation
)

そのほかの変更点

ライブ配信を型で表せる

新しいNow Playingでは、コンテンツの長さを表すMediaDurationに3つのタイプが用意されています。

duration: .finite(600)   // 尺の決まっている動画
duration: .live          // ライブ配信
duration: .continuous    // 環境音など、ライブではなく明確な尺を持たないコンテンツ

.finite / .live / .continuousのように、コンテンツの長さの性質を明示的に指定できるようになっています。

実装時に注意したいところ

requestToBecomeSystemPrimaryはフォアグラウンドで呼び出す

requestToBecomeSystemPrimary()を使う場合、アプリがバックグラウンドに回ってから呼んでも要求は有効にならず、フォアグラウンド時に呼ぶ必要があります。

検証では、requestToBecomeSystemPrimary()も試しましたが、internalFailureになる場合や、呼び出し後もisSystemPrimaryがfalseのままになる場合がありました。

ただ、Appleのドキュメントによると、ローカル再生では通常requestToBecomeSystemPrimary()は必須ではないため、今回はrequestToBecomeApplicationPrimary()のみを使用しました。

requestToBecomeApplicationPrimary()のみで、ロック画面・コントロールセンターとも正常に表示されました。

旧APIと混ぜてはいけない

Now PlayingのドキュメントのImportantに、ローカル再生でNow PlayingフレームワークとMPNowPlayingInfoCenter / MPRemoteCommandCenterを混在させるとundefined behaviorになると明記されています。

MPNowPlayingInfoCenterはiOS 27の時点でも利用できるため、既存アプリではOSバージョンによって新旧APIを分けて実装することもできます。

ただし、同じローカル再生の実装内で新旧APIを併用しないよう注意が必要です。

Remote sessions

Now Playingでは、外部デバイスで再生しているメディアを扱うRemote sessionsも追加されています。

例えば、スマートTVやストリーミングデバイスで再生しているコンテンツの情報をiPhoneのロック画面やコントロールセンターに表示し、再生操作を行うことができます。

また、外部デバイスの再生状態が変わった際に、サーバーからプッシュ通知を送り、iPhone側のRemote Media Sessionを開始・更新・終了する仕組みも用意されています。

これにより、iPhoneアプリが起動していない場合でも、外部デバイスの再生状態をロック画面などに反映できます。

今回はローカル動画再生の検証を中心としたため、Remote sessionsの実装については割愛します。

まとめ

iOS 27のNow Playingフレームワークを組み込んでみました。

  • 文字列キーの辞書ではなく、MediaSessionRepresentableに準拠した型を1つ作る
  • 実装するのは id / content / playbackSnapshot / commands の4つ
  • MediaDuration.liveでライブ配信を型で表せる
  • 再生状態やタイミングをMediaPlaybackSnapshotにまとめて設定する
  • requestToBecomeSystemPrimary()はフォアグラウンドで呼ぶ
  • 旧APIと混ぜるとundefined behavior

既存アプリでは対応OSとの兼ね合いもあり、すぐに置き換えるケースは多くないかもしれません。

けれども、辞書ベースだったNow Playingの情報を型として設定できるようになったことなど、実装面では良くなっていると感じました。

参考: