Why integration of Google Cast alongside Exoplayer really sucks?

Recently I played around with the integration of Google Cast inside Exoplayer and I was really amazed to see how bad it is and how much it sucks. I decided to write this post with the idea it will help someone else who is also struggling with the Google Cast integration for exoplayer.

Requirements

So the requirements I had for the integration of Google Cast are quite simple:

  • Play an item on cast
  • Play a list of items – when you press Next you go to the next item
  • Forward / Backwards with 30 seconds – so you can skip through the track
  • Play / Pause support
  • Switch between lists of items – you can player item 2 of List A, you should be able to switch to item 3 of List B
  • Switch between phone playback and cast playback – switch from cast to app and from app to cast
  • Update the progress when casting – time elapsed should also be updated
  • Volume up/down are supported

Implementation approach

The implementation is a bit tricky but there are some obvious things it needs to support:

  1. When you switch from SimpleExoPlayer to CastPlayer, cast player should continue from where SimpleExoPlayer left – the episode you listened to (author, title, icon), the time you reached, the next items to be continued with
  2. It should also supports all of the already present things like play, pause, seek, next
  3. It should also pass data back to SimpleExoPlayer when the user switches back to it

Both SimpleExoPlayer and CastPlayer extend from this BasePlayer interface so all options in point 2 should work straight out of the box. But is it like that? Let’s see.

Sample code of the implementation:

import com.google.android.exoplayer2.*
import com.google.android.exoplayer2.audio.AudioAttributes
import com.google.android.exoplayer2.audio.AudioListener
import com.google.android.exoplayer2.ext.cast.CastPlayer
import com.google.android.exoplayer2.ext.cast.SessionAvailabilityListener
import com.google.android.exoplayer2.ext.okhttp.OkHttpDataSource
import com.google.android.exoplayer2.source.DefaultMediaSourceFactory
import com.google.android.exoplayer2.source.ProgressiveMediaSource
import com.google.android.exoplayer2.upstream.FileDataSource
import okhttp3.OkHttpClient
import javax.inject.Inject
class MyCustomPlayerPlayer @Inject constructor(
private val context: Context,
private val castPlayerManager: CastPlayerManager,
private val mediaItemMapper: MediaItemMapper,
private val mediaQueueItemMapper: MediaQueueItemMapper
) {
private val mediaItemsCastQueue = mutableSetOf<MediaItem>()
private val onMediaItemTransitionListener = object : Player.EventListener {
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
lastWindowIndexId = currentPlayer.currentWindowIndex
}
}
private val currentAudioComponent: Player.AudioComponent?
get() = currentPlayer.audioComponent
private val castPlayer: CastPlayer?
get() = castPlayerManager.getCastPlayer()
private var lastWindowIndexId: Int = 0
private val playerEventListener = PlayerEventListener()
private val fileDataSourceFactory = ProgressiveMediaSource.Factory(FileDataSource.Factory())
private lateinit var simpleExoPlayer: SimpleExoPlayer
private val chromecastSessionSelectedListener = object : SessionAvailabilityListener {
override fun onCastSessionAvailable() {
if (castPlayer != null) {
mediaItemsCastQueue.addAll(simpleExoPlayer.getMediaItems())
setCurrentPlayer(castPlayer!!)
}
}
override fun onCastSessionUnavailable() {
setCurrentPlayer(simpleExoPlayer)
}
}
private lateinit var currentPlayer: Player
val currentPosition
get() = currentPlayer.currentPosition
val mediaItemCount
get() = currentPlayer.mediaItemCount
val shouldPersistState
get() = currentPlayer.shouldPersistState
val isPlaying
get() = currentPlayer.isPlaying
val duration
get() = currentPlayer.duration
val playbackParameters
get() = currentPlayer.playbackParameters
val currentWindowIndex
get() = currentPlayer.currentWindowIndex
override val playbackState
get() = currentPlayer.playbackState
override val isCasting
get() = currentPlayer is CastPlayer || castPlayerManager.isCasting()
override val bufferedPercentage: Int
get() = currentPlayer.bufferedPercentage
fun init() {
simpleExoPlayer = createExoplayer()
currentPlayer = if (castPlayer?.isCastSessionAvailable == true) castPlayer!! else simpleExoPlayer
addListener(playerEventListener)
addListener(onMediaItemTransitionListener)
addAudioListener(playerEventListener)
castPlayerManager.setAvailabilityListener(chromecastSessionSelectedListener)
}
private fun createExoplayer(): SimpleExoPlayer {
val audioStreamInfo = AudioAttributes.Builder()
.setContentType(C.CONTENT_TYPE_SPEECH)
.setUsage(C.USAGE_MEDIA)
.build()
val okHttpClient = OkHttpClient.Builder().build()
return SimpleExoPlayer.Builder(context)
.setAudioAttributes(audioStreamInfo, true)
.setMediaSourceFactory(DefaultMediaSourceFactory(OkHttpDataSource.Factory(okHttpClient)))
.build()
}
fun getCurrentPlayer() = currentPlayer
fun clearMediaItems() {
mediaItemsCastQueue.clear()
currentPlayer.clearMediaItems()
}
fun setEpisodes(episodesList: List<Episode>, startIndex: Int, positionMs: Long) {
if (episodesList.isNullOrEmpty()) return
mediaItemsCastQueue.clear()
currentPlayer.clearMediaItems()
if (isCasting) {
mediaItemsCastQueue.addAll(
// Casting for files is not possible. Google Cast does not work
// https://stackoverflow.com/questions/32049851/it-is-posible-to-cast-or-stream-android-chromecast-a-local-file
episodesList
.filter { it.streamUrl?.startsWith("http") == true }
.map { mediaItemMapper.toMediaItem(it) }
)
playerEventListener.onMediaItemTransition(
mediaItemsCastQueue.elementAt(startIndex),
Player.MEDIA_ITEM_TRANSITION_REASON_SEEK
)
setupMediaItemsForCast(startIndex, positionMs)
} else {
setEpisodesForExoPlayer(episodesList)
currentPlayer.seekTo(startIndex, positionMs)
}
}
private fun setupMediaItemsForCast(startIndex: Int, positionMs: Long) {
castPlayer!!.loadItems(
mediaItemsCastQueue.map { mediaQueueItemMapper.toMediaQueueItem(it) }.toTypedArray(),
startIndex, positionMs, Player.REPEAT_MODE_OFF
)
}
private fun setEpisodesForExoPlayer(episodesList: List<Episode>) {
episodesList.forEach { episode ->
if (episode.streamUrl!!.startsWith("http")) {
val mediaItem = mediaItemMapper.toMediaItem(episode)
mediaItemsCastQueue.add(mediaItem)
currentPlayer.addMediaItem(mediaItem)
} else {
(currentPlayer as SimpleExoPlayer).addMediaSource(
fileDataSourceFactory.createMediaSource(mediaItemMapper.toMediaItem(episode))
)
}
}
}
fun addListener(listener: Player.EventListener) {
currentPlayer.addListener(listener)
}
private fun addAudioListener(listener: AudioListener) {
currentAudioComponent?.addAudioListener(listener)
}
fun addEventListener(listener: PlayerListenerInterface) {
playerEventListener.addListener(listener)
}
fun removeEventListener(listener: PlayerListenerInterface) {
playerEventListener.removeListener(listener)
}
fun getMediaItemAt(index: Int): MediaItem {
return currentPlayer.getMediaItemAt(index)
}
fun seekTo(positionMs: Long) {
currentPlayer.seekTo(positionMs)
}
fun prepare() {
currentPlayer.prepare()
}
fun play() {
currentPlayer.play()
}
fun pause() {
currentPlayer.pause()
}
fun previous() {
currentPlayer.previous()
}
fun next() {
currentPlayer.next()
}
fun hasNext() = currentPlayer.hasNext()
fun hasPrevious() = currentPlayer.hasPrevious()
fun setPlayWhenReady(playWhenReady: Boolean) {
currentPlayer.playWhenReady = playWhenReady
}
fun stop() {
if (::currentPlayer.isInitialized) {
currentPlayer.stop()
}
}
fun setPlaybackParameters(playbackParameters: PlaybackParameters) {
currentPlayer.setPlaybackParameters(playbackParameters)
}
fun release() {
mediaItemsCastQueue.clear()
castPlayerManager.release()
playerEventListener.removeAllListeners()
if (::currentPlayer.isInitialized) {
currentPlayer.removeListener(playerEventListener)
currentPlayer.removeListener(onMediaItemTransitionListener)
currentAudioComponent?.removeAudioListener(playerEventListener)
currentPlayer.stop()
currentPlayer.release()
}
}
private fun setCurrentPlayer(newPlayer: Player) {
if (this.currentPlayer === newPlayer) {
return
}
val previousPlayer = this.currentPlayer
val previousPlayerState = previousPlayer.getCurrentPlayerState()
previousPlayer.stop()
currentPlayer = newPlayer
currentPlayer.setPlayerState(previousPlayerState)
addListener(playerEventListener)
addAudioListener(playerEventListener)
previousPlayer.removeListener(playerEventListener)
previousPlayer.audioComponent?.removeAudioListener(playerEventListener)
}
private fun Player.setPlayerState(playerState: PlayerState) {
setMediaItems(mediaItemsCastQueue.toList(), playerState.currentWindowIndex, playerState.currentPosition)
playWhenReady = playerState.playWhenReady
prepare()
}
private fun Player.getCurrentPlayerState() = PlayerState(
currentPosition = if (playbackState != Player.STATE_ENDED) currentPosition else C.TIME_UNSET,
currentWindowIndex = if (playbackState != Player.STATE_ENDED) lastWindowIndexId else C.INDEX_UNSET,
playbackState = playbackState,
playWhenReady = if (playbackState != Player.STATE_ENDED) playWhenReady else false
)
private fun Player.getMediaItems(): List<MediaItem> {
return List(mediaItemCount) { getMediaItemAt(it) }
}
private val Player.shouldPersistState
get() = playbackState != Player.STATE_IDLE && playbackState != Player.STATE_ENDED
fun getCurrentMediaItemId(): String? {
val currentMediaIndex = currentPlayer.currentWindowIndex
lastWindowIndexId = currentMediaIndex
if (currentPlayer is CastPlayer) {
val queueSize = mediaItemsCastQueue.size
return mediaItemsCastQueue.takeIf { queueSize > currentMediaIndex }?.elementAt(currentMediaIndex)?.mediaId
} else {
return currentPlayer.currentMediaItem?.mediaId
}
}
data class PlayerState(
val currentPosition: Long,
val currentWindowIndex: Int,
val playbackState: Int? = null,
val playWhenReady: Boolean
)
}

So basically we have this single player class who knows both about SimpleExoPlayer and CastPlayer. It keeps as state the currentPlayer that is playing and when you switch from one to the other, it just replaces the currentPlayer instance.

But as you can already see, not everything is written in the best way it could be.

Issues

Keeping state locally

One of the things you will see is the mediaItemsCastQueue array of media items. You would expect that when you pass to a player a list of MediaItems, it is the one that knows about it so you don’t have to keep it yourself. Right? Well, NO.

currentMediaItem is empty

When you try to do CastPlayer.currentMediaItem and if you check the current implementation of CastTimeline, you will see a shit there: https://github.com/google/ExoPlayer/issues/8212

getWindow(windowIndex) is the one that is used to get the MediaItem object. This method just always returns a new media items with some kind of a random ID set inside the tag object. So whenever you call CastPlayer.currentMediaItem, it is actually a new MediaItem with a random tag. Total bullshit.

In order to make it work you need to keep the list of MediaItems that you passed to the player, locally in your own custom player implementation. FYI: Google does the same total bullshit.

currentWindowIndex is 0

Let’s say your current player is CastPlayer. You switch from casting to the local phone player meaning from CastPlayer to SimpleExoPlayer. You listened to episode 5 out of 10, you call getCurrentWindowIndex() and it returns 0 and you are like “WTF is that !?”.

For some reason, maybe becase the cast player got detached, the currentWindowIndex position is always 0 and when you switch to SimpleExoPlayer, it always starts from episode 1. You know what is the solution, right? Keep the lastWindowIndex state yourself …

MediaItemConverter from MediaItem to MediaQueueItem

The guys from Cast decided to support the MediaItem data class that is already present in SimpleExoPlayer. Previously, they used to have MediaQueueItem class that offered things like author, title of episode, subtitle and image. MediaItem has only title. That’s all. You can imagine how different a player looks when you have all the details + an image and when you have one title + controls and nothing else.

And in order to get this better look, you have to register a mapper when you create your cast player, that looks like this:

private const val KEY_EPISODE = "episode"
class MediaQueueItemMapper @Inject constructor(
private val gson: Gson,
private val mediaItemMapper: MediaItemMapper // Our custom implementation
) : MediaItemConverter {
override fun toMediaQueueItem(mediaItem: MediaItem): MediaQueueItem {
val episode = mediaItem.playbackProperties?.tag as Episode
return toMediaQueueItem(episode)
}
override fun toMediaItem(mediaQueueItem: MediaQueueItem): MediaItem {
return mediaItemMapper.toMediaItem(gson.fromJson(mediaQueueItem.customData.getString(KEY_EPISODE), Episode::class.java))
}
private fun toMediaQueueItem(episode: Episode): MediaQueueItem = with(episode) {
val mediaMetaData = MediaMetadata(MediaMetadata.MEDIA_TYPE_AUDIOBOOK_CHAPTER)
mediaMetaData.putString(MediaMetadata.KEY_SUBTITLE, author)
mediaMetaData.putString(MediaMetadata.KEY_TITLE, title)
val coverImage = if (coverImageWithFallback != null) {
coverImageWithFallback
} else {
BuildConfig.CAST_FALLBACK_COVER_URL
}
mediaMetaData.addImage(WebImage(Uri.parse(coverImage)))
val rawStreamUrl = Uri.parse(this.streamUrl).buildUrl()
val mediaInfo = MediaInfo.Builder(rawStreamUrl).apply {
setStreamType(MediaInfo.STREAM_TYPE_BUFFERED)
setMetadata(mediaMetaData)
}.build()
val mediaQueueItem = MediaQueueItem.Builder(mediaInfo).build()
mediaQueueItem.customData = JSONObject()
mediaQueueItem.customData.put(KEY_EPISODE, gson.toJson(episode))
return mediaQueueItem
}
}

And why!? Come on guys, you transition to a new structure but drop support for the cooler look? I am really amazed what things are required in order to implement a decent looking feature.

onMediaItemTransition not called when loading items

Another issue that popped up is that when you call loadItems on the SimpleExoPlayer and you call seekTo(position = 5) let’s say, it would call the onMediaItemTransition callback to notify that a media item is being player. Well, guess what? Cast player does not invoke the callback in this case at all. You have to call it yourself.

Loading items the deprecated way

We used to have the following approach when loading items to play in the player:

  • Get a list of the items
  • Pass this list to Player.setMediaItems
  • Call seekTo(position) to start from item 5 for example
  • Set playWhenReady = true

Cast player doesn’t work that way. You would get an Invalid Request code that doesn’t have any meaningful explanation that you can use to fix it. The error happens because the items are still being loaded when you call seekTo. That’s why I ended up using the loadMediaItems method on the cast player which also accepts the position it should start from. There is no mention in. the docs that if you call seekTo immediately after setMediaItems, the player would not work.

Conclusion

The current implementation of Google Cast inside ExoPlayer sucks. There are a lot of improvements to be made. And the above issues that I listed – I am sure there are even a lot more. I am not sure to say that “I hope to see improvements” because I really don’t think there is any place for hope here. If Google sells Google Cast devices for 45 euro and they are the main contributor to Exoplayer – they should invest at least 25 euro of each sell into improving the shit they have made. Until then I just hope to never have to work with this piece of s***dk anywhere soon. Thanks!

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s