@akobor

The Curious Incident of Google Cast in Jetpack Compose

Cover Image for The Curious Incident of Google Cast in Jetpack Compose
Adam Kobor
Adam Kobor
| 14 min read

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:

  1. Define our own extended version of MediaRouteButton
  2. 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

Comments