In the ever-evolving landscape of digital entertainment, the ability to seamlessly cast content from your device to the big screen has become a ubiquitous and transformative experience. Chromecast, a revolutionary streaming device, has paved the way for immersive, cross-platform entertainment. Now, imagine harnessing the power of Jetpack Compose, Google’s modern UI toolkit, to elevate your Chromecast TV applications to new heights.
As we celebrate the first anniversary of this groundbreaking combination, this tutorial embarks on a journey to demystify the process of creating Chromecast TV applications with Jetpack Compose. The fusion of Jetpack Compose’s declarative UI approach, Material You’s dynamic theming, and the Chromecast SDK offers a recipe for crafting visually stunning, user-friendly applications that seamlessly integrate with the Chromecast ecosystem.
Throughout this tutorial, we will delve into the intricacies of the Chromecast SDK, exploring how Jetpack Compose can be leveraged to design intuitive user interfaces tailored for TV screens. Additionally, we’ll dive into the TV API and the innovative TV Compose library, exploring the synergy between these components and Material You to create applications that not only captivate but also adapt to users’ preferences.
Whether you’re a seasoned developer or just starting your journey in the world of Android app development, this tutorial will provide a step-by-step guide, enriched with code snippets and practical examples, to empower you in building Chromecast TV applications that seamlessly integrate with the latest Material Design principles. Let’s embark on this exciting journey to redefine the future of Chromecast TV applications with Jetpack Compose and Material You.
In this article, we will begin the journey of designing the application that links with the TV application to broadcast an events between the two devices.
Before We Start What we Need ?
A Chromecast Device (External or Built in) inside The TV
Android TV Device
Chromecast Dashboard Ready with 5$ To open The Account
The Chromecast Device Serial Number
Both Mobile and TV Should be Connected on Same Internet Source
To Get the Serial number of the Chromecast Device You can Find it on the Product Box in case of External Device, Internal Chromecast Can be found on OS Settings in the TV Under Chromecast Section
In This Example We Will build 2 Applications (Sender, Receiver) and The Application Called LinkLoom with one Main Objective Which is Pushing Links Between Mobile and TV and The Reason about this was i want to Push Links Between My Personal Mobile to TV and to Achive this We Can use ChromeCast to Push These Links With one Click only
Now We Need to Decide What is the Package Name of Each Application to Configure Them in the Chromecast Console and I’ll Use (com.yazantarifi.linkloom) So the Package names Will be (com.yazantarifi.linkloom.tv, com.yazantarifi.linkloom.mobile)
After Registering the Application With the Following Configurations You need to Add the Application Name and Publish It Then Wait Until App is Published
Now We Need to Register Our Chromecast Device for Testing
Fill This Dialog with the Device Serial number then Submit it and wait Until being Ready for Testing
We Will Build the TV Application First with the Following Dependencies inside TV Module
implementation("androidx.core:core-ktx:1.12.0")
implementation("androidx.leanback:leanback:1.0.0")
// Compose and Glide to Load Images
implementation("com.github.bumptech.glide:compose:1.0.0-beta01")
implementation(platform("androidx.compose:compose-bom:2023.10.01"))
// Jetoack Compose
implementation("androidx.activity:activity-compose:1.8.0")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.lifecycle:lifecycle-process:2.6.2")
debugImplementation("androidx.compose.ui:ui-tooling")
// Compose TV Dependencies
implementation("androidx.tv:tv-foundation:1.0.0-alpha10")
implementation("androidx.tv:tv-material:1.0.0-alpha10")
// Chromecast SDK
implementation("com.google.android.gms:play-services-cast-tv:21.0.0")
implementation("com.google.android.gms:play-services-cast:21.3.0")
For Creating the TV Home Screen Design We Will depend on the Following Composables
- NavigationDrawer
- NavigationDrawerItem
- TvLazyColumn
- GlideImage
You can Follow the Android Documentation about how to Use Compose on TV Devices and Glide for Compose Apps but Our Focus now is on Chromecast SDK With Compose Apps
Now The Home Screen Design Implemented Like The Following Example
class HomeScreen: ComponentActivity() {
private val viewModel: ApplicationViewModel by viewModels()
@OptIn(ExperimentalTvMaterial3Api::class, ExperimentalGlideComposeApi::class)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
LinkLoomTheme {
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
var selectedNavigationIndex = remember { mutableIntStateOf(0) }
NavigationDrawer(
drawerState = drawerState,
modifier = Modifier
.fillMaxHeight()
.background(Color.Black)
.padding(10.dp),
drawerContent = {
Column(
modifier = Modifier
.fillMaxHeight()
.padding(10.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
NavigationItemComposable(drawerState, 0, NavigationItem(R.drawable.home, getString(R.string.home)), selectedNavigationIndex)
NavigationItemComposable(drawerState, 1, NavigationItem(R.drawable.history, getString(R.string.history)), selectedNavigationIndex)
NavigationItemComposable(drawerState, 2, NavigationItem(R.drawable.settings, getString(R.string.settings)), selectedNavigationIndex)
}
}
) {
ScreenContent(selectedNavigationIndex.intValue)
}
}
}
}
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
fun ScreenContent(position: Int) {
Column(modifier = Modifier
.fillMaxSize()
.padding(10.dp)
) {
when (position) {
0 -> HomeTabComposable()
1 -> HistoryTabComposable(viewModel)
2 -> SettingsComposable()
}
}
}
@OptIn(ExperimentalTvMaterial3Api::class, ExperimentalGlideComposeApi::class)
@Composable
fun NavigationDrawerScope.NavigationItemComposable(state: DrawerState, index: Int, item: NavigationItem, selectedNavigationIndex: MutableIntState) {
val isSelected = selectedNavigationIndex.intValue == index
val scope = rememberCoroutineScope()
NavigationDrawerItem(
selected = isSelected,
onClick = {
selectedNavigationIndex.intValue = index
scope.launch {
state.setValue(DrawerValue.Closed)
}
},
leadingContent = {
GlideImage(model = item.icon, contentDescription = item.text, colorFilter = ColorFilter.tint(Color.White))
},
modifier = Modifier.padding(10.dp),
colors = NavigationDrawerItemColors(
focusedContainerColor = RedPrimary,
focusedContentColor = Color.White,
focusedSelectedContainerColor = RedPrimaryDark,
focusedSelectedContentColor = Color.White,
selectedContainerColor = RedPrimaryDark, // done
selectedContentColor = Color.White,
containerColor = Color.Black,
contentColor = Color.White,
inactiveContentColor = Color.White,
pressedContainerColor = Color.Black,
pressedContentColor = Color.White,
disabledContainerColor = Color.Black,
disabledContentColor = Color.Black,
disabledInactiveContentColor = Color.Black,
pressedSelectedContainerColor = Color.Black,
pressedSelectedContentColor = Color.White
)
) {
Text(text = item.text, color = Color.White, softWrap = false)
}
}
}
After Finishing the Tv Application In Home Screen Design Now we Need to Configure it to be able to Receive Events
We Will build a Class to Configure the Settings for Chromecast Options Provider like the Following Example
import android.content.Context
import com.google.android.gms.cast.LaunchOptions
import com.google.android.gms.cast.tv.CastReceiverOptions
import com.google.android.gms.cast.tv.ReceiverOptionsProvider
class MyReceiverOptionsProvider : ReceiverOptionsProvider {
override fun getOptions(context: Context): CastReceiverOptions {
return CastReceiverOptions.Builder(context)
.setStatusText("LinkLoom Cast Connect")
.setCustomNamespaces(listOf("urn:x-cast:open-website"))
.build()
}
}
Our App Depends on the Custom Namespaces so we need to register them in the Configurations Class and each Namespace Should have (urn:x-cast:) before the name then feel free to add any name you want
Now we Need to Register The Configurations in the Manifest File Like the Following Example
<activity
android:name=".MainScreen"
android:banner="@drawable/banner_icon"
android:exported="true"
android:icon="@drawable/banner_icon"
android:label="@string/app_name"
android:logo="@drawable/banner_icon"
android:screenOrientation="landscape">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="com.google.android.gms.cast.tv.action.LAUNCH" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
<meta-data
android:name="com.google.android.gms.cast.tv.RECEIVER_OPTIONS_PROVIDER_CLASS_NAME"
android:value="{AppPackageName}.MyReceiverOptionsProvider" />
Replace {AppPackageName} with Your Application Package Name for Our Example the Package Name is com.yazantarifi.linkloom.tv so the Result will be com.yazantarifi.linkloom.tv.MyReceiverOptionsProvider
Now we Need to Init The Chromecast SDK in Our Application Class like the Following Example
CastReceiverContext.initInstance(this)
ProcessLifecycleOwner.get().lifecycle.addObserver(AppLifecycleObserver())
CastReceiverContext.getInstance().registerEventCallback(EventCallback())
class AppLifecycleObserver : DefaultLifecycleObserver {
override fun onResume(owner: LifecycleOwner) {
CastReceiverContext.getInstance().start()
}
override fun onPause(owner: LifecycleOwner) {
CastReceiverContext.getInstance().stop()
}
}
private inner class EventCallback : CastReceiverContext.EventCallback() {
override fun onSenderConnected(senderInfo: SenderInfo) {
Toast.makeText(
this@LinkLoomApplication,
"Sender connected " + senderInfo.senderId,
Toast.LENGTH_LONG)
.show()
}
override fun onSenderDisconnected(eventInfo: SenderDisconnectedEventInfo) {
Toast.makeText(
this@LinkLoomApplication,
"Sender disconnected " + eventInfo.senderInfo.senderId,
Toast.LENGTH_LONG)
.show()
}
}
Now on Our Activity Registered in the Manifest We can Receive the Connection Events between Mobile and TV inside onNewIntent Function Like the Following Example
override fun onNewIntent(intent: Intent?) {
super.onNewIntent(intent)
processIntent(intent)
}
fun processIntent(intent: Intent?) {
Toast.makeText(this, "New Event 1", Toast.LENGTH_SHORT).show()
val mediaManager: MediaManager = CastReceiverContext.getInstance().mediaManager
// Pass intent to Cast SDK
if (mediaManager.onNewIntent(intent)) {
return
}
// Clears all overrides in the modifier.
mediaManager.mediaStatusModifier.clear()
Toast.makeText(this, "New Event", Toast.LENGTH_SHORT).show()
}
After Establishing the Connection Now we Need to Register the Custom Event that We need to Open the Urls Coming from Mobile App
CastReceiverContext.getInstance().setMessageReceivedListener("urn:x-cast:open-website", object : MessageReceivedListener {
override fun onMessageReceived(p0: String, p1: String?, p2: String) {
Toast.makeText(this@HomeScreen, "Opening New Website : ${p2}", Toast.LENGTH_SHORT).show()
if (!TextUtils.isEmpty(p2)) {
WebsiteScreen.startScreenDirectly(this@HomeScreen, p2, "Custom Website")
}
}
})
And the Name (urn:x-cast:open-website) Should be The same Name Registered in the Provider Settings Class that We Registered in Our Manifest, now in this Callback We Can Trigger the Event and The TV Example Done Now We Will Start Building The Mobile Application
Gradle Dependencies That We Need for Mobile Device
implementation(platform("com.google.firebase:firebase-bom:31.4.0"))
implementation(platform("androidx.compose:compose-bom:2023.10.01"))
implementation("androidx.appcompat:appcompat:1.6.1")
implementation("androidx.mediarouter:mediarouter:1.6.0")
implementation("com.google.android.gms:play-services-cast-framework:21.3.0")
implementation("androidx.core:core-ktx:1.12.0")
implementation("androidx.activity:activity-compose:1.8.0")
implementation("com.github.bumptech.glide:compose:1.0.0-beta01")
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.foundation:foundation")
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-tooling-preview")
debugImplementation("androidx.compose.ui:ui-tooling")
Inside Application Class we Need to Init the SDK for Chromecast and Be aware that This Function Can Throw Exceptions on Some Devices if they Cant Use Chromecast SDK Like Emulators So We Need to Test Always on Real Devices
CastContext
.getSharedInstance(this, Executors.newSingleThreadExecutor())
.addOnSuccessListener { castContext: CastContext? ->
println("Chromecast Successful")
castInstance = castContext
}
.addOnFailureListener { exception: java.lang.Exception? ->
println("Chromecast Exception : ${exception?.message}")
exception?.let { FirebaseCrashlytics.getInstance().recordException(it) }
}
Now We Will Configure the Mobile App Class for Casting
import android.content.Context
import com.google.android.gms.cast.LaunchOptions
import com.google.android.gms.cast.framework.CastOptions
import com.google.android.gms.cast.framework.OptionsProvider
import com.google.android.gms.cast.framework.SessionProvider
class CastOptionsProvider : OptionsProvider {
override fun getCastOptions(context: Context): CastOptions {
val options = LaunchOptions.Builder()
.setAndroidReceiverCompatible(true)
.build()
return CastOptions.Builder()
.setReceiverApplicationId("07EB8A95")
.setLaunchOptions(options)
.build()
}
override fun getAdditionalSessionProviders(context: Context): List<SessionProvider>? {
return null
}
}
07EB8A95 Is the ID you get when you publish your Application in the First Step from the Console, make sure to replace it with your id
Now We Register the Configuration Class in Manifest
<meta-data
android:name="com.google.android.gms.cast.framework.OPTIONS_PROVIDER_CLASS_NAME"
android:value="com.yazantarifi.mobile.CastOptionsProvider" />
Now When we Open the Screen We Need to Search on All Devices Available on the Same Network
mediaSelector = MediaRouteSelector.Builder()
.addControlCategory(MediaControlIntent.CATEGORY_REMOTE_PLAYBACK)
.addControlCategory(CastMediaControlIntent.categoryForCast("07EB8A95"))
.build()
mediaRouter = MediaRouter.getInstance(applicationContext)
val routes = mediaRouter.routes;
routes.forEach {
val device = CastDevice.getFromBundle(it.extras)
if (!TextUtils.isEmpty(device?.deviceId ?: "")) {
chromecastDevicesState.add(LinkLoomChromeCastDevice(device?.deviceId ?: "", device?.friendlyName ?: "", device?.deviceVersion ?: "", it))
}
}
Now We Need to Establish a Connection between Mobile App and Tv and this can be done by using the select Route Function
mediaSelector?.let { it1 ->
mediaRouter.addCallback(
it1, mediaRouterCallback,
MediaRouter.CALLBACK_FLAG_PERFORM_ACTIVE_SCAN)
}
Toast.makeText(this@HomeScreen, "Device Connection Started", Toast.LENGTH_SHORT).show()
mediaRouter.selectRoute(it.routeDevice)
After Connection Success Now We have a Session between both of them we need to store the Session in a Variable to Use it to push Events, Now We Have all of the Available Devices, We Need to Push the Event for the Device
castSession?.sendMessage("urn:x-cast:open-website", websiteLink)
(urn:x-cast:open-website) This Name is the Name we registered to listen to events and get the payload, replace it with your Name
Now We Will see a Full Example of a Functinal Activity that Interact with Chromecast and Send Messages
class HomeScreen: ComponentActivity() {
private var currentUrl = ""
private var connectedDevice: String = ""
private var castSession: CastSession? = null
private lateinit var mediaRouter: MediaRouter
private var mediaSelector: MediaRouteSelector? = null
private var connectedDeviceState by mutableStateOf("")
private val chromecastDevicesState by lazy { mutableStateListOf<LinkLoomChromeCastDevice>() }
@OptIn(ExperimentalMaterial3Api::class, ExperimentalGlideComposeApi::class)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
Column(
modifier = Modifier
.fillMaxSize()
.background(Color.Black)
.padding(30.dp),
verticalArrangement = Arrangement.SpaceBetween,
horizontalAlignment = Alignment.Start
) {
Column(modifier = Modifier.fillMaxWidth()) {
Text(text = "Link Loom Mobile Application", color = RedPrimary)
Spacer(modifier = Modifier.height(10.dp))
Text(text = "Share Websites Links Directly to TV", color = Color.White)
}
Column(modifier = Modifier.fillMaxWidth()) {
var websiteLink by remember { mutableStateOf("") }
OutlinedTextField(modifier = Modifier.fillMaxWidth(), value = websiteLink, onValueChange = {
websiteLink = it
}, placeholder = {
Text(text = "Example (www.example.com)")
}, label = {
Text(text = "Website Url", color = RedPrimary)
}, colors = TextFieldDefaults.textFieldColors(focusedTextColor = Color.Black))
Spacer(modifier = Modifier.height(20.dp))
Button(
modifier = Modifier,
onClick = {
if (websiteLink.isNotEmpty()) {
currentUrl = websiteLink
castSession?.sendMessage("urn:x-cast:open-website", websiteLink)
}
}
) {
Text(text = getString(R.string.add_new), color = Color.White)
}
Spacer(modifier = Modifier.height(20.dp))
Text(text = "Select a Device To Connect", color = Color.White)
Spacer(modifier = Modifier.height(5.dp))
LazyColumn(modifier = Modifier.fillMaxWidth()) {
items(chromecastDevicesState) {
Column( modifier = Modifier
.fillMaxWidth()
.clickable {
if (connectedDeviceState.isEmpty()) {
mediaSelector?.let { it1 ->
mediaRouter.addCallback(
it1, mediaRouterCallback,
MediaRouter.CALLBACK_FLAG_PERFORM_ACTIVE_SCAN)
}
Toast.makeText(this@HomeScreen, "Device Connection Started", Toast.LENGTH_SHORT).show()
mediaRouter.selectRoute(it.routeDevice)
}
}) {
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Start) {
GlideImage(model = R.drawable.chromecast, contentDescription = "Device Icon", modifier = Modifier.size(30.dp))
Spacer(modifier = Modifier.width(10.dp))
Column {
Text(text = it.name, color = Color.White, modifier = Modifier
.fillMaxWidth()
.padding(5.dp))
Text(text = it.id, color = Color.White, modifier = Modifier
.fillMaxWidth())
}
}
Spacer(modifier = Modifier.height(10.dp))
Divider()
}
}
}
}
var result by remember { mutableStateOf(connectedDeviceState) }
LaunchedEffect(connectedDeviceState) {
result = connectedDeviceState
}
Column(modifier = Modifier.fillMaxWidth()) {
if (result.isNotEmpty()) {
Text(text = "Chromecast Connected", color = Color.Green)
}
Spacer(modifier = Modifier.height(5.dp))
Text(text = "Link Loom Work only with Link Loom TV Application", color = Color.White)
Spacer(modifier = Modifier.height(10.dp))
Text(text = "Make Sure All Devices Connected to Same Network", color = Color.Gray)
}
}
}
mediaSelector = MediaRouteSelector.Builder()
.addControlCategory(MediaControlIntent.CATEGORY_REMOTE_PLAYBACK)
.addControlCategory(CastMediaControlIntent.categoryForCast("07EB8A95"))
.build()
mediaRouter = MediaRouter.getInstance(applicationContext)
val routes = mediaRouter.routes;
routes.forEach {
val device = CastDevice.getFromBundle(it.extras)
if (!TextUtils.isEmpty(device?.deviceId ?: "")) {
chromecastDevicesState.add(LinkLoomChromeCastDevice(device?.deviceId ?: "", device?.friendlyName ?: "", device?.deviceVersion ?: "", it))
}
}
(applicationContext as LinkLoomApplication).castInstance?.sessionManager?.addSessionManagerListener(sessionManagerListener, CastSession::class.java)
}
override fun onStart() {
super.onStart()
mediaSelector?.also { selector ->
mediaRouter.addCallback(selector, mediaRouterCallback,
MediaRouter.CALLBACK_FLAG_PERFORM_ACTIVE_SCAN)
}
}
private val mediaRouterCallback = object : MediaRouter.Callback() {
override fun onRouteSelected(
router: MediaRouter,
route: MediaRouter.RouteInfo,
reason: Int
) {
super.onRouteSelected(router, route, reason)
println("Charomcast Event : onRouteSelected : $route")
}
}
private val sessionManagerListener = object : SessionManagerListener<CastSession> {
override fun onSessionEnded(p0: CastSession, p1: Int) {
println("Charomcast Event : onSessionEnded")
connectedDevice = ""
connectedDeviceState = ""
}
override fun onSessionEnding(p0: CastSession) {
println("Charomcast Event : onSessionEnding")
connectedDevice = ""
connectedDeviceState = ""
}
override fun onSessionResumeFailed(p0: CastSession, p1: Int) {
println("Charomcast Event : onSessionResumeFailed")
}
override fun onSessionResumed(p0: CastSession, p1: Boolean) {
println("Charomcast Event : onSessionResumed")
}
override fun onSessionResuming(p0: CastSession, p1: String) {
println("Charomcast Event : onSessionResuming")
}
override fun onSessionStartFailed(p0: CastSession, p1: Int) {
println("Charomcast Event : onSessionStartFailed : ${ (applicationContext as LinkLoomApplication).castInstance?.getCastReasonCodeForCastStatusCode(p1)}")
println("Charomcast Event : onSessionStartFailed : ${CastStatusCodes.getStatusCodeString(p1)}")
connectedDevice = ""
connectedDeviceState = ""
}
override fun onSessionStarted(p0: CastSession, p1: String) {
println("Charomcast Event : onSessionStarted")
castSession = p0
connectedDeviceState = p0.sessionId ?: ""
Toast.makeText(this@HomeScreen, "Device Connected", Toast.LENGTH_SHORT).show()
}
override fun onSessionStarting(p0: CastSession) {
println("Charomcast Event : onSessionStarting")
}
override fun onSessionSuspended(p0: CastSession, p1: Int) {
println("Charomcast Event : onSessionSuspended")
}
}
override fun onPause() {
super.onPause()
(applicationContext as LinkLoomApplication).castInstance?.sessionManager?.removeSessionManagerListener(sessionManagerListener, CastSession::class.java)
}
override fun onResume() {
super.onResume()
(applicationContext as LinkLoomApplication).castInstance?.sessionManager?.addSessionManagerListener(sessionManagerListener, CastSession::class.java)
castSession = (applicationContext as LinkLoomApplication).castInstance?.sessionManager?.currentCastSession
}
}
Now we have Everything Done for Mobile and Tv, We Will See how to Run the Project
- Export 2 Apks for Mobile and Tv
- Push the Tv Apk on Tv and Install it
- Install Mobile SDK
- Enable Developer Mode in Tv Settings
- Turn Off The Tv Completely then Turn it on Again After Setup the Serial Number on the Dashboard and the Status is Ready for Testing
Common Connection Mistakes
- Device Not Visible in Routes List: Not Connected on the Same Network
- Device Not Compatible to Accept Events, Mobile App Should Declare setAndroidReceiverCompatible to true in Configurations
Finally We Have Completed the Setup for the Project and We have a Public Version of the Source Code available
Top comments (0)