路上では、スマートフォンからオーディオブックやポッドキャストをよく聞きます。 家に帰ったら、Android TVまたはGoogle Homeで引き続き聞きたいです。 ただし、すべてのアプリケーションがChromecastをサポートしているわけではありません。 そして、それは便利でしょう。
過去3年間のGoogleの統計によると 、Android TVのデバイス数は4倍に増加し、製造パートナーの数はすでに「スマート」テレビ、スピーカー、セットトップボックスの100を超えています。 それらはすべてChromecastをサポートしています。 しかし、まだ多くのアプリケーションが市場にあり、明らかにそれとの統合が欠けています。
この記事では、メディアコンテンツを再生するためにChromecastをAndroidアプリケーションに統合した経験を共有したいと思います。
仕組み
「Chromecast」という言葉を聞いたのが初めての場合は、簡単に説明します。 使用に関しては、次のようになります。
- ユーザーは、アプリケーションまたはWebサイトを通じて音楽を聴いたり、ビデオを見たりします。
- Chromecastデバイスがローカルネットワークに表示されます。
- 対応するボタンがプレーヤーのインターフェースに表示されます。
- ユーザーはそれをクリックして、リストから目的のデバイスを選択します。 Nexus Player、Android TV、またはスマートスピーカーを使用できます。
- このデバイスでさらに再生が続行されます。
技術的には、次のようなことが起こります。
- Googleサービスは、ブロードキャストを介してローカルネットワーク上のChromecastデバイスの存在を監視します。
- MediaRouterがアプリケーションに接続されている場合、これに関するイベントを受け取ります。
- ユーザーがキャストデバイスを選択して接続すると、新しいメディアセッション(CastSession)が開きます。
- 作成されたセッションで既に、再生のためにコンテンツを転送します。
とても簡単に聞こえます。
統合
GoogleにはChromecastを操作するための独自のSDKがありますが、ドキュメントで十分にカバーされておらず、そのコードは難読化されています。 したがって、多くのことを入力して確認する必要がありました。 すべてを順番に取得しましょう。
初期化
まず、Cast Application FrameworkとMediaRouterを接続する必要があります。
implementation "com.google.android.gms:play-services-cast-framework:16.1.0" implementation "androidx.mediarouter:mediarouter:1.0.0"
次に、Cast Frameworkはアプリケーション識別子(詳細は後ほど説明します)およびサポートされているメディアコンテンツの種類を取得する必要があります。 つまり、アプリケーションがビデオのみを再生する場合、Google Home列へのキャストは不可能になり、デバイスのリストには含まれなくなります。 これを行うには、OptionsProviderの実装を作成します。
class CastOptionsProvider: OptionsProvider { override fun getCastOptions(context: Context): CastOptions { return CastOptions.Builder() .setReceiverApplicationId(BuildConfig.CHROMECAST_APP_ID) .build() } override fun getAdditionalSessionProviders(context: Context): MutableList<SessionProvider>? { return null } }
マニフェストで宣言します。
<meta-data android:name="com.google.android.gms.cast.framework.OPTIONS_PROVIDER_CLASS_NAME" android:value="your.app.package.CastOptionsProvider" />
アプリケーションを登録する
Chromecastをアプリケーションで使用するには、 Google Cast SDK Developers Consoleに登録する必要があります。 これには、Chromecast開発者アカウントが必要です(Google Play開発者アカウントと混同しないでください)。 登録時に、5ドルの1回限りの料金を支払う必要があります。 ChromeCastアプリケーションを公開した後、少し待つ必要があります。
コンソールでは、画面のあるデバイスのキャストプレーヤーの外観を変更し、アプリケーション内のキャスト分析を確認できます。
メディアルーター
MediaRouteFrameworkは、ユーザーの近くにあるすべてのリモート再生デバイスを見つけることができるメカニズムです。 これには、Chromecastだけでなく、サードパーティのプロトコルを使用したリモートディスプレイやスピーカーも使用できます。 しかし、私たちが興味を持っているのはChromecastです。
MediaRouteFrameworkには、メディアスクーターの状態を反映するビューがあります。 接続するには2つの方法があります。
1)メニューから:
<?xml version="1.0" encoding="utf-8"?> <menu xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto"> ... <item android:id="@+id/menu_media_route" android:title="@string/cast" app:actionProviderClass="androidx.mediarouter.app.MediaRouteActionProvider" app:showAsAction="always"/> ... </menu>
2)レイアウト経由:
<androidx.mediarouter.app.MediaRouteButton android:id="@+id/mediaRouteButton" android:layout_width="wrap_content" android:layout_height="wrap_content" android:mediaRouteTypes="user"/>
コードから、ボタンをCastButtonFactoryに登録するだけです。 次に、メディアスクーターの現在の状態がスローされます。
CastButtonFactory.setUpMediaRouteButton(applicationContext, view.mediaRouteButton)
これでアプリケーションが登録され、MediaRouterが構成されたので、ChromeCastデバイスに接続してそれらのセッションを開くことができます。
メディアコンテンツのキャスト
ChromeCastは3つの主要なコンテンツタイプをサポートしています。
- 音声
- 映像
- 写真
メディアコンテンツやキャストデバイスなどのプレーヤーの設定によって、プレーヤーのインターフェースは異なる場合があります。
キャストセッション
そのため、ユーザーが目的のデバイスを選択すると、CastFrameworkは新しいセッションを開きました。 ここでのタスクは、これに応答し、再生のためにデバイス情報を渡すことです。
セッションの現在の状態を確認し、この状態を更新するためにサインアップするには、 SessionManagerオブジェクトを使用します。
private val mediaSessionListener = object : SessionManagerListener<CastSession> { override fun onSessionStarted(session: CastSession, sessionId: String) { currentSession = session // , checkAndStartCasting() } override fun onSessionEnding(session: CastSession) { stopCasting() } override fun onSessionResumed(session: CastSession, wasSuspended: Boolean) { currentSession = session checkAndStartCasting() } override fun onSessionStartFailed(session: CastSession, p1: Int) { stopCasting() } override fun onSessionEnded(session: CastSession, p1: Int) { // do nothing } override fun onSessionResumeFailed(session: CastSession, p1: Int) { // do nothing } override fun onSessionSuspended(session: CastSession, p1: Int) { // do nothing } override fun onSessionStarting(session: CastSession) { // do nothing } override fun onSessionResuming(session: CastSession, sessionId: String) { // do nothing } } val sessionManager = CastContext.getSharedInstance(context).sessionManager sessionManager.addSessionManagerListener(mediaSessionListener, CastSession::class.java)
また、現在開いているセッションがあるかどうかも確認できます。
val currentSession: CastSession? = sessionManager.currentCastSession
キャストを開始できる主な条件は2つあります。
- セッションはすでに開いています。
- キャスト用のコンテンツがあります。
これらの2つのイベントのそれぞれで、ステータスを確認し、すべてが正常である場合、キャストを開始できます。
キャスティング
キャストするものとキャストする場所ができたので、次に最も重要なことに移ります。 特に、CastSessionには、メディアコンテンツの再生状態を担当するRemoteMediaClientオブジェクトがあります。 彼と一緒に働きます。
作成者、アルバムなどに関する情報が保存されるMediaMetadataを作成しましょう。これは、ローカル再生を開始するときにMediaSessionに転送するものと非常に似ています。
val mediaMetadata = MediaMetadata(MediaMetadata.MEDIA_TYPE_MUSIC_TRACK ).apply { putString(MediaMetadata.KEY_TITLE, “In C”) putString(MediaMetadata.KEY_ARTIST, “Terry Riley”) mediaContent?.metadata?.posterUrl?.let { poster -> addImage(WebImage(Uri.parse(“https://habrastorage.org/webt/wk/oi/pf/wkoipfkdyy2ctoa5evnd8vhxtem.png”))) } }
MediaMetadataには多くのパラメーターがあり、ドキュメントで確認することをお勧めします。 ビットマップではなく、単にWebImage内のリンクによって画像を追加できることに驚きました。
MediaInfoオブジェクトは、コンテンツメタデータに関する情報を保持し 、メディアコンテンツの発信元、種類、再生方法について説明します。
val mediaInfo = MediaInfo.Builder(“https://you-address.com/in_c.mp3”) .setContentType(“audio/mp3”) .setStreamType(MediaInfo.STREAM_TYPE_BUFFERED) .setMetadata(mediaMetadata) .build()
contentTypeは、 MIME仕様によるコンテンツのタイプであることを思い出してください。
MediaInfoでは、広告挿入物を転送することもできます。
- setAdBreakClips-コンテンツ、タイトル、タイミング、広告がスキップされる時間へのリンクを含むAdBreakClipInfoコマーシャルのリストを受け入れます。
- setAdBreaks-広告挿入のレイアウトとタイミングに関する情報。
MediaLoadOptionsでは、メディアストリームの処理方法(速度、開始位置)について説明します。 ドキュメントでは、setCredentialsを介して認証ヘッダーを渡すこともできますが、Chromecastからの要求には要求された認証フィールドが含まれていないと記載されています。
val mediaLoadOptions = MediaLoadOptions.Builder() .setPlayPosition(position!!) .setAutoplay(true) .setPlaybackRate(playbackSpeed) .setCredentials(context.getString(R.string.bearer_token, authGateway.authState.accessToken!!)) .setCredentialsType(context.getString(R.string.authorization_header_key)) .build()
すべての準備が整ったら、すべてのデータをRemoteMediaClientに渡すことができ、Chromecastは再生を開始します。 ローカル再生を一時停止することが重要です。
val remoteMediaClient = currentSession!!.remoteMediaClient remoteMediaClient.load(mediaInfo, mediaLoadOptions)
イベント処理
ビデオが再生され始め、そして何が? ユーザーがテレビを一時停止するとどうなりますか? Chromecastの側からイベントについて学ぶために、RemoteMediaClientにはコールバックがあります。
private val castStatusCallback = object : RemoteMediaClient.Callback() { override fun onStatusUpdated() { // check and update current state } } remoteMediaClient.registerCallback(castStatusCallback)
現在の進行状況を知ることも簡単です。
val periodMills = 1000L remoteMediaClient.addProgressListener( RemoteMediaClient.ProgressListener { progressMills, durationMills -> // show progress in your UI }, periodMills )
既存のプレーヤーとの統合経験
私が取り組んでいたアプリケーションには、すでに既製のメディアプレーヤーがありました。 目標は、Chromecastサポートを統合することでした。 メディアプレーヤーはステートマシンに基づいており、最初に考えたのは新しい状態「CastingState」を追加することでした。 ただし、各プレーヤーの状態が再生状態を反映しているため、このアイデアはすぐに拒否されました。ExoPlayerまたはChromeCastの実装として機能するかどうかは関係ありません。
その後、プレーヤーの「ライフサイクル」の優先順位付けと処理を行う特定のデリゲートシステムを作成するというアイデアが生まれました。 すべてのデリゲートは、プレーヤーステータスイベントを受信できます:プレイ、一時停止など。 -ただし、主任代理人のみがメディアコンテンツを再生します。
このプレーヤーインターフェイスのようなものがあります。
interface Player { val isPlaying: Boolean val isReleased: Boolean val duration: Long var positionInMillis: Long var speed: Float var volume: Float var loop: Boolean fun addListener(listener: PlayerCallback) fun removeListener(listener: PlayerCallback): Boolean fun getListeners(): MutableSet<PlayerCallback> fun prepare(mediaContent: MediaContent) fun play() fun pause() fun release() interface PlayerCallback { fun onPlaying(currentPosition: Long) fun onPaused(currentPosition: Long) fun onPreparing() fun onPrepared() fun onLoadingChanged(isLoading: Boolean) fun onDurationChanged(duration: Long) fun onSetSpeed(speed: Float) fun onSeekTo(fromTimeInMillis: Long, toTimeInMillis: Long) fun onWaitingForNetwork() fun onError(error: String?) fun onReleased() fun onPlayerProgress(currentPosition: Long) } }
内部には非常に多くの状態を持つ状態マシンがあります:
- 空-初期化前の初期状態。
- 準備中-プレーヤーはメディアコンテンツの再生を開始します。
- 準備完了-メディアがアップロードされ、再生する準備ができました。
- 遊ぶ
- 一時停止
- ネットワークを待っています
- エラー
以前は、初期化中の各状態がExoPlayerでコマンドを発行していました。 これで、Playingデリゲートのリストにコマンドが発行され、「Lead」デリゲートがそれを処理できるようになります。 デリゲートはプレーヤーのすべての機能を実装するため、プレーヤーのインターフェースから継承し、必要に応じて個別に使用することもできます。 次に、抽象デリゲートは次のようになります。
abstract class PlayingDelegate( protected val playerCallback: Player.PlayerCallback, var isLeading: Boolean = false ) : Player { fun setIsLeading(isLeading: Boolean, positionMills: Long, isPlaying: Boolean) { this.isLeading = isLeading if (isLeading) { onLeading(positionMills, isPlaying) } else { onDormant() } } final override fun addListener(listener: Player.PlayerCallback) { // do nothing } final override fun removeListener(listener: Player.PlayerCallback): Boolean { return false } final override fun getListeners(): MutableSet<Player.PlayerCallback> { return mutableSetOf() } /** * */ open fun netwarkIsRestored() { // do nothing } /** * */ abstract fun onLeading(positionMills: Long, isPlaying: Boolean) /** * */ abstract fun onIdle() /** * . * , * . */ abstract fun readyForLeading(): Boolean }
たとえば、インターフェイスを簡素化しました。 実際には、もう少しイベントがあります。
複製ソースと同数のデリゲートが存在する可能性があります。 Chromecastデリゲートは次のようになります。
class ChromeCastDelegate( private val context: Context, private val castCallback: ChromeCastListener, playerCallback: Player.PlayerCallback ) : PlayingDelegate(playerCallback) { companion object { private const val CONTENT_TYPE_VIDEO = "videos/mp4" private const val CONTENT_TYPE_AUDIO = "audio/mp3" private const val PROGRESS_DELAY_MILLS = 500L } interface ChromeCastListener { fun onCastStarted() fun onCastStopped() } private var sessionManager: SessionManager? = null private var currentSession: CastSession? = null private var mediaContent: MediaContent? = null private var currentPosition: Long = 0 private val mediaSessionListener = object : SessionManagerListener<CastSession> { override fun onSessionStarted(session: CastSession, sessionId: String) { currentSession = session castCallback.onCastStarted() } override fun onSessionEnding(session: CastSession) { currentPosition = session.remoteMediaClient?.approximateStreamPosition ?: currentPosition stopCasting() } override fun onSessionResumed(session: CastSession, wasSuspended: Boolean) { currentSession = session castCallback.onCastStarted() } override fun onSessionStartFailed(session: CastSession, p1: Int) { stopCasting() } override fun onSessionEnded(session: CastSession, p1: Int) { // do nothing } override fun onSessionResumeFailed(session: CastSession, p1: Int) { // do nothing } override fun onSessionSuspended(session: CastSession, p1: Int) { // do nothing } override fun onSessionStarting(session: CastSession) { // do nothing } override fun onSessionResuming(session: CastSession, sessionId: String) { // do nothing } } private val castStatusCallback = object : RemoteMediaClient.Callback() { override fun onStatusUpdated() { if (currentSession == null) return val playerState = currentSession!!.remoteMediaClient.playerState when (playerState) { MediaStatus.PLAYER_STATE_PLAYING -> playerCallback.onPlaying(positionInMillis) MediaStatus.PLAYER_STATE_PAUSED -> playerCallback.onPaused(positionInMillis) } } } private val progressListener = RemoteMediaClient.ProgressListener { progressMs, durationMs -> playerCallback.onPlayerProgress(progressMs) } // Playing delegate override val isReleased: Boolean = false override var loop: Boolean = false override val isPlaying: Boolean get() = currentSession?.remoteMediaClient?.isPlaying ?: false override val duration: Long get() = currentSession?.remoteMediaClient?.streamDuration ?: 0 override var positionInMillis: Long get() { currentPosition = currentSession?.remoteMediaClient?.approximateStreamPosition ?: currentPosition return currentPosition } set(value) { currentPosition = value checkAndStartCasting() } override var speed: Float = SpeedProvider.default() set(value) { field = value checkAndStartCasting() } override var volume: Float get() = currentSession?.volume?.toFloat() ?: 0F set(value) { currentSession?.volume = value.toDouble() } override fun prepare(mediaContent: MediaContent) { sessionManager = CastContext.getSharedInstance(context).sessionManager sessionManager?.addSessionManagerListener(mediaSessionListener, CastSession::class.java) currentSession = sessionManager?.currentCastSession this.mediaContent = mediaContent playerCallback.onPrepared() } override fun play() { if (isLeading) { currentSession?.remoteMediaClient?.play() } } override fun pause() { if (isLeading) { currentSession?.remoteMediaClient?.pause() } } override fun release() { stopCasting(true) } override fun onLeading(positionMills: Long, isPlaying: Boolean) { currentPosition = positionMills checkAndStartCasting() } override fun onIdle() { // TODO } override fun readyForLeading(): Boolean { return currentSession != null } // internal private fun checkAndStartCasting() { if (currentSession != null && mediaContent?.metadata != null && isLeading) { val mediaMetadata = MediaMetadata(getMetadataType(mediaContent!!.type)).apply { putString(MediaMetadata.KEY_TITLE, mediaContent?.metadata?.title.orEmpty()) putString(MediaMetadata.KEY_ARTIST, mediaContent?.metadata?.author.orEmpty()) mediaContent?.metadata?.posterUrl?.let { poster -> addImage(WebImage(Uri.parse(poster))) } } val mediaInfo = MediaInfo.Builder(mediaContent!!.contentUri.toString()) .setContentType(getContentType(mediaContent!!.type)) .setStreamType(MediaInfo.STREAM_TYPE_BUFFERED) .setMetadata(mediaMetadata) .build() val mediaLoadOptions = MediaLoadOptions.Builder() .setPlayPosition(currentPosition) .setAutoplay(true) .setPlaybackRate(speed.toDouble()) .build() val remoteMediaClient = currentSession!!.remoteMediaClient remoteMediaClient.unregisterCallback(castStatusCallback) remoteMediaClient.load(mediaInfo, mediaLoadOptions) remoteMediaClient.registerCallback(castStatusCallback) remoteMediaClient.addProgressListener(progressListener, PROGRESS_DELAY_MILLS) } } private fun stopCasting(removeListener: Boolean = false) { if (removeListener) { sessionManager?.removeSessionManagerListener(mediaSessionListener, CastSession::class.java) } currentSession?.remoteMediaClient?.unregisterCallback(castStatusCallback) currentSession?.remoteMediaClient?.removeProgressListener(progressListener) currentSession?.remoteMediaClient?.stop() currentSession = null if (isLeading) { castCallback.onCastStopped() } } private fun getContentType(mediaType: MediaContent.Type) = when (mediaType) { MediaContent.Type.AUDIO -> CONTENT_TYPE_AUDIO MediaContent.Type.VIDEO -> CONTENT_TYPE_VIDEO } private fun getMetadataType(mediaType: MediaContent.Type) = when (mediaType) { MediaContent.Type.AUDIO -> MediaMetadata.MEDIA_TYPE_MUSIC_TRACK MediaContent.Type.VIDEO -> MediaMetadata.MEDIA_TYPE_MOVIE } }
再生に関するコマンドを与える前に、主要なデリゲートを決定する必要があります。 これを行うには、プレーヤーに優先度の順に追加し、それぞれがreadyForLeading()メソッドで準備状態を提供できます。 完全なサンプルコードはGitHubで見ることができます。
ChromeCastの後に生活はありますか
Chromecastのサポートをアプリケーションに統合した後、ヘッドフォンだけでなく、Google Homeを使って家に帰ってオーディオブックを楽しむことがより楽しくなりました。 アーキテクチャに関しては、異なるアプリケーションでのプレーヤーの実装は異なる場合があるため、このアプローチはどこでも適切ではありません。 しかし、私たちのアーキテクチャについては、それが出てきました。 この記事がお役に立てば幸いです。近い将来、デジタル環境と統合できるアプリケーションがさらに増えることを期待しています。