First pass adding android auto and apple carplay support. Android auto tuned on emulator.

This commit is contained in:
2026-05-15 21:30:23 -07:00
parent 52f5ec577b
commit 83dd1a28b8
14 changed files with 382 additions and 33 deletions
+19
View File
@@ -42,12 +42,31 @@
android:name="com.google.android.gms.cast.framework.OPTIONS_PROVIDER_CLASS_NAME"
android:value="com.example.kryz_go_flutter.CastOptionsProvider" />
<!-- Declares this app as an Android Auto media app. -->
<meta-data
android:name="com.google.android.gms.car.application"
android:resource="@xml/automotive_app_desc" />
<service
android:name="com.ryanheise.audioservice.AudioService"
android:enabled="true"
android:exported="true"
android:foregroundServiceType="mediaPlayback"
tools:ignore="Instantiatable">
</service>
<!--
Dedicated MediaBrowserService for Android Auto. Android Auto connects here
to browse content and send transport controls. Styled with KryzAutoTheme
so Auto accents its media UI with the KRYZ brand orange (#E36A18).
-->
<service
android:name="com.example.kryz_go_flutter.KryzAutoMediaBrowserService"
android:enabled="true"
android:exported="true"
android:foregroundServiceType="mediaPlayback"
android:theme="@style/KryzAutoTheme"
tools:ignore="Instantiatable">
<intent-filter>
<action android:name="android.media.browse.MediaBrowserService"/>
</intent-filter>
@@ -3,7 +3,6 @@ package com.example.kryz_go_flutter
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.ComponentName
import android.content.Context
import android.media.AudioAttributes
import android.media.AudioFocusRequest
@@ -132,39 +131,29 @@ class KryzAutoMediaBrowserService : MediaBrowserServiceCompat() {
Log.d(TAG, "onCreate()")
createNotificationChannel()
try {
mediaSession = MediaSessionCompat(
this,
TAG,
ComponentName(this, com.ryanheise.audioservice.MediaButtonReceiver::class.java),
null,
).apply {
setCallback(mediaSessionCallback)
@Suppress("DEPRECATION")
setFlags(
MediaSessionCompat.FLAG_HANDLES_MEDIA_BUTTONS or
MediaSessionCompat.FLAG_HANDLES_TRANSPORT_CONTROLS
)
setPlaybackState(
PlaybackStateCompat.Builder()
.setActions(
PlaybackStateCompat.ACTION_PLAY or
PlaybackStateCompat.ACTION_STOP or
PlaybackStateCompat.ACTION_PLAY_FROM_MEDIA_ID or
PlaybackStateCompat.ACTION_PLAY_FROM_SEARCH
)
.setState(PlaybackStateCompat.STATE_STOPPED, 0, 1f)
.build()
)
setMetadata(buildLiveStreamMetadata())
isActive = true
}
sessionToken = mediaSession.sessionToken
Log.d(TAG, "MediaSession created, sessionToken set")
} catch (e: Exception) {
Log.e(TAG, "Failed to create MediaSession", e)
mediaSession = MediaSessionCompat(this, TAG).apply {
setCallback(mediaSessionCallback)
@Suppress("DEPRECATION")
setFlags(
MediaSessionCompat.FLAG_HANDLES_MEDIA_BUTTONS or
MediaSessionCompat.FLAG_HANDLES_TRANSPORT_CONTROLS
)
setPlaybackState(
PlaybackStateCompat.Builder()
.setActions(
PlaybackStateCompat.ACTION_PLAY or
PlaybackStateCompat.ACTION_STOP or
PlaybackStateCompat.ACTION_PLAY_FROM_MEDIA_ID or
PlaybackStateCompat.ACTION_PLAY_FROM_SEARCH
)
.setState(PlaybackStateCompat.STATE_STOPPED, 0, 1f)
.build()
)
setMetadata(buildLiveStreamMetadata())
isActive = true
}
sessionToken = mediaSession.sessionToken
Log.d(TAG, "MediaSession created, sessionToken set")
}
override fun onGetRoot(
@@ -1,5 +1,6 @@
package com.example.kryz_go_flutter
import android.content.Intent
import android.os.Handler
import android.os.Looper
import androidx.appcompat.view.ContextThemeWrapper
@@ -26,6 +27,7 @@ class MainActivity : AudioServiceActivity(), MethodChannel.MethodCallHandler, Ev
companion object {
private const val METHOD_CHANNEL = "kryz_go_flutter/casting/methods"
private const val EVENT_CHANNEL = "kryz_go_flutter/casting/events"
private const val PLAYBACK_METHOD_CHANNEL = "kryz_go_flutter/playback/methods"
}
private var castContext: CastContext? = null
@@ -33,6 +35,9 @@ class MainActivity : AudioServiceActivity(), MethodChannel.MethodCallHandler, Ev
private var chooserDialog: MediaRouteChooserDialog? = null
private val mainHandler = Handler(Looper.getMainLooper())
private var activeRemoteMediaClient: RemoteMediaClient? = null
private var playbackMethodChannel: MethodChannel? = null
private var flutterReady = false
private var pendingAutoPlay = false
private var statusPayload: MutableMap<String, Any?> = mutableMapOf(
"status" to "idle",
@@ -101,9 +106,46 @@ class MainActivity : AudioServiceActivity(), MethodChannel.MethodCallHandler, Ev
EventChannel(flutterEngine.dartExecutor.binaryMessenger, EVENT_CHANNEL)
.setStreamHandler(this)
playbackMethodChannel = MethodChannel(
flutterEngine.dartExecutor.binaryMessenger,
PLAYBACK_METHOD_CHANNEL,
)
playbackMethodChannel?.setMethodCallHandler { call, result ->
when (call.method) {
"flutterReady" -> {
flutterReady = true
// Stop the service's MediaPlayer and hand off to Flutter.
val wasServicePlaying = KryzAutoMediaBrowserService.isServicePlaying()
KryzAutoMediaBrowserService.stopServicePlayer()
KryzAutoMediaBrowserService.registerDirectCallbacks(
play = { mainHandler.post { invokeFlutterPlay() } },
stop = { mainHandler.post { playbackMethodChannel?.invokeMethod("stop", null) } },
)
if (pendingAutoPlay || wasServicePlaying) {
pendingAutoPlay = false
invokeFlutterPlay()
}
result.success(null)
}
"updatePlaybackState" -> {
val isPlaying = call.argument<Boolean>("isPlaying") ?: false
val isBuffering = call.argument<Boolean>("isBuffering") ?: false
KryzAutoMediaBrowserService.updatePlaybackState(isPlaying, isBuffering)
result.success(null)
}
else -> result.notImplemented()
}
}
initializeCastContext()
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
}
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"getStatus" -> result.success(statusPayload)
@@ -139,6 +181,8 @@ class MainActivity : AudioServiceActivity(), MethodChannel.MethodCallHandler, Ev
chooserDialog?.dismiss()
chooserDialog = null
KryzAutoMediaBrowserService.unregisterDirectCallbacks()
castContext?.sessionManager?.removeSessionManagerListener(
castSessionManagerListener,
CastSession::class.java,
@@ -148,6 +192,16 @@ class MainActivity : AudioServiceActivity(), MethodChannel.MethodCallHandler, Ev
super.onDestroy()
}
private fun invokeFlutterPlay() {
if (!flutterReady) {
pendingAutoPlay = true
return
}
mainHandler.post {
playbackMethodChannel?.invokeMethod("play", null)
}
}
private fun initializeCastContext() {
try {
castContext = CastContext.getSharedInstance(this)
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Brand colors exposed to Android Auto. Android Auto's media UI reads colorPrimary
from the app theme to accent its browsing and now-playing screens.
#E36A18 matches AppThemeBusinessObject.lightColors.primarySeed (brand orange).
-->
<resources>
<color name="kryz_primary">#E36A18</color>
<color name="kryz_primary_dark">#B84E08</color>
<color name="kryz_accent">#1663C7</color>
</resources>
@@ -16,4 +16,14 @@
<item name="android:windowBackground">@android:color/white</item>
<item name="android:windowIsTranslucent">false</item>
</style>
<!--
Theme used by KryzAutoMediaBrowserService. Android Auto reads colorPrimary
from the service's theme to accent its media browsing UI with the KRYZ brand orange.
-->
<style name="KryzAutoTheme" parent="Theme.AppCompat">
<item name="colorPrimary">@color/kryz_primary</item>
<item name="colorPrimaryDark">@color/kryz_primary_dark</item>
<item name="colorAccent">@color/kryz_accent</item>
</style>
</resources>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<automotiveApp>
<uses name="media"/>
</automotiveApp>