Смотрю и слушаю где хочу. Интегрируем Chromecast в Android-приложение +18



На улице я часто слушаю аудиокниги и подкасты со смартфона. Когда прихожу домой, мне хочется продолжить слушать их на Android TV или Google Home. Но далеко не все приложения поддерживают Chromecast. А было бы удобно.


По статистике Google за последние 3 года, количество девайсов на Android TV увеличилось в 4 раза, а число партнеров-производителей уже превысило сотню: «умные» телевизоры, колонки, TV-приставки. Все они поддерживают Chromecast. Но в маркете ещё много приложений, которым явно не хватает интеграции с ним.


В этой статье я хочу поделиться своим опытом интеграции Chromecast в Android-приложение для воспроизведения медиа-контента.


Как это работает


Если вы впервые слышите слово «Chromecast», то постараюсь вкратце рассказать. С точки зрения пользования, это выглядит примерно так:


  1. Пользователь слушает музыку или смотрит видео через приложение или веб-сайт.
  2. В локальной сети появляется Chromecast-девайс.
  3. В интерфейсе плеера должна появиться соответствующая кнопка.
  4. Нажав её, пользователь выбирает нужный девайс из списка. Это может быть Nexus Player, Android TV или «умная» колонка.
  5. Дальше воспроизведение продолжается именно с этого девайса.


Технически происходит примерно следующее:


  1. Google Services отслеживают наличие Chromecast девайсов в локальной сети посредством бродкастинга.
  2. Если к вашему приложению подключен MediaRouter, то вам придёт событие об этом.
  3. Когда пользователь выбирает Cast-девайс, и подключается к нему, открывается новая медиа-сессия (CastSession).
  4. Уже в созданную сессию мы будем передавать контент для воспроизведения.
    Звучит очень просто.

Интеграция


У Google есть свой SDK для работы с Chromecast, но он плохо покрыт документацией, а его код обфусцирован. Поэтому многие вещи пришлось проверять методом тыка. Давайте обо всём по порядку.


Инициализация


Для начала нам надо подключить 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
   }
}

И объявить его в Manifest:


<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$. После публикации ChromeCast Application нужно немного подождать.
В консоли можно изменить внешний вид Cast-плеера для девайсов с экраном и посмотреть аналитику кастинга в рамках приложения.


MediaRouter


MediaRouteFramework – это механизм, который позволяет находить все удалённые устройства воспроизведения вблизи пользователя. Это может быть не только Chromecast, но и удалённые дисплеи и колонки с использованием сторонних протоколов. Но нас интересует именно Chromecast.



В MediaRouteFramework есть View, которая отражает состояние медиароутера. Есть два способа её подключить:


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) Через layout:


<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 поддерживает три основных вида контента:


  • Audio;
  • Video;
  • Photo.

В зависимости от настроек плеера, типа медиаконтента и cast-девайса, интерфейс плеера может отличаться.


CastSession


Итак, пользователь выбрал нужный девайс, 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

У нас есть два основных условия, при которых мы можем начинать кастинг:


  1. Сессия уже открыта.
  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 много, и их лучше посмотреть в документации. Приятно удивило, что можно добавить изображение не через bitmap, а просто ссылкой внутри 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. В основе медиаплеера лежал State Machine, и первой мыслью было добавить новое состояние: «CastingState». Но эта идея сразу была отвергнута, потому что каждое состояние плеера отражает состояние воспроизведения, и не важно, что служит реализацией ExoPlayer или ChromeCast.
Тогда пришла идея сделать некую систему делегатов с расстановкой приоритетов и обработкой «жизненного цикла» плеера. Все делегаты могут получать события о состоянии плеера: Play, Pause и т.д. — но только ведущий делегат будет воспроизводить медиаконтент.



У нас есть примерно такой интерфейс плеера:


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)
   }
}

Внутри у него будет лежать State Machine с таким множеством состояний:


  • Empty — начальное состояние до инициализации.
  • Preparing — плеер инициализирует воспроизведение медиаконтента.
  • Prepared — медиаданные загружены и готовы к воспроизведению.
  • Playing
  • Paused
  • WaitingForNetwork
  • Error


Раньше каждое состояние при инициализации отдавало команду в ExoPlayer. Теперь оно будет отдавать команду в список Playing-делегатов, и «Ведущий» делегат сможет его обработать. Поскольку делегат реализует все функции плеера, то его тоже можно наследовать от интерфейса плеера и при необходимости использовать отдельно. Тогда абстрактный делегат будет выглядеть так:


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 может выглядеть примерно так:


ChromeCastDelegate.kt
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. Что касается архитектуры, то реализация плееров в разных приложениях может различаться, поэтому не везде такой подход будет уместен. Но для нашей архитектуры это подошло. Надеюсь, эта статья была полезной, и в ближайшем будущем появится больше приложений, умеющих интегрироваться с цифровым окружением!




К сожалению, не доступен сервер mySQL