Cast-enable an Android TV app

1. Overview

Google Cast logo

This codelab will teach you how to modify an existing Android TV app to support casting and communication from your existing Cast sender apps.

What is Google Cast and Cast Connect?

Google Cast allows users to cast content from a mobile device to a TV. A typical Google Cast session consists of two components — a sender and a receiver application. Sender applications, like a mobile app or website such as Youtube.com, initiate and control the playback of a Cast receiver application. Cast receiver applications are HTML 5 apps that run on Chromecast and Android TV devices.

Almost all of the state in a Cast session is stored on the receiver application. When the state updates, for example if a new media item is loaded, a media status is broadcasted to all senders. These broadcasts contain the current state of the Cast session. Sender applications use this media status to display playback information in their UI.

Cast Connect builds on top of this infrastructure, with your Android TV app acting as a receiver. The Cast Connect library allows your Android TV app to receive messages and broadcast media status as if it were a cast receiver application.

What are we going to be building?

When you have completed this codelab, you will be able to use Cast sender apps to cast videos to an Android TV app. The Android TV app can also communicate with sender apps via the Cast protocol.

What you'll learn

  • How to add the Cast Connect library to a sample ATV app.
  • How to connect a Cast sender and launch the ATV app.
  • How to initiate media playback on the ATV app from a Cast sender app.
  • How to send media status from the ATV app to Cast sender apps.

What you'll need

2. Get the sample code

You can download all the sample code to your computer...

and unpack the downloaded zip file.

3. Run the sample app

First, let's see what the completed sample app looks like. The Android TV app uses the Leanback UI and a basic video player. The user can select a video from a list which then plays on the TV when selected. With the accompanying mobile sender app, a user can also cast a video to the Android TV app.

Image of a series of video thumbnails (one of which is highlighted) overlaying a full-screen preview of a video; the words 'Cast Connect' appearing in the upper right

Register developer devices

In order to enable Cast Connect capabilities for application development you must register the serial number of the Android TV device's built-in Chromecast that you are going to use in the Cast Developer Console. You can find the serial number by going to Settings > Device Preferences > Chromecast built-in > Serial number on your Android TV. Note that this is different from your physical device's serial number and must be obtained from the method described above.

Image of an Android TV screen showing the 'Chromecast built-in' screen, the Version number and Serial number

Without registration, Cast Connect will only work for apps installed from the Google Play Store due to security reasons. After 15 minutes of starting the registration process, restart your device.

Install the Android sender app

To test sending requests from a mobile device we have provided a simple sender application called Cast Videos as mobile-sender-0629.apk file in the source code zip download. We will be leveraging ADB to install the APK. If you have already installed a different version of Cast Videos, please uninstall that version from all profiles located on the device prior to continuing.

  1. Enable developer options and USB debugging on your Android phone.
  2. Plug in a USB data cable to connect your Android phone with your development computer.
  3. Install mobile-sender-0629.apk to your Android phone.

Image of a terminal window running the adb install command to install mobile-sender.apk

  1. You can find the Cast Videos sender app on your Android phone. Cast Videos sender app icon

Image of the Cast Videos sender app running on an Android phone screen

Install the Android TV app

The following instructions describe how to open and run the completed sample app in Android Studio:

  1. Select the Import Project on the welcome screen or the File > New > Import Project... menu options.
  2. Select the folder iconapp-done directory from the sample code folder and click OK.
  3. Click File > Android App Studio's Sync Project with Gradle button Sync Project with Gradle Files.
  4. Enable developer options and USB debugging on your Android TV device.
  5. ADB connect with your Android TV device, the device should show in Android Studio. Image showing the Android TV device appearing on the Android Studio toolbar
  6. Click the Android Studio Run button, a green triangle pointing to the rightRun button, you should see the ATV app named Cast Connect Codelab appear after a few seconds.

Let's play Cast Connect with ATV app

  1. Go to Android TV Home Screen.
  2. Open Cast Videos sender app from your Android phone. Click on the Cast button Cast button icon and select your ATV device.
  3. The Cast Connect Codelab ATV app will be launched on your ATV and the Cast button in your sender will indicate that it is connected Cast button icon with inverted colors.
  4. Select a video from the ATV app and the video will start playing on your ATV.
  5. On your mobile phone, a mini controller is now visible at the bottom of your sender app. You can use the play/pause button to control the playback.
  6. Select a video from the mobile phone and play. The video will start playing on your ATV and the expanded controller will be displayed on your mobile sender.
  7. Lock your phone and when you unlock it, you should see a notification on the lock screen to control the media playback or stop casting.

Image of a section of an Android phone screen with a miniplayer playing a video

4. Prepare the start project

Now that we have verified the completed app's Cast Connect integration we need to add support for Cast Connect to the start app you downloaded. Now you're ready to build on top of the starter project using Android Studio:

  1. Select the Import Project on the welcome screen or the File > New > Import Project... menu options.
  2. Select the folder iconapp-start directory from the sample code folder and click OK.
  3. Click File > Android Studio's Sync Project with Gradle button Sync Project with Gradle Files.
  4. Select ATV device and click the Android Studio's Run button, a green triangle pointing to the rightRun button to run the app and explore the UI. Android Studio toolbar showing the selected Android TV device

Image of a series of video thumbnails (one of which is highlighted) overlaying a full-screen preview of a video; the words 'Cast Connect' appearing in the upper right

App design

The app provides a list of videos for the user to browse. Users can select a video to play on the Android TV. The app consists of two main activities: MainActivity and PlaybackActivity.

MainActivity

This activity contains a Fragment (MainFragment). The list of videos and their associated metadata are configured in MovieList class and setupMovies() method is called to build a list of Movie objects.

A Movie object represents a video entity with title, description, image thumbs and video url. Each Movie object is bound to a CardPresenter to present the video thumbnail with title and studio and passed to the ArrayObjectAdapter.

When an item is selected, the corresponding Movie object is passed to the PlaybackActivity.

PlaybackActivity

This activity contains a Fragment (PlaybackVideoFragment) which hosts a VideoView with ExoPlayer, some media controls, and a text area to show the description of the selected video and allows the user to play the video on the Android TV. The user can use remote control to play/pause or seek the playback of videos.

Prerequisites of Cast Connect

Cast Connect uses new versions of Google Play Services that require your ATV app to have been updated to use the AndroidX namespace.

In order to support Cast Connect in your Android TV app, you must create and support events from a media session. Cast Connect library generates media status based on the status of the media session. Your media session is also used by the Cast Connect library to signal when it has received certain messages from a sender, like pause.

5. Configuring Cast Support

Dependencies

Update the app build.gradle file to include the necessary library dependencies:

dependencies {
    ....

    // Cast Connect libraries
    implementation 'com.google.android.gms:play-services-cast-tv:20.0.0'
    implementation 'com.google.android.gms:play-services-cast:21.1.0'
}

Sync the project to confirm the project builds without errors.

Initialization

CastReceiverContext is a singleton object to coordinate all the Cast interactions. You must implement the ReceiverOptionsProvider interface to provide the CastReceiverOptions when CastReceiverContext is initialized.

Create CastReceiverOptionsProvider.kt file and add the following class to the project:

package com.google.sample.cast.castconnect

import android.content.Context
import com.google.android.gms.cast.tv.ReceiverOptionsProvider
import com.google.android.gms.cast.tv.CastReceiverOptions

class CastReceiverOptionsProvider : ReceiverOptionsProvider {
    override fun getOptions(context: Context): CastReceiverOptions {
        return CastReceiverOptions.Builder(context)
                .setStatusText("Cast Connect Codelab")
                .build()
    }
}

Then specify the receiver options provider within the <application> tag of the app AndroidManifest.xml file:

<application>
  ...
  <meta-data
    android:name="com.google.android.gms.cast.tv.RECEIVER_OPTIONS_PROVIDER_CLASS_NAME"
    android:value="com.google.sample.cast.castconnect.CastReceiverOptionsProvider" />
</application>

To connect with your ATV app from your Cast sender, select an activity you want to launch. In this codelab, we will launch the MainActivity of the app when a Cast session is started. In the AndroidManifest.xml file, add the launch intent filter in the MainActivity.

<activity android:name=".MainActivity">
  ...
  <intent-filter>
    <action android:name="com.google.android.gms.cast.tv.action.LAUNCH" />
    <category android:name="android.intent.category.DEFAULT" />
  </intent-filter>
</activity>

Cast Receiver Context Lifecycle

You should start the CastReceiverContext when your app is launched and stop the CastReceiverContext when your app is moved to the background. We recommend that you use the LifecycleObserver from the androidx.lifecycle library to manage calling CastReceiverContext.start() and CastReceiverContext.stop()

Open MyApplication.kt file, initialize the cast context by calling initInstance() in the onCreate method of the application. In the AppLifeCycleObserver class start() the CastReceiverContext when the application is resumed and stop() it when the application is paused:

package com.google.sample.cast.castconnect

import com.google.android.gms.cast.tv.CastReceiverContext
...

class MyApplication : Application() {
    override fun onCreate() {
        super.onCreate()
        CastReceiverContext.initInstance(this)
        ProcessLifecycleOwner.get().lifecycle.addObserver(AppLifecycleObserver())
    }

    class AppLifecycleObserver : DefaultLifecycleObserver {
        override fun onResume(owner: LifecycleOwner) {
            Log.d(LOG_TAG, "onResume")
            CastReceiverContext.getInstance().start()
        }

        override fun onPause(owner: LifecycleOwner) {
            Log.d(LOG_TAG, "onPause")
            CastReceiverContext.getInstance().stop()
        }
    }
}

Connecting MediaSession to MediaManager

MediaManager is a property of the CastReceiverContext singleton, it manages the media status, handles the load intent, translates the media namespace messages from senders into media commands, and sends media status back to the senders.

When you create a MediaSession, you also need to provide the current MediaSession token to MediaManager so it knows where to send the commands and retrieve the media playback state. In PlaybackVideoFragment.kt file, make sure the MediaSession is initialized before setting the token to MediaManager.

import com.google.android.gms.cast.tv.CastReceiverContext
import com.google.android.gms.cast.tv.media.MediaManager
...

class PlaybackVideoFragment : VideoSupportFragment() {
    private var castReceiverContext: CastReceiverContext? = null
    ...

    private fun initializePlayer() {
        if (mPlayer == null) {
            ...
            mMediaSession = MediaSessionCompat(getContext(), LOG_TAG)
            ...
            castReceiverContext = CastReceiverContext.getInstance()
            if (castReceiverContext != null) {
                val mediaManager: MediaManager = castReceiverContext!!.getMediaManager()
                mediaManager.setSessionCompatToken(mMediaSession!!.getSessionToken())
            }

        }
    }
}

When you release your MediaSession due to inactive playback, you should set a null token on MediaManager:

private fun releasePlayer() {
    mMediaSession?.release()
    castReceiverContext?.mediaManager?.setSessionCompatToken(null)
    ...
}

Let's run the sample app

Click the Android Studio's Run button, a green triangle pointing to the rightRun button to deploy the app on your ATV device, close the app and return to ATV Home Screen. From your sender, click on the Cast button Cast button icon and select your ATV device. You will see the ATV app is launched on the ATV device and Cast button state is connected.

6. Loading Media

The load command is sent via an intent with the package name you defined in the developer console. You need to add the following predefined intent filter in your Android TV app to specify the target activity that will receive this intent. In AndroidManifest.xml file, add the load intent filter to PlayerActivity:

<activity android:name="com.google.sample.cast.castconnect.PlaybackActivity"
          android:launchMode="singleTask"
          android:exported="true">
  <intent-filter>
     <action android:name="com.google.android.gms.cast.tv.action.LOAD"/>
     <category android:name="android.intent.category.DEFAULT" />
  </intent-filter>
</activity>

Handling Load Requests on Android TV

Now that the activity is configured to receive this intent containing a load request we will need to handle it.

The app calls a private method called processIntent when the activity starts. This method contains the logic for processing incoming intents. To handle a load request we will modify this method and send the intent to be further processed by calling the MediaManager instance's onNewIntent method. If MediaManager detects the intent is a load request, it extracts the MediaLoadRequestData object from the intent and invokes MediaLoadCommandCallback.onLoad(). Modify the processIntent method in PlaybackVideoFragment.kt file to handle the intent containing the load request:

fun processIntent(intent: Intent?) {
    val mediaManager: MediaManager = CastReceiverContext.getInstance().getMediaManager()
    // Pass intent to Cast SDK
    if (mediaManager.onNewIntent(intent)) {
        return
    }

    // Clears all overrides in the modifier.
    mediaManager.getMediaStatusModifier().clear()

    // If the SDK doesn't recognize the intent, handle the intent with your own logic.
    ...
}

Next we will extend the abstract class MediaLoadCommandCallback which will override the onLoad() method called by MediaManager. This method receives the load request's data and converts it to a Movie object. Once converted, the movie is played by the local player. The MediaManager is then updated with the MediaLoadRequest and broadcasts the MediaStatus to the connected senders. Create a nested private class called MyMediaLoadCommandCallback in the PlaybackVideoFragment.kt file:

import com.google.android.gms.cast.MediaLoadRequestData
import com.google.android.gms.cast.MediaInfo
import com.google.android.gms.cast.MediaMetadata
import com.google.android.gms.cast.MediaError
import com.google.android.gms.cast.tv.media.MediaException
import com.google.android.gms.cast.tv.media.MediaCommandCallback
import com.google.android.gms.cast.tv.media.QueueUpdateRequestData
import com.google.android.gms.cast.tv.media.MediaLoadCommandCallback
import com.google.android.gms.tasks.Task
import com.google.android.gms.tasks.Tasks
import android.widget.Toast
...

private inner class MyMediaLoadCommandCallback :  MediaLoadCommandCallback() {
    override fun onLoad(
        senderId: String?, mediaLoadRequestData: MediaLoadRequestData): Task<MediaLoadRequestData> {
        Toast.makeText(activity, "onLoad()", Toast.LENGTH_SHORT).show()
        return if (mediaLoadRequestData == null) {
            // Throw MediaException to indicate load failure.
            Tasks.forException(MediaException(
                MediaError.Builder()
                    .setDetailedErrorCode(MediaError.DetailedErrorCode.LOAD_FAILED)
                    .setReason(MediaError.ERROR_REASON_INVALID_REQUEST)
                    .build()))
        } else Tasks.call {
            play(convertLoadRequestToMovie(mediaLoadRequestData)!!)
            // Update media metadata and state
            val mediaManager = castReceiverContext!!.mediaManager
            mediaManager.setDataFromLoad(mediaLoadRequestData)
            mediaLoadRequestData
        }
    }
}

private fun convertLoadRequestToMovie(mediaLoadRequestData: MediaLoadRequestData?): Movie? {
    if (mediaLoadRequestData == null) {
        return null
    }
    val mediaInfo: MediaInfo = mediaLoadRequestData.getMediaInfo() ?: return null
    var videoUrl: String = mediaInfo.getContentId()
    if (mediaInfo.getContentUrl() != null) {
        videoUrl = mediaInfo.getContentUrl()
    }
    val metadata: MediaMetadata = mediaInfo.getMetadata()
    val movie = Movie()
    movie.videoUrl = videoUrl
    movie.title = metadata?.getString(MediaMetadata.KEY_TITLE)
    movie.description = metadata?.getString(MediaMetadata.KEY_SUBTITLE)
    if(metadata?.hasImages() == true) {
        movie.cardImageUrl = metadata.images[0].url.toString()
    }
    return movie
}

Now that the Callback has been defined, we need to register it to the MediaManager. The callback must be registered before MediaManager.onNewIntent() is called. Add setMediaLoadCommandCallback when the player is initialized:

private fun initializePlayer() {
    if (mPlayer == null) {
        ...
        mMediaSession = MediaSessionCompat(getContext(), LOG_TAG)
        ...
        castReceiverContext = CastReceiverContext.getInstance()
        if (castReceiverContext != null) {
            val mediaManager: MediaManager = castReceiverContext.getMediaManager()
            mediaManager.setSessionCompatToken(mMediaSession.getSessionToken())
            mediaManager.setMediaLoadCommandCallback(MyMediaLoadCommandCallback())
        }
    }
}

Let's run the sample app

Click the Android Studio's Run button, a green triangle pointing to the rightRun button to deploy the app on your ATV device. From your sender, click on the Cast button Cast button icon and select your ATV device. The ATV app will be launched on the ATV device. Select a video on mobile, the video will start playing on the ATV. Check whether you receive a notification on your phone where you have playback controls. Try using the controls such as pause, video on the ATV device should be paused.

7. Supporting Cast Control Commands

The current application now supports basic commands that are compatible with a media session, such as play, pause, and seek. However, there are some Cast control commands that are not available in media session. You need to register a MediaCommandCallback to support those Cast control commands.

Add MyMediaCommandCallback to the MediaManager instance using setMediaCommandCallback when the player is initialized:

private fun initializePlayer() {
    ...
    castReceiverContext = CastReceiverContext.getInstance()
    if (castReceiverContext != null) {
        val mediaManager = castReceiverContext!!.mediaManager
        ...
        mediaManager.setMediaCommandCallback(MyMediaCommandCallback())
    }
}

Create MyMediaCommandCallback class to override the methods, such as onQueueUpdate() to support those Cast control commands:

private inner class MyMediaCommandCallback : MediaCommandCallback() {
    override fun onQueueUpdate(
        senderId: String?,
        queueUpdateRequestData: QueueUpdateRequestData
    ): Task<Void> {
        Toast.makeText(getActivity(), "onQueueUpdate()", Toast.LENGTH_SHORT).show()
        // Queue Prev / Next
        if (queueUpdateRequestData.getJump() != null) {
            Toast.makeText(
                getActivity(),
                "onQueueUpdate(): Jump = " + queueUpdateRequestData.getJump(),
                Toast.LENGTH_SHORT
            ).show()
        }
        return super.onQueueUpdate(senderId, queueUpdateRequestData)
    }
}

8. Working with Media Status

Modifying Media Status

Cast Connect gets the base media status from the media session. To support advanced features, your Android TV app can specify and override additional status properties via a MediaStatusModifier. MediaStatusModifier will always operate on the MediaSession which you have set in CastReceiverContext.

For example, to specify setMediaCommandSupported when onLoad callback is triggered:

import com.google.android.gms.cast.MediaStatus
...
private class MyMediaLoadCommandCallback : MediaLoadCommandCallback() {
    fun onLoad(
        senderId: String?,
        mediaLoadRequestData: MediaLoadRequestData
    ): Task<MediaLoadRequestData> {
        Toast.makeText(getActivity(), "onLoad()", Toast.LENGTH_SHORT).show()
        ...
        return Tasks.call({
            play(convertLoadRequestToMovie(mediaLoadRequestData)!!)
            ...
            // Use MediaStatusModifier to provide additional information for Cast senders.
            mediaManager.getMediaStatusModifier()
                .setMediaCommandSupported(MediaStatus.COMMAND_QUEUE_NEXT, true)
                .setIsPlayingAd(false)
            mediaManager.broadcastMediaStatus()
            // Return the resolved MediaLoadRequestData to indicate load success.
            mediaLoadRequestData
        })
    }
}

Intercepting MediaStatus Before Sending Out

Similar to the Web receiver SDK's MessageInterceptor, you can specify a MediaStatusWriter in your MediaManager to perform additional modifications to your MediaStatus before it is broadcast to the connected senders.

For example, you can set custom data in the MediaStatus before sending out to mobile senders:

import com.google.android.gms.cast.tv.media.MediaManager.MediaStatusInterceptor
import com.google.android.gms.cast.tv.media.MediaStatusWriter
import org.json.JSONObject
import org.json.JSONException
...

private fun initializePlayer() {
    if (mPlayer == null) {
        ...
        if (castReceiverContext != null) {
            ...
            val mediaManager: MediaManager = castReceiverContext.getMediaManager()
            ...
            // Use MediaStatusInterceptor to process the MediaStatus before sending out.
            mediaManager.setMediaStatusInterceptor(
                MediaStatusInterceptor { mediaStatusWriter: MediaStatusWriter ->
                    try {
                        mediaStatusWriter.setCustomData(JSONObject("{myData: 'CustomData'}"))
                    } catch (e: JSONException) {
                        Log.e(LOG_TAG,e.message,e);
                    }
            })
        }
    }
}        

9. Congratulations

You now know how to Cast-enable an Android TV app using the Cast Connect Library.

Take a look at developer guide for more details: /cast/docs/android_tv_receiver.