Media3のMediaSessionServiceとMediaControllerでメディアを再生する
はじめに
何を話すのか?
下記のようなアプリがあるとして、バックグラウンド再生に備えるために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です。
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内で、上記で定義したカスタムコマンドをハンドリングします。
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です。
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
再生するメディアごとにMediaSessionServiceを作る事も考えたのですが、公式ドキュメントで下記の様に記載があったので、トラブルを避けるために不採用としました。