Skip to content

Commit

Permalink
Fix Chromecast setup (#34)
Browse files Browse the repository at this point in the history
* Add Chromecast to demo

* Update player.cast on source change

* Fix Chromecast discovery in demo app

* Keep controls visible while casting

* Add guide on setting up Chromecast
  • Loading branch information
MattiasBuelens authored Aug 30, 2024
1 parent f288f24 commit d8ecb96
Show file tree
Hide file tree
Showing 10 changed files with 202 additions and 15 deletions.
3 changes: 3 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ dependencies {
implementation(libs.androidx.compose.ui.toolingPreview)
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.material.iconsExtended)
implementation(libs.androidx.mediarouter)
implementation(libs.playServices.castFramework)
testImplementation(libs.junit4)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso)
Expand All @@ -79,4 +81,5 @@ dependencies {
implementation(libs.theoplayer)
implementation(libs.theoplayer.ads)
implementation(libs.theoplayer.ads.ima)
implementation(libs.theoplayer.cast)
}
4 changes: 4 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>

<meta-data
android:name="com.google.android.gms.cast.framework.OPTIONS_PROVIDER_CLASS_NAME"
android:value="com.theoplayer.android.ui.demo.CastOptionsProvider" />
</application>

</manifest>
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package com.theoplayer.android.ui.demo

import android.content.Context
import com.google.android.gms.cast.CastMediaControlIntent
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

class CastOptionsProvider : OptionsProvider {
override fun getCastOptions(context: Context): CastOptions {
val notificationOptions = NotificationOptions.Builder()
.setTargetActivityClassName(MainActivity::class.java.name)
.build()

val castMediaOptions = CastMediaOptions.Builder()
.setNotificationOptions(notificationOptions)
.build()

return CastOptions.Builder()
.setReceiverApplicationId(CastMediaControlIntent.DEFAULT_MEDIA_RECEIVER_APPLICATION_ID)
.setCastMediaOptions(castMediaOptions)
.build()
}

override fun getAdditionalSessionProviders(context: Context): List<SessionProvider>? {
return null
}
}
19 changes: 18 additions & 1 deletion app/src/main/java/com/theoplayer/android/ui/demo/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,12 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import com.google.android.gms.cast.framework.CastContext
import com.theoplayer.android.api.THEOplayerConfig
import com.theoplayer.android.api.ads.ima.GoogleImaIntegrationFactory
import com.theoplayer.android.api.cast.CastConfiguration
import com.theoplayer.android.api.cast.CastIntegrationFactory
import com.theoplayer.android.api.cast.CastStrategy
import com.theoplayer.android.ui.DefaultUI
import com.theoplayer.android.ui.demo.nitflex.NitflexUI
import com.theoplayer.android.ui.demo.nitflex.theme.NitflexTheme
Expand All @@ -34,6 +38,9 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

// Initialize Chromecast immediately, for automatic receiver discovery to work correctly.
CastContext.getSharedInstance(this)

setContent {
THEOplayerTheme(useDarkTheme = true) {
MainContent()
Expand All @@ -51,7 +58,17 @@ fun MainContent() {
val player = rememberPlayer()
LaunchedEffect(player) {
player.theoplayerView?.let { theoplayerView ->
theoplayerView.player.addIntegration(GoogleImaIntegrationFactory.createGoogleImaIntegration(theoplayerView))
// Add ads integration through Google IMA
theoplayerView.player.addIntegration(
GoogleImaIntegrationFactory.createGoogleImaIntegration(theoplayerView)
)
// Add Chromecast integration
val castConfiguration = CastConfiguration.Builder().apply {
castStrategy(CastStrategy.AUTO)
}.build()
theoplayerView.player.addIntegration(
CastIntegrationFactory.createCastIntegration(theoplayerView, castConfiguration)
)
}
}
LaunchedEffect(player, stream) {
Expand Down
3 changes: 2 additions & 1 deletion app/src/main/res/values/themes.xml
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>

<style name="Theme.THEOplayerAndroidUI" parent="android:Theme.Material.Light.NoActionBar">
<!-- This MUST be a Theme.AppCompat theme for the Chromecast MediaRouteChooserDialog to work! -->
<style name="Theme.THEOplayerAndroidUI" parent="Theme.AppCompat.Light.NoActionBar">
<item name="android:statusBarColor">@color/purple_700</item>
</style>
</resources>
105 changes: 105 additions & 0 deletions docs/guides/chromecast.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
---
sidebar_position: 2
---

# Setting up Chromecast

The Open Video UI for Android has integrated support for Chromecast.

- When using the `DefaultUI`, the Chromecast button is automatically added in the top right corner
of the player.
- When creating a custom UI using a `UIController`, you can add a `ChromecastButton` component
anywhere you like.
You should also add a `ChromecastDisplay` to show a "Playing on Chromecast" message while casting.

However, you need to perform some additional setup to get it fully working.

## Install the THEOplayer Cast integration

The Chromecast support requires the THEOplayer Cast integration to be included in your app.
We'll also need to interact with the Cast Application Framework directly, so we'll include that too:

```groovy title="build.gradle"
dependencies {
implementation "com.theoplayer.theoplayer-sdk-android:core:7.+"
// highlight-next-line
implementation "com.theoplayer.theoplayer-sdk-android:integration-cast:7.+"
// highlight-next-line
implementation "com.google.android.gms:play-services-cast-framework:21.5.0"
implementation "com.theoplayer.android-ui:android-ui:1.+"
}
```

Create the player manually using `rememberPlayer()`, and then create and add the cast integration:

```kotlin title="MainActivity.kt"
import com.theoplayer.android.api.cast.CastConfiguration
import com.theoplayer.android.api.cast.CastIntegrationFactory
import com.theoplayer.android.api.cast.CastStrategy

setContent {
val player = rememberPlayer()
LaunchedEffect(player) {
player.theoplayerView?.let { theoplayerView ->
// Add Chromecast integration
val castConfiguration = CastConfiguration.Builder().apply {
castStrategy(CastStrategy.AUTO)
}.build()
theoplayerView.player.addIntegration(
CastIntegrationFactory.createCastIntegration(theoplayerView, castConfiguration)
)
}
}

DefaultUI(
player = player
)
}
```

## Initialize the `CastContext` during activity creation

The Cast Application Framework handles automatic discovery of Chromecast receivers.
However, this only works correctly if the `CastContext` is initialized immediately when
your app's activity is constructed.

Therefore, make sure to call `CastContext.getSharedInstance(this)` inside `Activity.onCreate()`:

```kotlin title="MainActivity.kt"
import com.google.android.gms.cast.framework.CastContext

class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

// Initialize Chromecast immediately, for automatic receiver discovery to work correctly.
CastContext.getSharedInstance(this)

setContent {
// ...
}
}
}
```

## Use an `AppCompat` theme

The Cast Application Framework creates dialogs such as
[MediaRouteChooserDialog](https://developer.android.com/reference/androidx/mediarouter/app/MediaRouteChooserDialog)
and [MediaRouteControllerDialog](https://developer.android.com/reference/androidx/mediarouter/app/MediaRouteControllerDialog)
to start and control a cast session. However, because these dialogs inherit
from [AppCompatDialog](https://developer.android.com/reference/androidx/appcompat/app/AppCompatDialog),
you need to use theme based on `Theme.AppCompat` in your app:

```xml title="src/main/res/values/themes.xml"
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- don't do this:
<style name="Theme.MyAppTheme" parent="android:Theme.Material.Light.NoActionBar">
instead, do this:
-->
<style name="Theme.MyAppTheme" parent="Theme.AppCompat.Light.NoActionBar">
<!-- your app's theme colors go here -->
</style>
</resources>
```
4 changes: 4 additions & 0 deletions docs/guides/custom-ui.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
---
sidebar_position: 1
---

# Making a custom UI

Although the default UI was designed to support a variety of usage scenarios, you may still run into a case that it doesn't handle very well. Perhaps you want to move some buttons around, or add like and dislike buttons to the control bar, or perhaps integrate a text chat component inside your player. In these situations, you may want to build a custom player UI to create a truly unique experience for your viewers.
Expand Down
5 changes: 5 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@ activity-compose = "1.9.1"
appcompat = "1.7.0"
compose-bom = "2024.06.00"
junit4 = "4.13.2"
playServices-castFramework = "21.5.0"
ui-test-junit4 = "1.6.8" # ...not in BOM for some reason?
androidx-junit = "1.2.1"
androidx-espresso = "3.6.1"
androidx-mediarouter = "1.7.0"
dokka = "1.9.20"
theoplayer = "7.10.0"

Expand All @@ -29,6 +31,8 @@ androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-toolin
androidx-compose-ui-toolingPreview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidx-junit" }
androidx-espresso = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "androidx-espresso" }
androidx-mediarouter = { group = "androidx.mediarouter", name = "mediarouter", version.ref = "androidx-mediarouter" }
playServices-castFramework = { group = "com.google.android.gms", name = "play-services-cast-framework", version.ref = "playServices-castFramework" }
gradle-plugin = { group = "com.android.tools.build", name = "gradle", version.ref = "gradle" }
dokka-base = { group = "org.jetbrains.dokka", name = "dokka-base", version.ref = "dokka" }
dokka-plugin = { group = "org.jetbrains.dokka", name = "android-documentation-plugin", version.ref = "dokka" }
Expand All @@ -37,6 +41,7 @@ junit4 = { group = "junit", name = "junit", version.ref = "junit4" }
theoplayer = { group = "com.theoplayer.theoplayer-sdk-android", name = "core", version.ref = "theoplayer" }
theoplayer-ads = { group = "com.theoplayer.theoplayer-sdk-android", name = "integration-ads", version.ref = "theoplayer" }
theoplayer-ads-ima = { group = "com.theoplayer.theoplayer-sdk-android", name = "integration-ads-ima", version.ref = "theoplayer" }
theoplayer-cast = { group = "com.theoplayer.theoplayer-sdk-android", name = "integration-cast", version.ref = "theoplayer" }

[plugins]
android-application = { id = "com.android.application", version.ref = "gradle" }
Expand Down
40 changes: 29 additions & 11 deletions ui/src/main/java/com/theoplayer/android/ui/Player.kt
Original file line number Diff line number Diff line change
Expand Up @@ -296,7 +296,7 @@ enum class StreamType {
internal class PlayerImpl(override val theoplayerView: THEOplayerView?) : Player {
override val player = theoplayerView?.player
override val ads = theoplayerView?.player?.ads
override val cast = theoplayerView?.cast
override var cast by mutableStateOf<Cast?>(null)
override var currentTime by mutableStateOf(0.0)
private set
override var duration by mutableStateOf(Double.NaN)
Expand Down Expand Up @@ -367,6 +367,9 @@ internal class PlayerImpl(override val theoplayerView: THEOplayerView?) : Player
_source = player?.source
error = null
firstPlay = false
// The cast integration is only registered *after* rememberPlayer() is called,
// so it's not available at construction time. Check if it's available now.
updateCast(theoplayerView?.cast)
updateCurrentTimeAndPlaybackState()
updateDuration()
updateVideoWidthAndHeight()
Expand Down Expand Up @@ -611,6 +614,29 @@ internal class PlayerImpl(override val theoplayerView: THEOplayerView?) : Player
override var castState: PlayerCastState by mutableStateOf(PlayerCastState.UNAVAILABLE)
override var castReceiverName: String? by mutableStateOf(null)

private fun updateCast(cast: Cast?) {
if (this.cast == cast) return
this.cast?.let { oldCast ->
oldCast.chromecast.removeEventListener(
ChromecastEventTypes.STATECHANGE,
chromecastStateChangeListener
)
oldCast.chromecast.removeEventListener(
ChromecastEventTypes.ERROR,
chromecastErrorListener
)
}
this.cast = cast
cast?.let {
cast.chromecast.addEventListener(
ChromecastEventTypes.STATECHANGE,
chromecastStateChangeListener
)
cast.chromecast.addEventListener(ChromecastEventTypes.ERROR, chromecastErrorListener)
}
updateCastState()
}

private fun updateCastState() {
castState = cast?.chromecast?.state ?: PlayerCastState.UNAVAILABLE
castReceiverName = cast?.chromecast?.receiverName
Expand Down Expand Up @@ -683,11 +709,7 @@ internal class PlayerImpl(override val theoplayerView: THEOplayerView?) : Player
ads?.addEventListener(AdsEventTypes.AD_BREAK_BEGIN, adListener)
ads?.addEventListener(AdsEventTypes.AD_SKIP, adListener)
ads?.addEventListener(AdsEventTypes.AD_BREAK_END, adListener)
cast?.chromecast?.addEventListener(
ChromecastEventTypes.STATECHANGE,
chromecastStateChangeListener
)
cast?.chromecast?.addEventListener(ChromecastEventTypes.ERROR, chromecastErrorListener)
updateCast(theoplayerView?.cast)
fullscreenHandler?.onFullscreenChangeListener = fullscreenListener
}

Expand Down Expand Up @@ -749,11 +771,7 @@ internal class PlayerImpl(override val theoplayerView: THEOplayerView?) : Player
ads?.removeEventListener(AdsEventTypes.AD_BREAK_BEGIN, adListener)
ads?.removeEventListener(AdsEventTypes.AD_SKIP, adListener)
ads?.removeEventListener(AdsEventTypes.AD_BREAK_END, adListener)
cast?.chromecast?.removeEventListener(
ChromecastEventTypes.STATECHANGE,
chromecastStateChangeListener
)
cast?.chromecast?.removeEventListener(ChromecastEventTypes.ERROR, chromecastErrorListener)
updateCast(null)
fullscreenHandler?.onFullscreenChangeListener = null
}
}
Expand Down
4 changes: 2 additions & 2 deletions ui/src/main/java/com/theoplayer/android/ui/UIController.kt
Original file line number Diff line number Diff line change
Expand Up @@ -167,14 +167,14 @@ fun UIController(
derivedStateOf {
if (!isReady) {
false
} else if (!player.firstPlay) {
} else if (!player.firstPlay || player.castState == PlayerCastState.CONNECTED) {
true
} else if (player.playingAd) {
false
} else if (forceControlsHidden) {
false
} else {
isRecentlyTapped || isPressed || player.paused || player.castState == PlayerCastState.CONNECTED
isRecentlyTapped || isPressed || player.paused
}
}
}
Expand Down

0 comments on commit d8ecb96

Please sign in to comment.