На улице я часто слушаю аудиокниги и подкасты со смартфона. Когда прихожу домой, мне хочется продолжить слушать их на Android TV или Google Home. Но далеко не все приложения поддерживают Chromecast. А было бы удобно.
По статистике Google за последние 3 года, количество девайсов на Android TV увеличилось в 4 раза, а число партнеров-производителей уже превысило сотню: «умные» телевизоры, колонки, TV-приставки. Все они поддерживают Chromecast. Но в маркете ещё много приложений, которым явно не хватает интеграции с ним.
В этой статье я хочу поделиться своим опытом интеграции Chromecast в Android-приложение для воспроизведения медиа-контента.
Как это работает
Если вы впервые слышите слово «Chromecast», то постараюсь вкратце рассказать. С точки зрения пользования, это выглядит примерно так:
- Пользователь слушает музыку или смотрит видео через приложение или веб-сайт.
- В локальной сети появляется Chromecast-девайс.
- В интерфейсе плеера должна появиться соответствующая кнопка.
- Нажав её, пользователь выбирает нужный девайс из списка. Это может быть Nexus Player, Android TV или «умная» колонка.
- Дальше воспроизведение продолжается именно с этого девайса.
Технически происходит примерно следующее:
- Google Services отслеживают наличие Chromecast девайсов в локальной сети посредством бродкастинга.
- Если к вашему приложению подключен MediaRouter, то вам придёт событие об этом.
- Когда пользователь выбирает Cast-девайс, и подключается к нему, открывается новая медиа-сессия (CastSession).
- Уже в созданную сессию мы будем передавать контент для воспроизведения.
Звучит очень просто.
Интеграция
У 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
У нас есть два основных условия, при которых мы можем начинать кастинг:
- Сессия уже открыта.
- Есть контент для кастинга.
При каждом из этих двух событий можем проверять состояние, и если всё в порядке, то начинать кастить.
Кастинг
Теперь, когда у нас есть что кастить и куда кастить, можем перейти к самому главному. Помимо всего прочего, у 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 может выглядеть примерно так:
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. Что касается архитектуры, то реализация плееров в разных приложениях может различаться, поэтому не везде такой подход будет уместен. Но для нашей архитектуры это подошло. Надеюсь, эта статья была полезной, и в ближайшем будущем появится больше приложений, умеющих интегрироваться с цифровым окружением!
Автор: Konstantin Kulikov