Media3のMediaSessionServiceとMediaControllerでメディアを再生する

2024/02/14に公開1

はじめに

何を話すのか?

下記のようなアプリがあるとして、バックグラウンド再生に備えるためにMediaSessionServiceとMediaControllerでビデオを再生する方法について話します。

  • すでにMedia3のExoPlayerで、ビデオを再生する機能がある
  • ただし、ExoPlayerのインスタンスはViewModelで保持している

MediaSessionServiceとMediaControllerとは?

ExoPlayerでバックグラウンド再生をサポートする場合、Activityが破棄された場合でもメディアの再生を継続するために、ExoPlayerのインスタンスはServiceで保持することが望ましいです。

ViewModelでExoPlayerを保持すれば画面回転などには耐えられますが、Activiyが裏に回った状態でOSからActivityを終了させられた場合、ExoPlayerのインスタンスは破棄されてしまうでしょう。

Media3登場前までは「ServiceでExoPlayerのインスタンスを保持し、ActivityからServiceに接続して再生などを指示」は自前でコードを書く必要があったと思うのですが、
Media3は標準でこのような実装を提供してくれています。

それが、MediaSessionServiceとMediaControllerです。
関連する公式ドキュメントとして、まず Background playback with a MediaSessionService に目を通すのがお勧めです。

MediaSessionServiceが名前の通りServiceで、これでExoPlayerのインスタンスを保持します。

MediaControllerはMediaSessionServiceと通信し、再生や一時停止などを指示できます
Playerインターフェースを実装しているため、ほぼExoPlayerと同じ関数を利用できます。

課題と対策

MediaSessionServiceとExoPlayerインスタンスの寿命をほぼ一致させる必要がある

もともとのアプリの作りでは、

  • 再生するビデオが選択されたら、ExoPlayerのインスタンスをつくる
  • プレイヤーActivity内で別のビデオが選択された場合、今のExoPlayerインスタンスを破棄して新しいExoPlayerのインスタンスをつくる

という事をやっていました。

しかし、これはMediaSessionServiceと相性が悪いです。

  • MediaSessionServiceは、onGetSessionでMediaSessionを返す事が期待されている
    • もし返さない場合、MediaControllerはMediaSessionServiceへの接続に失敗する
  • MediaSessionは、ExoPlayerなどのPlayerインスタンスが存在しないと作成できない
    • MediaSessionはコンストラクタでPlayerインスタンスを必要としている
    • 作成したMediaSessionに紐付くPlayerインスタンスだけを差し替えるのは(工夫すればできなくはないが)難しい

問題点が2つあります。

  • もともとのアプリは再生するものが決まってからExoPlayerのインスタンスを作っていたが、MediaSessionServiceはサービス開始後すぐさまExoPlayerのインスタンスを作成しないといけない
    • 「卵が先か、鶏が先か」という問題です
    • 再生するメディアによって、ExoPlayerインスタンス生成に使用するパラメーターが異なるため「MediaControllerでMediaSessionServiceへ接続する前に再生するメディアの種類を伝えて、それに応じたExoPlayerのインスタンスを作る」必要があります
  • 一度作ったExoPlayerのインスタンスは基本的にMediaSessionServiceで保持し続ける
    • 先の問題よりは楽ですが、これまで頻繁にExoPlayerのインスタンスを作り直していたのをやめる必要があります

後者はやれば良いだけなので、前者についてコード例を示します。

MyMediaSessionService開始時のIntentにactionを指定し、
このactionに応じてonStartCommandでExoPlayerのインスタンスとMediaSessionのインスタンスを作成します。

MyMediaSessionServiceを使用する側は、MediaControllerの使用前に明示的にstartVideoPlaybackServiceでMyMediaSessionServicを起動すればOKです。

MyMediaSessionService
internal class MyMediaSessionService : MediaSessionService() {
    private var mediaSession: MediaSession? = null

    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
        return when (intent?.action) {
            ACTION_VIDEO_PLAYBACK -> {
                mediaSession = //ExoPlayerのインスタンスとMediaSessionのインスタンスを作成する
                START_STICKY
            }

            else -> super.onStartCommand(intent, flags, startId)
        }
    }

    companion object {
        private const val ACTION_VIDEO_PLAYBACK =
            "ACTION_VIDEO_PLAYBACK"

        fun startVideoPlaybackService(context: Context) {
            val intent = Intent(context, MyMediaSessionService::class.java).apply {
                action = ACTION_VIDEO_PLAYBACK
            }

            ContextCompat.startForegroundService(context, intent)
        }
    }
}

Playerインターフェースに定義されていない機能の実行をMediaController経由でMediaSessionServiceに依頼したい

基本的な再生や一時停止はMediaControllelrでMediaSessionServiceに依頼が可能です。
これらはPlayerインターフェースに定義されており、
またMediaControlelrはPlayerインターフェースを実装しているためです。

しかし、Playerインターフェースに定義されていない機能をMediaControllelr経由でも実現したいケースがあると思います。
これに関しては、Custom Commandsという仕組みがあります。関連の公式ドキュメントは Declare available player and custom commands です。

ここでは、「これから再生するメディアの情報をMediaControllelrからMediaSessionServiceに伝える」例を示します。

事前準備として、カスタムコマンド定義と送信部分、引数や結果をBundleに格納・取得するコードを書いておきます。

internal const val CUSTOM_SESSION_ACTION_PREPARE_VIDEO_PLAYBACK =
    "PREPARE_VIDEO_PLAYBACK"

private const val ARGS_PLAYABLE_VIDEO_INFO = "playableVideoInfo"
private const val ARGS_PLAY_WHEN_READY = "playWhenReady"

// 実際にMediaSessionServiceに依頼するCustomCommandを投げる
internal suspend fun MediaController.prepareVideoPlayback(
    playable: PlayableVideoInfo,
    playWhenReady: Boolean,
) {
    val args = Bundle().apply {
        putParcelable(ARGS_PLAYABLE_VIDEO_INFO, playable)
        putBoolean(ARGS_PLAY_WHEN_READY, playWhenReady)
    }

    sendCustomCommandWithErrorCheck(
        SessionCommand(CUSTOM_SESSION_ACTION_PREPARE_VIDEO_PLAYBACK, args),
    )
}

internal fun SessionCommand.getPlayableVideoInfo(): PlayableVideoInfo {
    return requireNotNull(
        BundleCompat.getParcelable(
            customExtras,
            ARGS_PLAYABLE_VIDEO_INFO,
            PlayableVideoInfo::class.java,
        ),
    )
}

internal fun SessionCommand.getPlayWhenReady(): Boolean {
    return requireNotNull(customExtras.getBoolean(ARGS_PLAY_WHEN_READY))
}

// 送信したコマンドの結果を取り出して、成功以外であれば例外を投げる
private suspend fun MediaController.sendCustomCommandWithErrorCheck(
    command: SessionCommand,
) {
    val result = sendCustomCommand(
        command,
        Bundle(),
    ).await()
    if (result.resultCode != SessionResult.RESULT_SUCCESS) {
        throw result.getThrowable() ?: throw Exception()
    }
}

private const val RESULT_ERROR_EXTRA_THROWABLE = "throwable"

internal fun createErrorSessionResult(throwable: Throwable): SessionResult {
    val extra = Bundle().apply {
        putSerializable(RESULT_ERROR_EXTRA_THROWABLE, throwable)
    }
    return SessionResult(SessionResult.RESULT_ERROR_UNKNOWN, extra)
}

internal fun SessionResult.getThrowable(): Throwable? {
    return extras.getSerializable(RESULT_ERROR_EXTRA_THROWABLE) as Throwable?
}

MediaSessionはbuilderで作成時にMediaSession.Callbackをsetできます。
このMediaSession.Callback内で、上記で定義したカスタムコマンドをハンドリングします。

MediaSession.Callback
object : MediaSession.Callback {
    private fun launchCustomCommandFuture(
        block: suspend () -> Unit,
    ): ListenableFuture<SessionResult> =
        SuspendToFutureAdapter.launchFuture {
            // CancellationExceptionが発生すると失敗扱いになるので、握りつぶしてしまう
            runCatching {
                block.invoke()
            }.fold(
                onSuccess = {
                    SessionResult(SessionResult.RESULT_SUCCESS)
                },
                onFailure = {
                    if (it is CancellationException) {
                        // Ignore cancellation and treat as success
                        SessionResult(SessionResult.RESULT_SUCCESS)
                    } else {
                        createErrorSessionResult(it)
                    }
                }
            )
        }

    @UnstableApi
    override fun onConnect(
        session: MediaSession,
        controller: ControllerInfo
    ): ConnectionResult {
        val sessionCommands =
            ConnectionResult.DEFAULT_SESSION_COMMANDS.buildUpon()
                .add( // カスタムコマンドをここで利用可能なコマンドに含めないと、onCustomCommandで受け取れない
                    SessionCommand(
                        CUSTOM_SESSION_ACTION_PREPARE_VIDEO_PLAYBACK,
                        Bundle.EMPTY,
                    )
                )
                .build()
        return AcceptedResultBuilder(session)
            .setAvailableSessionCommands(sessionCommands)
            .build()
    }

    override fun onCustomCommand(
        session: MediaSession,
        controller: ControllerInfo,
        customCommand: SessionCommand,
        args: Bundle
    ): ListenableFuture<SessionResult> {
        return when (customCommand.customAction) {
            CUSTOM_SESSION_ACTION_PREPARE_VIDEO_PLAYBACK -> {
                launchCustomCommandFuture {
                    // ここで、カスタムコマンドを処理する
                    startPreparePlaying(
                        playable = customCommand.getPlayableVideoInfo(),
                        playWhenReady = customCommand.getPlayWhenReady(),
                    )
                }
            }

            else -> super.onCustomCommand(
                session,
                controller,
                customCommand,
                args
            )
        }
    }
}

MediaSessionServiceからMediaControllerに定期的に通知を投げる仕組みが必要

「Playerインターフェースに定義されていない機能の実行をMediaController経由でMediaSessionServiceに依頼したい」ではMediaControllerからMediaSessionServiceへ何かしらの要求はできますが、MediaSessionServiceからMediaControllerへ定期的に何かの情報を伝える、といった用途には向きません。
(ポーリングすればできなくはない)

MediaSession側からカスタムコマンドを送る仕組みがあったので、今回はそれを利用しました。
カスタムコマンド定義と送信部分、通知内容をBundleに格納・取得するコードを書いていきましょう。

internal const val CUSTOM_SESSION_ACTION_NOTIFY_ASPECT_RATIO =
    "NOTIFY_ASPECT_RATIO"

private const val ARGS_ASPECT_RATIO = "aspect_ratio"

internal suspend fun MediaSession.notifyAspectRatio(
    context: Context,
    aspectRatio: Float,
) {
    val args = Bundle().apply {
        putFloat(ARGS_ASPECT_RATIO, aspectRatio)
    }

    // since this is a just notify, ignore result
    this.sendCustomCommand(
        context,
        SessionCommand(CUSTOM_SESSION_ACTION_NOTIFY_ASPECT_RATIO, args),
    )
}

internal fun SessionCommand.getAspectRatio(): Float {
    return customExtras.getFloat(ARGS_ASPECT_RATIO)
}

private suspend fun MediaSession.sendCustomCommand(
    context: Context,
    command: SessionCommand,
): SessionResult {
    val myPackageName = context.packageName

    val controller = connectedControllers.firstOrNull {
        it.packageName == myPackageName &&
            it.connectionHints.getBoolean(REQUIRE_PLAYER_NOTIFICATIONS, false) // MediaController生成時、setConnectionHintsでREQUIRE_PLAYER_NOTIFICATIONS=trueを格納して目印にする
    } ?: return SessionResult(SessionResult.RESULT_ERROR_UNKNOWN)

    return this.sendCustomCommand(
        controller,
        command,
        Bundle(),
    ).await()
}

上記の通知を受け取る側のコードをみてみます。
MediaController生成時にsetListenerでMediaController.Listenerをsetできるので、
このMediaController.Listener内で、上記で定義したカスタムコマンドをハンドリングすればOKです。

MediaController.Listener
object : MediaController.Listener {
    override fun onCustomCommand(
        controller: MediaController,
        command: SessionCommand,
        args: Bundle
    ): ListenableFuture<SessionResult> = SuspendToFutureAdapter.launchFuture {
        return@launchFuture handleCustomCommand(command)
    }

    private suspend fun handleCustomCommand(
        command: SessionCommand,
    ): SessionResult {
        return when (command.customAction) {
            CUSTOM_SESSION_ACTION_NOTIFY_ASPECT_RATIO -> {
                // command.getAspectRatio()で内容を取り出して使用する
                SessionResult(SessionResult.RESULT_SUCCESS)
            }

            else -> SessionResult(SessionResult.RESULT_ERROR_NOT_SUPPORTED)
        }
    }
}

Discussion

tomoya0x00tomoya0x00

もともとのアプリは再生するものが決まってからExoPlayerのインスタンスを作っていたが、MediaSessionServiceはサービス開始後すぐさまExoPlayerのインスタンスを作成しないといけない

再生するメディアごとにMediaSessionServiceを作る事も考えたのですが、公式ドキュメントで下記の様に記載があったので、トラブルを避けるために不採用としました。

It's recommended for an app to have a single service declared in the manifest. Otherwise, your app might be shown twice in the list of the controller apps, or another app might fail to pick the right service when it wants to start a playback on this app. If you want to provide multiple sessions, take a look at Supporting Multiple Sessions.

https://developer.android.com/reference/androidx/media3/session/MediaSessionService