The Curious Incident of Google Cast in Jetpack Compose
The Cast SDK from Google is everything but modern in terms of the officially supported ways to integrate it into modern Android apps. This is especially true if you're using Jetpack Compose. In this blog post, we'll explore how can you make it work in a Compose application, without the need of reinventing the wheel.
The problem
At Lovely Systems currently we're actively exploring the possibilities, pros/cons of Kotlin Multiplatform and Jetpack Compose. One of our task was to integrate the Google Cast SDK into a Compose application, and it turned out that it's not that straightforward as we thought. The Cast SDK is not really designed to work with Compose, so we had to find a way to make it work.
What would work out of the box with Compose?
Well, if you would like to implement every single user-facing feature of casting, then I have a good news: you can, and it will work without any problem with Compose. This means, that you can use the CastContext
provided by the library, and by interacting with it, you can also connect to casting compatible devices, and you can start casting your content to them.
But be aware! This means that you'll need to take care of everything, including every single UI element, interaction, state handling, etc.
The only requirement in this case is that you need to instantiate a CastContext
in one of your activities like this:
val castContext = CastContext.getSharedInstance(context)
...and then you can get the same instance practically anywhere in your code by calling CastContext.getSharedInstance()
again.
There is this great example project, so you can grab the essence what this means in practice: Gidex/AndroidCast
If you would like to build something very custom, then this is the way to go, but if you would like to have a more "native" experience, then you have to do some extra work.
The latter was our case too, because we would like to use the built-in UI elements, for example the connection dialog, the controllers, etc.
The bad things first
It's unfortunate, but this guide won't make you able to use the Cast SDK's UI widgets without:
- an
AppCompat
based theme, and - a
FragmentActivity
So far we haven't found a way to make them work without these two, but if you have, please let me know! :)
The problem is that the SDK heavily relies on these two when it comes to the UI elements, starting with the fundamental MediaRouteButton
, that seems to be one of the most important parts of the whole UI flow. If that's not initialized correctly, practically nothing will work.
An extra pinch of salt
We had an extra requirement too, which made the thing one step more complicated: we couldn't use the actual MediaRouteButton
provided by the SDK, because we had to show the device chooser dialog manually, triggered by a different kind of interaction inside the app, so the idea was to fake a click on the button in the background, when the user interacts with the app in a certain way. Anything else however should work as is, including the controllers, the connection dialog, etc.
If you would like to replace the built-in MediaRouteButton
with your own Composable
then you're probably in the same situation as we were, so this guide is for you!
The solution
The dependencies
First of all, you'll need these two libraries
# libs.versions.toml
google-cast-framework = { module = "com.google.android.gms:play-services-cast-framework", version = "22.0.0" }
androidx-mediarouter = { module = "androidx.mediarouter:mediarouter", version = "1.7.0" }
Also in your build.gradle.kts
:
// build.gradle.kts
implementation(libs.google.cast.framework)
implementation(libs.androidx.mediarouter)
values/styles.xml
The styling possibilities of the SDK are documented here.
This is a quite minimalistic styles.xml
file, however any other AppCompat
based theme should work too.
<resources>
<!-- Base application theme. -->
<style name="AppTheme" parent="@style/Theme.AppCompat.Light.NoActionBar">
<!-- Styling for google cast. Using an AppCompat based theme is a must, otherwise the
built-in stuff from the Cast SDK won't work -->
<item name="colorPrimary">#03535E</item>
<item name="mediaRouteButtonTint">#ffffff</item>
</style>
</resources>
The CastOptionsProvider
This is pretty much the basic, documented example. Be sure that you include your own receiver application ID!
// CastOptionsProvider.kt
/**
* Provides the CastOptions for the Cast SDK. Referenced in the AndroidManifest.xml.
*/
class CastOptionsProvider : OptionsProvider {
override fun getCastOptions(context: Context): CastOptions {
return CastOptions.Builder()
.setReceiverApplicationId(context.getString({{YOUR_RECEIVER_APPLICATION_ID}}))
.build()
}
override fun getAdditionalSessionProviders(context: Context): List<SessionProvider>? {
return null
}
}
In your manifest you need to reference your provider:
<application>
...
<meta-data
android:name=
"com.google.android.gms.cast.framework.OPTIONS_PROVIDER_CLASS_NAME"
android:value="com.foo.CastOptionsProvider" />
</application>
And this is where the things get interesting. I've already mentioned that we had to show the device chooser dialog manually, so we had to fake a click on the MediaRouteButton
in the background, and the story will be the same if you would like to replace the button with your own Composable
.
Something to keep tabs on your own MediaRouteButton
instances
In this step we'll do two things:
- Define our own extended version of
MediaRouteButton
- Create a singleton that will keep track of the instances of our custom buttons
We need to do so, because these buttons can be created and destroyed dynamically, so we need to explicitly know when these events happen.
import android.content.Context
import androidx.mediarouter.app.MediaRouteButton
/**
* An extended version of the cast framework's built-in [MediaRouteButton] that allows us to keep
* track of the valid instances. The idea is to bind the addition and removal of the button to the
* event when the button is attached and detached from the window respectively.
*/
class CustomMediaRouteButton(context: Context): MediaRouteButton(context) {
override fun onAttachedToWindow() {
super.onAttachedToWindow()
MediaRouteButtonManager.add(this)
}
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
MediaRouteButtonManager.remove(this)
}
}
/**
* A helper singleton to store the valid [CustomMediaRouteButton] instances. Since these buttons can be
* created and destroyed dynamically, we need to keep track of the valid instances.
*/
object MediaRouteButtonManager {
private val buttons = mutableListOf<CustomMediaRouteButton>()
fun add(button: CustomMediaRouteButton) {
buttons.add(button)
}
fun remove(button: CustomMediaRouteButton) {
buttons.remove(button)
}
// Since we only want to fake a click on the button, it's enough to always interact with the last one
val current: CustomMediaRouteButton?
get() = buttons.lastOrNull()
}
Initializing everything inside your Activity
This is the place where you have to initialize the CastContext
, and also where you have to create your custom MediaRouteButton
instances.
And this is where AndroidView
from Compose comes into the picture, because we have to attach the button to a window (even if it won't be visible) in order to make the built-in provisioning logic kick in.
The AndroidView
composable lets you wrap an Android view and use it in your Compose UI, and it also provides you with some lifecycle callbacks, like onRelease
, update
that are especially useful in this case.
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.viewinterop.AndroidView
import androidx.fragment.app.FragmentActivity
import com.google.android.gms.cast.framework.CastButtonFactory
import com.google.android.gms.cast.framework.CastContext
import com.foo.MediaRouteButtonManager
import com.foo.CustomMediaRouteButton
class ExampleActivity : FragmentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Initializing the Cast button and the initial CastContext
val mediaRouteButton = try {
val button = CustomMediaRouteButton(this)
// Lazy load Google Cast context. It needs to be done here where we have a Context,
// otherwise we won't have a CastContext "later" in the application
CastContext.getSharedInstance(this)
CastButtonFactory.setUpMediaRouteButton(applicationContext, button)
button
} catch (ex: Exception) {
// Casting is probably not supported on this device
null
}
setContent {
mediaRouteButton?.let { mRButton ->
// The MediaRouteButton needs to be rendered (more precisely, attached to
// the window) in order to make it able to interact with it. The onRelease and
// update callbacks are used to add and remove the button from the
// MediaRouteButtonManager when the view is exiting the composition or being
// recomposed. Without these callbacks, the activity could become unresponsive
// after it comes back from the background (under certain conditions).
AndroidView(
factory = { _ -> mRButton },
onRelease = { MediaRouteButtonManager.remove(it) },
update = { updatedButton: CustomMediaRouteButton ->
if (updatedButton.isAttachedToWindow) {
MediaRouteButtonManager.add(updatedButton)
}
},
onReset = {}, // The necessary thing here is to have a non-null argument
)
}
// Your other composables, for example your main screen, or anything else
AnotherComposable()
}
}
}
In our example above we've also specified the onReset
callback, which is just a no-op in this case, but it's necessary to have a non-null argument there. It instructs compose that the given view should be reused when the view is moved or removed from the composition. To be honest, I still don't get why, but this was the only thing that fixed a weird issue on our side, when the app was in the background for a long time, and then it became unresponsive right after it came back to the foreground (without any errors in the logs).
The place where everything comes together
Although the necessary stuff are already in place, we need something that will encapsulate and expose the functionality we need, regarding casting. This is the place where you can put your custom logic, for example the logic that will fake a click on the button, load the media, etc.
We call it CastContextManager
in this example (and we use Koin for DI, but that's quite irrelevant, the only point is that you'll need a Context
in there, but it doesn't matter how you pass it, or where you get it from):
import android.content.Context
import android.content.Intent
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.google.android.gms.cast.MediaLoadRequestData
import com.google.android.gms.cast.framework.CastContext
import com.google.android.gms.cast.framework.CastState
import com.google.android.gms.cast.framework.CastStateListener
import com.google.android.gms.cast.framework.media.RemoteMediaClient
import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
enum class CastDeviceState {
CONNECTED,
DISCONNECTED,
}
class CastContextManager : ViewModel(), KoinComponent {
// Get the already initialized instance
private val castCtx: CastContext = CastContext.getSharedInstance().apply {
addCastStateListener(castStateListener())
}
/**
* A reference to the RemoteMediaClient that is used to control the media on the connected
* cast device.
*/
private var remoteMediaClient: RemoteMediaClient? = null
/**
* The current state of the connected cast device.
*/
private var deviceState by mutableStateOf(null as CastDeviceState?)
// We use Koin, but you can pass a Context here on your own, it's up to you
private val appContext: Context by inject()
/**
* Shows the expanded cast controls activity. The activity is started in a new task, so it
* doesn't interfere with the current activity. This is necessary because the expanded cast
* controls activity is a dialog-like activity.
*/
private fun showExpandedControls() {
viewModelScope.launch {
val intent = Intent(appContext, ExpandedControlsActivity::class.java)
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
appContext.startActivity(intent)
}
}
/**
* Shows either the device selector dialog (in case no device is connected) or the expanded
* cast controls (in case a device is connected).
*/
fun showDialog() {
if (deviceState == CastDeviceState.CONNECTED) {
showExpandedControls()
} else {
viewModelScope.launch {
// Faking a click on the MediaRouteButton to show the device selector dialog
MediaRouteButtonManager.current?.performClick()
}
}
}
/**
* Loads the media on the connected cast device. The media is loaded with the given cast data.
* After a successful loading, the expanded cast controls are shown.
* In case of an error, the current cast session is forcefully ended & the "state" of the
* CastContextManager is reset, just in case.
*/
fun loadMedia() {
try {
val loadRequestData = MediaLoadRequestData.Builder()
// Do your own stuff here, like providing the media URL, DRM info, the title, etc.
.build()
viewModelScope.launch {
remoteMediaClient?.load(loadRequestData)
}
} catch (ex: Exception) {
// Error during loading the media
endSession()
return
}
showExpandedControls()
}
/**
* Ends the current cast session & resets the "state" of our CastContextManager
*/
fun endSession() {
viewModelScope.launch {
try {
castCtx?.sessionManager?.endCurrentSession(true)
remoteMediaClient = null
} catch (ex: Exception) {
// Error during ending the cast session
}
}
}
/**
* CastStateListener that listens to the state of the Cast device
*/
private fun castStateListener() = CastStateListener { state: Int ->
when (state) {
CastState.CONNECTED -> {
remoteMediaClient = castCtx?.sessionManager?.currentCastSession?.remoteMediaClient
remoteMediaClient?.stop()
deviceState = CastDeviceState.CONNECTED
}
CastState.NOT_CONNECTED -> {
// The NOT_CONNECTED state is emitted not only when the device is disconnected,
// but as an initial state too (multiple times), so we only want to handle it,
// when the device was previously connected.
if (deviceState == CastDeviceState.CONNECTED) {
endSession()
deviceState = CastDeviceState.DISCONNECTED
}
}
}
}
}
I think the comments are self-explanatory, you can fill the methods with your own logic, and you can also register additional listeners, based on your needs. You can also invoke the showDialog
method from your Composables, for example from a button click, or from a gesture, etc.
One thing left to be clarified, and it's the expanded controls. You can see references in the code above to an ExpandedControlsActivity
, which is a simple activity that shows the expanded controls. These controls are the ones, that are shown when a media is already playing on the cast device, and you can control the playback, the volume, etc. from there.
Setting up the expanded controls
First, you'll need a menu definition in your res/menu
folder:
<?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/media_route_menu_item"
android:title="@string/media_route_menu_item"
app:actionProviderClass="androidx.mediarouter.app.MediaRouteActionProvider"
app:showAsAction="always" />
</menu>
And an Activity
:
import android.view.Menu
import com.google.android.gms.cast.framework.CastButtonFactory
import com.google.android.gms.cast.framework.media.widget.ExpandedControllerActivity
import com.foo.R
/**
* Activity that provides the expanded controls for the Cast SDK.
*/
class ExpandedControlsActivity : ExpandedControllerActivity() {
override fun onCreateOptionsMenu(menu: Menu): Boolean {
super.onCreateOptionsMenu(menu)
menuInflater.inflate(R.menu.cast_expanded_controller_menu, menu)
CastButtonFactory.setUpMediaRouteButton(this, menu, R.id.media_route_menu_item)
return true
}
}
Then you need to use it in your AndroidManifest.xml
:
<activity android:name="com.foo.ExpandedControlsActivity" />
...and in your custom CastOptionsProvider
, so it should look like this at the end:
import android.content.Context
import com.google.android.gms.cast.framework.CastOptions
import com.google.android.gms.cast.framework.OptionsProvider
import com.google.android.gms.cast.framework.SessionProvider
import com.google.android.gms.cast.framework.media.CastMediaOptions
import com.google.android.gms.cast.framework.media.NotificationOptions
/**
* Provides the CastOptions for the Cast SDK. Referenced in the AndroidManifest.xml.
*/
class CastOptionsProvider : OptionsProvider {
override fun getCastOptions(context: Context): CastOptions {
val notificationOptions = NotificationOptions.Builder()
.setTargetActivityClassName(ExpandedControlsActivity::class.java.name)
.build()
val mediaOptions = CastMediaOptions.Builder()
.setNotificationOptions(notificationOptions)
.setExpandedControllerActivityClassName(ExpandedControlsActivity::class.java.name)
.build()
return CastOptions.Builder()
.setReceiverApplicationId(context.getString({{YOUR_RECEIVER_APPLICATION_ID}}))
.setCastMediaOptions(mediaOptions)
.build()
}
override fun getAdditionalSessionProviders(context: Context): List<SessionProvider>? {
return null
}
}
That's it! With the code above you should be able now to:
- provide your own Composable that would act as the SDK's built-in
MediaRouteButton
, or you can programmatically fake a click on it - show the device selector on the UI, and connect to a device
- load media on the connected device
- control the playback from the expanded controls
- end the session manually, or it will be ended automatically when the device is disconnected