feat(cast): implement Google Cast support and expanded controller activity

This commit is contained in:
2026-04-05 17:15:48 +02:00
parent 0796bc8ef4
commit 7b2cfb4b17
11 changed files with 68 additions and 15 deletions
+11 -5
View File
@@ -31,11 +31,6 @@
android:usesCleartextTraffic="true" android:usesCleartextTraffic="true"
tools:targetApi="33"> tools:targetApi="33">
<!-- GOOGLE CAST SUPPORT -->
<meta-data
android:name="com.google.android.gms.cast.framework.OPTIONS_PROVIDER_CLASS_NAME"
android:value="com.michatec.radio.CastOptionsProvider" />
<!-- ANDROID AUTO SUPPORT --> <!-- ANDROID AUTO SUPPORT -->
<meta-data <meta-data
android:name="com.google.android.gms.car.application" android:name="com.google.android.gms.car.application"
@@ -44,6 +39,17 @@
android:name="com.google.android.gms.car.notification.SmallIcon" android:name="com.google.android.gms.car.notification.SmallIcon"
android:resource="@mipmap/ic_launcher" /> android:resource="@mipmap/ic_launcher" />
<!-- GOOGLE CAST SUPPORT -->
<receiver android:name="androidx.mediarouter.media.MediaTransferReceiver" />
<meta-data
android:name="com.google.android.gms.cast.framework.OPTIONS_PROVIDER_CLASS_NAME"
android:value="com.michatec.radio.CastOptionsProvider" />
<activity
android:name=".ExpandedControllerActivity"
android:exported="false"
android:launchMode="singleTask" />
<!-- Main activity for radio station playback on phone and TV --> <!-- Main activity for radio station playback on phone and TV -->
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
@@ -16,6 +16,7 @@ class CastOptionsProvider : OptionsProvider {
.build() .build()
val mediaOptions = CastMediaOptions.Builder() val mediaOptions = CastMediaOptions.Builder()
.setNotificationOptions(notificationOptions) .setNotificationOptions(notificationOptions)
.setExpandedControllerActivityClassName(ExpandedControllerActivity::class.java.name)
.build() .build()
return CastOptions.Builder() return CastOptions.Builder()
@@ -0,0 +1,14 @@
package com.michatec.radio
import android.view.Menu
import com.google.android.gms.cast.framework.CastButtonFactory
import com.google.android.gms.cast.framework.media.widget.ExpandedControllerActivity
class ExpandedControllerActivity : ExpandedControllerActivity() {
override fun onCreateOptionsMenu(menu: Menu): Boolean {
super.onCreateOptionsMenu(menu)
menuInflater.inflate(R.menu.expanded_controller, menu)
CastButtonFactory.setUpMediaRouteButton(this, menu, R.id.media_route_menu_item)
return true
}
}
@@ -402,6 +402,7 @@ class PlayerFragment : Fragment(),
/* Releases MediaController */ /* Releases MediaController */
private fun releaseController() { private fun releaseController() {
controller?.removeListener(playerListener)
MediaController.releaseFuture(controllerFuture) MediaController.releaseFuture(controllerFuture)
} }
@@ -10,6 +10,7 @@ import android.os.CountDownTimer
import android.util.Log import android.util.Log
import androidx.localbroadcastmanager.content.LocalBroadcastManager import androidx.localbroadcastmanager.content.LocalBroadcastManager
import androidx.media.MediaBrowserServiceCompat.BrowserRoot.EXTRA_RECENT import androidx.media.MediaBrowserServiceCompat.BrowserRoot.EXTRA_RECENT
import androidx.media3.cast.CastPlayer
import androidx.media3.common.* import androidx.media3.common.*
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.HttpDataSource import androidx.media3.datasource.HttpDataSource
@@ -45,6 +46,8 @@ class PlayerService : MediaLibraryService(), SharedPreferences.OnSharedPreferenc
/* Main class variables */ /* Main class variables */
private lateinit var player: Player private lateinit var player: Player
private lateinit var exoPlayer: ExoPlayer
private lateinit var castPlayer: CastPlayer
private lateinit var mediaLibrarySession: MediaLibrarySession private lateinit var mediaLibrarySession: MediaLibrarySession
private lateinit var sleepTimer: CountDownTimer private lateinit var sleepTimer: CountDownTimer
var sleepTimerTimeRemaining: Long = 0L var sleepTimerTimeRemaining: Long = 0L
@@ -93,6 +96,8 @@ class PlayerService : MediaLibraryService(), SharedPreferences.OnSharedPreferenc
PreferencesHelper.saveIsPlaying(false) PreferencesHelper.saveIsPlaying(false)
player.removeListener(playerListener) player.removeListener(playerListener)
player.release() player.release()
exoPlayer.release()
castPlayer.release()
mediaLibrarySession.release() mediaLibrarySession.release()
// unregister preference change listener // unregister preference change listener
PreferencesHelper.unregisterPreferenceChangeListener(this) PreferencesHelper.unregisterPreferenceChangeListener(this)
@@ -134,7 +139,7 @@ class PlayerService : MediaLibraryService(), SharedPreferences.OnSharedPreferenc
} }
} }
val exoPlayer: ExoPlayer = ExoPlayer.Builder(this, renderersFactory).apply { exoPlayer = ExoPlayer.Builder(this, renderersFactory).apply {
setAudioAttributes(audioAttributes, true) setAudioAttributes(audioAttributes, true)
setHandleAudioBecomingNoisy(true) setHandleAudioBecomingNoisy(true)
setLoadControl(createDefaultLoadControl(bufferSizeMultiplier)) setLoadControl(createDefaultLoadControl(bufferSizeMultiplier))
@@ -147,7 +152,10 @@ class PlayerService : MediaLibraryService(), SharedPreferences.OnSharedPreferenc
exoPlayer.addAnalyticsListener(analyticsListener) exoPlayer.addAnalyticsListener(analyticsListener)
exoPlayer.addListener(playerListener) exoPlayer.addListener(playerListener)
// manually add seek to next and seek to previous since headphones issue them and they are translated to next and previous station // Initialize CastPlayer
castPlayer = CastPlayer.Builder(this).setLocalPlayer(exoPlayer).build()
// manually add seek to next and seek to previous since headphones issue them, and they are translated to next and previous station
player = object : ForwardingPlayer(exoPlayer) { player = object : ForwardingPlayer(exoPlayer) {
override fun getAvailableCommands(): Player.Commands { override fun getAvailableCommands(): Player.Commands {
return super.getAvailableCommands().buildUpon().add(COMMAND_SEEK_TO_NEXT) return super.getAvailableCommands().buildUpon().add(COMMAND_SEEK_TO_NEXT)
@@ -543,7 +551,7 @@ class PlayerService : MediaLibraryService(), SharedPreferences.OnSharedPreferenc
/* /*
* Custom LoadErrorHandlingPolicy that network drop outs * Custom LoadErrorHandlingPolicy that network drop-outs
*/ */
private val loadErrorHandlingPolicy: DefaultLoadErrorHandlingPolicy = object: DefaultLoadErrorHandlingPolicy() { private val loadErrorHandlingPolicy: DefaultLoadErrorHandlingPolicy = object: DefaultLoadErrorHandlingPolicy() {
override fun getRetryDelayMsFor(loadErrorInfo: LoadErrorHandlingPolicy.LoadErrorInfo): Long { override fun getRetryDelayMsFor(loadErrorInfo: LoadErrorHandlingPolicy.LoadErrorInfo): Long {
@@ -638,15 +638,13 @@ object CollectionHelper {
}.build() }.build()
// build MediaMetadata // build MediaMetadata
val mediaMetadata = MediaMetadata.Builder().apply { val mediaMetadata = MediaMetadata.Builder().apply {
setTitle(station.name)
setArtist(station.name) setArtist(station.name)
//setTitle(station.name)
// Set artwork URI for casting (TV needs a public URL) // Set artwork URI for casting (remote devices need a public URL)
val artworkUrl = station.remoteImageLocation.ifEmpty { if (station.remoteImageLocation.isNotEmpty()) {
// Placeholder PNG image for stations without remote image setArtworkUri(station.remoteImageLocation.toUri())
"https://raw.githubusercontent.com/google/material-design-icons/master/png/av/radio/materialicons/48dp/2x/baseline_radio_black_48dp.png"
} }
setArtworkUri(artworkUrl.toUri())
/* check for "file://" prevents a crash when an old backup was restored */ /* check for "file://" prevents a crash when an old backup was restored */
if (station.image.isNotEmpty() && station.image.startsWith("file://")) { if (station.image.isNotEmpty() && station.image.startsWith("file://")) {
@@ -656,6 +654,7 @@ object CollectionHelper {
} }
setIsBrowsable(false) setIsBrowsable(false)
setIsPlayable(true) setIsPlayable(true)
setMediaType(MediaMetadata.MEDIA_TYPE_RADIO_STATION)
}.build() }.build()
// build MediaItem and return it // build MediaItem and return it
return MediaItem.Builder().apply { return MediaItem.Builder().apply {
@@ -0,0 +1,11 @@
<?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_title"
app:actionProviderClass="androidx.mediarouter.app.MediaRouteActionProvider"
app:showAsAction="always" />
</menu>
+6
View File
@@ -27,4 +27,10 @@
<!-- Don't show light status bar --> <!-- Don't show light status bar -->
<item name="android:statusBarColor">@android:color/transparent</item> <item name="android:statusBarColor">@android:color/transparent</item>
</style> </style>
<style name="CustomCastExpandedControllerStyle" parent="CastExpandedController">
<item name="android:windowBackground">#FFFFFF</item>
<item name="castBackground">#FFFFFF</item>
<item name="castButtonColor">#FF495D92</item>
</style>
</resources> </resources>
+1
View File
@@ -157,4 +157,5 @@
<!-- Extras --> <!-- Extras -->
<string name="loading">Loading...</string> <string name="loading">Loading...</string>
<string name="media_route_menu_title">Cast</string>
</resources> </resources>
+6
View File
@@ -74,4 +74,10 @@
<item name="cornerSizeBottomRight">16dp</item> <item name="cornerSizeBottomRight">16dp</item>
<item name="cornerFamilyBottomRight">rounded</item> <item name="cornerFamilyBottomRight">rounded</item>
</style> </style>
<style name="CustomCastExpandedControllerStyle" parent="CastExpandedController">
<item name="android:windowBackground">#FFFFFF</item>
<item name="castBackground">#FFFFFF</item>
<item name="castButtonColor">#FF495D92</item>
</style>
</resources> </resources>
+1 -1
View File
@@ -7,5 +7,5 @@ plugins {
} }
tasks.register('clean', Delete) { tasks.register('clean', Delete) {
delete rootProject.buildDir() delete layout.buildDirectory
} }