Compare commits

...
2 Commits
Author SHA1 Message Date
kfj001 83dd1a28b8 First pass adding android auto and apple carplay support. Android auto tuned on emulator. 2026-05-15 21:30:23 -07:00
kfj001andCopilot 52f5ec577b Fix: remove orphaned old class body from KryzAutoMediaBrowserService
The previous rewrite left the entire old class body (companion object,
mediaSessionCallback, lifecycle overrides, launchMainActivity helpers)
dangling outside the new class closing brace — a Kotlin syntax error.
Gradle was using the last successfully compiled APK, so Android Auto
received a broken service and showed 'doesn't seem to be working'.

Remove lines 362-581 (old code), leaving only the new clean class.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-15 21:17:21 -07:00
14 changed files with 710 additions and 0 deletions
+1
View File
@@ -9,6 +9,7 @@ dependencies {
implementation("com.google.android.gms:play-services-cast-framework:22.1.0") implementation("com.google.android.gms:play-services-cast-framework:22.1.0")
implementation("androidx.mediarouter:mediarouter:1.7.0") implementation("androidx.mediarouter:mediarouter:1.7.0")
implementation("androidx.appcompat:appcompat:1.7.0") implementation("androidx.appcompat:appcompat:1.7.0")
implementation("androidx.media:media:1.7.0")
} }
android { android {
+19
View File
@@ -42,12 +42,31 @@
android:name="com.google.android.gms.cast.framework.OPTIONS_PROVIDER_CLASS_NAME" android:name="com.google.android.gms.cast.framework.OPTIONS_PROVIDER_CLASS_NAME"
android:value="com.example.kryz_go_flutter.CastOptionsProvider" /> 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 <service
android:name="com.ryanheise.audioservice.AudioService" android:name="com.ryanheise.audioservice.AudioService"
android:enabled="true" android:enabled="true"
android:exported="true" android:exported="true"
android:foregroundServiceType="mediaPlayback" android:foregroundServiceType="mediaPlayback"
tools:ignore="Instantiatable"> 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> <intent-filter>
<action android:name="android.media.browse.MediaBrowserService"/> <action android:name="android.media.browse.MediaBrowserService"/>
</intent-filter> </intent-filter>
@@ -0,0 +1,350 @@
package com.example.kryz_go_flutter
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.media.AudioAttributes
import android.media.AudioFocusRequest
import android.media.AudioManager
import android.media.MediaPlayer
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.support.v4.media.MediaBrowserCompat.MediaItem
import android.support.v4.media.MediaDescriptionCompat
import android.support.v4.media.MediaMetadataCompat
import android.support.v4.media.session.MediaSessionCompat
import android.support.v4.media.session.PlaybackStateCompat
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.media.MediaBrowserServiceCompat
/**
* MediaBrowserService for Android Auto.
*
* Android Auto connects to this service to browse content and control playback.
*
* Two playback modes:
* 1. Cold start (Flutter not running): the service plays the stream directly via
* MediaPlayer. This avoids the unreliable startActivity → Flutter bridge.
* 2. Warm start (Flutter running): MainActivity registers play/stop callbacks so
* Auto commands are forwarded through the method channel to just_audio.
*
* When the Flutter app opens while the service is playing, MainActivity stops the
* service's MediaPlayer and Flutter takes over seamlessly.
*
* Brand color (#E36A18) is declared on this service via KryzAutoTheme in styles.xml
* so Android Auto accents its media UI with the KRYZ orange.
*/
class KryzAutoMediaBrowserService : MediaBrowserServiceCompat() {
companion object {
private const val TAG = "KryzAutoService"
private const val MEDIA_ID_ROOT = "kryz_root"
private const val MEDIA_ID_LIVE = "kryz_live_stream"
private const val STREAM_URL = "https://kryz.out.airtime.pro/kryz_a"
private const val NOTIFICATION_CHANNEL_ID = "com.kryzgoflutter.auto.channel"
private const val NOTIFICATION_ID = 1001
private var instance: KryzAutoMediaBrowserService? = null
// Called by MainActivity to mirror Flutter playback state into the MediaSession.
fun updatePlaybackState(isPlaying: Boolean, isBuffering: Boolean) {
instance?.applyPlaybackState(isPlaying, isBuffering)
}
// Called by MainActivity when Flutter is alive and taking over from the service player.
fun stopServicePlayer() {
instance?.stopServicePlayback()
}
fun isServicePlaying(): Boolean {
return try {
instance?.serviceMediaPlayer?.isPlaying == true
} catch (_: IllegalStateException) {
false
}
}
// Flutter-side play/stop callbacks registered by MainActivity when Flutter is ready.
private var directPlayCallback: (() -> Unit)? = null
private var directStopCallback: (() -> Unit)? = null
fun registerDirectCallbacks(play: () -> Unit, stop: () -> Unit) {
directPlayCallback = play
directStopCallback = stop
}
fun unregisterDirectCallbacks() {
directPlayCallback = null
directStopCallback = null
}
}
private lateinit var mediaSession: MediaSessionCompat
private var serviceMediaPlayer: MediaPlayer? = null
private var audioFocusRequest: AudioFocusRequest? = null
private val mediaSessionCallback = object : MediaSessionCompat.Callback() {
override fun onPlay() {
Log.d(TAG, "onPlay()")
triggerPlay()
}
override fun onStop() {
Log.d(TAG, "onStop()")
val stop = directStopCallback
if (stop != null) {
stop()
} else {
stopServicePlayback()
}
}
override fun onPlayFromMediaId(mediaId: String?, extras: Bundle?) {
Log.d(TAG, "onPlayFromMediaId: $mediaId")
if (mediaId == MEDIA_ID_LIVE) triggerPlay()
}
override fun onPlayFromSearch(query: String?, extras: Bundle?) {
Log.d(TAG, "onPlayFromSearch: $query")
triggerPlay()
}
}
private fun triggerPlay() {
val play = directPlayCallback
if (play != null) {
// Flutter is running — signal buffering then let Flutter handle audio.
applyPlaybackState(isPlaying = false, isBuffering = true)
play()
} else {
// Flutter is not running — play directly via MediaPlayer.
startServicePlayback()
}
}
override fun onCreate() {
super.onCreate()
instance = this
Log.d(TAG, "onCreate()")
createNotificationChannel()
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(
clientPackageName: String,
clientUid: Int,
rootHints: Bundle?
): BrowserRoot {
Log.d(TAG, "onGetRoot() from $clientPackageName")
return BrowserRoot(MEDIA_ID_ROOT, null)
}
override fun onLoadChildren(
parentId: String,
result: Result<List<MediaItem>>
) {
Log.d(TAG, "onLoadChildren($parentId)")
if (parentId == MEDIA_ID_ROOT) {
val iconUri = Uri.parse("android.resource://${packageName}/mipmap/ic_launcher")
val description = MediaDescriptionCompat.Builder()
.setMediaId(MEDIA_ID_LIVE)
.setTitle("KRYZ Live Stream")
.setSubtitle("KRYZ Radio")
.setIconUri(iconUri)
.setMediaUri(Uri.parse(STREAM_URL))
.build()
result.sendResult(listOf(MediaItem(description, MediaItem.FLAG_PLAYABLE)))
} else {
result.sendResult(emptyList())
}
}
override fun onDestroy() {
Log.d(TAG, "onDestroy()")
stopServicePlayback()
instance = null
if (::mediaSession.isInitialized) mediaSession.release()
super.onDestroy()
}
// Direct MediaPlayer playback used when Flutter is not running (cold Auto start).
private fun startServicePlayback() {
Log.d(TAG, "startServicePlayback()")
stopServicePlayback()
applyPlaybackState(isPlaying = false, isBuffering = true)
startForeground(NOTIFICATION_ID, buildNotification(isPlaying = false))
requestAudioFocus()
val player = MediaPlayer()
try {
player.setAudioAttributes(
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
.build()
)
player.setDataSource(STREAM_URL)
player.setOnPreparedListener { mp ->
Log.d(TAG, "MediaPlayer prepared → starting")
mp.start()
applyPlaybackState(isPlaying = true, isBuffering = false)
startForeground(NOTIFICATION_ID, buildNotification(isPlaying = true))
}
player.setOnInfoListener { _, what, _ ->
when (what) {
MediaPlayer.MEDIA_INFO_BUFFERING_START ->
applyPlaybackState(isPlaying = false, isBuffering = true)
MediaPlayer.MEDIA_INFO_BUFFERING_END ->
applyPlaybackState(isPlaying = true, isBuffering = false)
}
true
}
player.setOnErrorListener { _, what, extra ->
Log.e(TAG, "MediaPlayer error what=$what extra=$extra")
applyPlaybackState(isPlaying = false, isBuffering = false)
@Suppress("DEPRECATION")
stopForeground(true)
true
}
player.prepareAsync()
serviceMediaPlayer = player
} catch (e: Exception) {
Log.e(TAG, "Failed to start service playback: ${e.message}")
player.release()
applyPlaybackState(isPlaying = false, isBuffering = false)
@Suppress("DEPRECATION")
stopForeground(true)
}
}
private fun stopServicePlayback() {
val player = serviceMediaPlayer ?: return
Log.d(TAG, "stopServicePlayback()")
serviceMediaPlayer = null
try {
if (player.isPlaying) player.stop()
player.release()
} catch (e: Exception) {
Log.e(TAG, "Error releasing MediaPlayer: ${e.message}")
}
abandonAudioFocus()
applyPlaybackState(isPlaying = false, isBuffering = false)
@Suppress("DEPRECATION")
stopForeground(true)
}
private fun applyPlaybackState(isPlaying: Boolean, isBuffering: Boolean) {
if (!::mediaSession.isInitialized) return
val state = when {
isPlaying -> PlaybackStateCompat.STATE_PLAYING
isBuffering -> PlaybackStateCompat.STATE_BUFFERING
else -> PlaybackStateCompat.STATE_STOPPED
}
val actions = if (isPlaying || isBuffering) {
PlaybackStateCompat.ACTION_STOP
} else {
PlaybackStateCompat.ACTION_PLAY or
PlaybackStateCompat.ACTION_PLAY_FROM_MEDIA_ID or
PlaybackStateCompat.ACTION_PLAY_FROM_SEARCH
}
mediaSession.setPlaybackState(
PlaybackStateCompat.Builder()
.setActions(actions)
.setState(state, PlaybackStateCompat.PLAYBACK_POSITION_UNKNOWN, 1f)
.build()
)
Log.d(TAG, "applyPlaybackState: playing=$isPlaying buffering=$isBuffering")
}
private fun requestAudioFocus() {
val audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val req = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
.setAudioAttributes(
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
.build()
)
.build()
audioFocusRequest = req
audioManager.requestAudioFocus(req)
} else {
@Suppress("DEPRECATION")
audioManager.requestAudioFocus(null, AudioManager.STREAM_MUSIC, AudioManager.AUDIOFOCUS_GAIN)
}
}
private fun abandonAudioFocus() {
val audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
audioFocusRequest?.let { audioManager.abandonAudioFocusRequest(it) }
audioFocusRequest = null
}
}
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
NOTIFICATION_CHANNEL_ID,
"KRYZ Auto Playback",
NotificationManager.IMPORTANCE_LOW,
).apply {
description = "KRYZ Radio Android Auto playback"
setShowBadge(false)
}
val mgr = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
mgr.createNotificationChannel(channel)
}
}
private fun buildNotification(isPlaying: Boolean): Notification {
return NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
.setContentTitle("KRYZ Radio")
.setContentText(if (isPlaying) "Playing via Android Auto" else "Connecting…")
.setSmallIcon(R.mipmap.ic_launcher)
.setOngoing(isPlaying)
.setSilent(true)
.build()
}
private fun buildLiveStreamMetadata(): MediaMetadataCompat {
val iconUri = Uri.parse("android.resource://${packageName}/mipmap/ic_launcher")
return MediaMetadataCompat.Builder()
.putString(MediaMetadataCompat.METADATA_KEY_TITLE, "KRYZ Live Stream")
.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, "KRYZ Radio")
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM, "Live")
.putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, MEDIA_ID_LIVE)
.putString(MediaMetadataCompat.METADATA_KEY_MEDIA_URI, STREAM_URL)
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, iconUri.toString())
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI, iconUri.toString())
.build()
}
}
@@ -1,5 +1,6 @@
package com.example.kryz_go_flutter package com.example.kryz_go_flutter
import android.content.Intent
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import androidx.appcompat.view.ContextThemeWrapper import androidx.appcompat.view.ContextThemeWrapper
@@ -26,6 +27,7 @@ class MainActivity : AudioServiceActivity(), MethodChannel.MethodCallHandler, Ev
companion object { companion object {
private const val METHOD_CHANNEL = "kryz_go_flutter/casting/methods" private const val METHOD_CHANNEL = "kryz_go_flutter/casting/methods"
private const val EVENT_CHANNEL = "kryz_go_flutter/casting/events" 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 private var castContext: CastContext? = null
@@ -33,6 +35,9 @@ class MainActivity : AudioServiceActivity(), MethodChannel.MethodCallHandler, Ev
private var chooserDialog: MediaRouteChooserDialog? = null private var chooserDialog: MediaRouteChooserDialog? = null
private val mainHandler = Handler(Looper.getMainLooper()) private val mainHandler = Handler(Looper.getMainLooper())
private var activeRemoteMediaClient: RemoteMediaClient? = null 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( private var statusPayload: MutableMap<String, Any?> = mutableMapOf(
"status" to "idle", "status" to "idle",
@@ -101,9 +106,46 @@ class MainActivity : AudioServiceActivity(), MethodChannel.MethodCallHandler, Ev
EventChannel(flutterEngine.dartExecutor.binaryMessenger, EVENT_CHANNEL) EventChannel(flutterEngine.dartExecutor.binaryMessenger, EVENT_CHANNEL)
.setStreamHandler(this) .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() initializeCastContext()
} }
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
}
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) { when (call.method) {
"getStatus" -> result.success(statusPayload) "getStatus" -> result.success(statusPayload)
@@ -139,6 +181,8 @@ class MainActivity : AudioServiceActivity(), MethodChannel.MethodCallHandler, Ev
chooserDialog?.dismiss() chooserDialog?.dismiss()
chooserDialog = null chooserDialog = null
KryzAutoMediaBrowserService.unregisterDirectCallbacks()
castContext?.sessionManager?.removeSessionManagerListener( castContext?.sessionManager?.removeSessionManagerListener(
castSessionManagerListener, castSessionManagerListener,
CastSession::class.java, CastSession::class.java,
@@ -148,6 +192,16 @@ class MainActivity : AudioServiceActivity(), MethodChannel.MethodCallHandler, Ev
super.onDestroy() super.onDestroy()
} }
private fun invokeFlutterPlay() {
if (!flutterReady) {
pendingAutoPlay = true
return
}
mainHandler.post {
playbackMethodChannel?.invokeMethod("play", null)
}
}
private fun initializeCastContext() { private fun initializeCastContext() {
try { try {
castContext = CastContext.getSharedInstance(this) 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:windowBackground">@android:color/white</item>
<item name="android:windowIsTranslucent">false</item> <item name="android:windowIsTranslucent">false</item>
</style> </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> </resources>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<automotiveApp>
<uses name="media"/>
</automotiveApp>
+9
View File
@@ -14,6 +14,7 @@
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; }; 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; };
A1B2C3D4E5F6A7B8C9D0E1F2 /* CarPlaySceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F6A7B8C9D0E1F1 /* CarPlaySceneDelegate.swift */; };
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
@@ -56,6 +57,8 @@
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; }; 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
A1B2C3D4E5F6A7B8C9D0E1F1 /* CarPlaySceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarPlaySceneDelegate.swift; sourceTree = "<group>"; };
A1B2C3D4E5F6A7B8C9D0E1F3 /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; }; 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
@@ -158,10 +161,12 @@
97C146FD1CF9000F007C117D /* Assets.xcassets */, 97C146FD1CF9000F007C117D /* Assets.xcassets */,
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */,
97C147021CF9000F007C117D /* Info.plist */, 97C147021CF9000F007C117D /* Info.plist */,
A1B2C3D4E5F6A7B8C9D0E1F3 /* Runner.entitlements */,
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
74858FAE1ED2DC5600515810 /* AppDelegate.swift */, 74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */, 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */,
A1B2C3D4E5F6A7B8C9D0E1F1 /* CarPlaySceneDelegate.swift */,
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
); );
path = Runner; path = Runner;
@@ -382,6 +387,7 @@
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */, 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */,
A1B2C3D4E5F6A7B8C9D0E1F2 /* CarPlaySceneDelegate.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@@ -473,6 +479,7 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = SGW7CVBAMN; DEVELOPMENT_TEAM = SGW7CVBAMN;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
@@ -656,6 +663,7 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = SGW7CVBAMN; DEVELOPMENT_TEAM = SGW7CVBAMN;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
@@ -679,6 +687,7 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = SGW7CVBAMN; DEVELOPMENT_TEAM = SGW7CVBAMN;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
+113
View File
@@ -7,12 +7,14 @@ import AVKit
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate, FlutterStreamHandler { @objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate, FlutterStreamHandler {
private let castingMethodChannelName = "kryz_go_flutter/casting/methods" private let castingMethodChannelName = "kryz_go_flutter/casting/methods"
private let castingEventChannelName = "kryz_go_flutter/casting/events" private let castingEventChannelName = "kryz_go_flutter/casting/events"
private let playbackMethodChannelName = "kryz_go_flutter/playback/methods"
private var castingEventSink: FlutterEventSink? private var castingEventSink: FlutterEventSink?
private var castingStatus: [String: Any] = [ private var castingStatus: [String: Any] = [
"status": "idle", "status": "idle",
"platform": "ios" "platform": "ios"
] ]
private var playbackMethodChannel: FlutterMethodChannel?
override func application( override func application(
_ application: UIApplication, _ application: UIApplication,
@@ -60,6 +62,21 @@ import AVKit
methodChannel.setMethodCallHandler { [weak self] call, result in methodChannel.setMethodCallHandler { [weak self] call, result in
self?.handleCastingMethod(call: call, result: result) self?.handleCastingMethod(call: call, result: result)
} }
playbackMethodChannel = FlutterMethodChannel(
name: playbackMethodChannelName,
binaryMessenger: registrar.messenger()
)
}
/// Invoke play on the Flutter audio engine. Called by CarPlaySceneDelegate.
func invokePlay() {
playbackMethodChannel?.invokeMethod("play", arguments: nil)
}
/// Invoke stop on the Flutter audio engine. Called by CarPlaySceneDelegate.
func invokeStop() {
playbackMethodChannel?.invokeMethod("stop", arguments: nil)
} }
@objc private func handleAudioRouteChange() { @objc private func handleAudioRouteChange() {
@@ -161,3 +178,99 @@ import AVKit
NotificationCenter.default.removeObserver(self) NotificationCenter.default.removeObserver(self)
} }
} }
private func handleCastingMethod(call: FlutterMethodCall, result: @escaping FlutterResult) {
switch call.method {
case "getStatus":
updateCastingStatusForCurrentRoute()
result(castingStatus)
case "showDevicePicker":
showAirPlayPicker()
result(nil)
case "startCastingLiveStream":
// AirPlay route selection controls where iOS outputs audio. No explicit load call needed.
result(nil)
case "disconnect":
// iOS does not expose direct route disconnect. Users select route via picker.
showAirPlayPicker()
result(nil)
default:
result(FlutterMethodNotImplemented)
}
}
private func showAirPlayPicker() {
DispatchQueue.main.async {
guard let rootViewController = self.activeRootViewController() else {
self.emitCastingEvent(status: "error", message: "Unable to open AirPlay picker")
return
}
let routePickerView = AVRoutePickerView(frame: CGRect(x: 0, y: 0, width: 1, height: 1))
routePickerView.alpha = 0.01
rootViewController.view.addSubview(routePickerView)
for subview in routePickerView.subviews {
if let button = subview as? UIButton {
button.sendActions(for: .touchUpInside)
break
}
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) {
routePickerView.removeFromSuperview()
}
}
}
private func activeRootViewController() -> UIViewController? {
let activeScene = UIApplication.shared.connectedScenes
.first { $0.activationState == .foregroundActive } as? UIWindowScene
let keyWindow = activeScene?.windows.first { $0.isKeyWindow }
return keyWindow?.rootViewController
}
private func updateCastingStatusForCurrentRoute() {
let currentOutputs = AVAudioSession.sharedInstance().currentRoute.outputs
if let airPlayOutput = currentOutputs.first(where: { $0.portType == .airPlay }) {
castingStatus = [
"status": "connected",
"platform": "ios",
"deviceName": airPlayOutput.portName
]
} else {
castingStatus = [
"status": "idle",
"platform": "ios"
]
}
castingEventSink?(castingStatus)
}
private func emitCastingEvent(status: String, message: String) {
let event: [String: Any] = [
"status": status,
"platform": "ios",
"message": message
]
castingStatus = event
castingEventSink?(event)
}
func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? {
castingEventSink = events
events(castingStatus)
return nil
}
func onCancel(withArguments arguments: Any?) -> FlutterError? {
castingEventSink = nil
return nil
}
deinit {
NotificationCenter.default.removeObserver(self)
}
}
+85
View File
@@ -0,0 +1,85 @@
import CarPlay
import UIKit
/**
* CarPlay scene delegate for KRYZ Go!
*
* Shows a CPListTemplate with the KRYZ Live Stream item. Tapping it triggers
* playback via AppDelegate.invokePlay(), then presents CPNowPlayingTemplate.
*
* CarPlay enforces its own design system for safety colors cannot be customised.
* The app icon (which carries the KRYZ brand orange) is used as item artwork to
* provide visual continuity with the phone app.
*
* Note: the com.apple.developer.carplay-audio entitlement must be enabled in the
* Apple Developer Portal and added to the target in Xcode Signing & Capabilities.
*/
class CarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate {
private var carPlayInterfaceController: CPInterfaceController?
private var listTemplate: CPListTemplate?
// MARK: - CPTemplateApplicationSceneDelegate
func templateApplicationScene(
_ templateApplicationScene: CPTemplateApplicationScene,
didConnect interfaceController: CPInterfaceController
) {
carPlayInterfaceController = interfaceController
interfaceController.setRootTemplate(makeListTemplate(), animated: false, completion: nil)
}
func templateApplicationScene(
_ templateApplicationScene: CPTemplateApplicationScene,
didDisconnectInterfaceController interfaceController: CPInterfaceController
) {
carPlayInterfaceController = nil
listTemplate = nil
appDelegate?.invokeStop()
}
// MARK: - Template construction
private func makeListTemplate() -> CPListTemplate {
let artwork = CPListItem.maximumImageSize
let icon = UIImage(named: "AppIcon")?
.withRenderingMode(.alwaysOriginal)
let item = CPListItem(
text: "KRYZ Live Stream",
detailText: "KRYZ Radio",
image: icon
)
item.handler = { [weak self] _, completion in
self?.handleStreamItemTapped()
completion()
}
let section = CPListSection(items: [item])
let template = CPListTemplate(title: "KRYZ Go!", sections: [section])
template.tabTitle = "KRYZ Go!"
template.tabImage = icon
listTemplate = template
// Silence unused-variable warning
_ = artwork
return template
}
// MARK: - Playback
private func handleStreamItemTapped() {
appDelegate?.invokePlay()
carPlayInterfaceController?.pushTemplate(
CPNowPlayingTemplate.shared,
animated: true,
completion: nil
)
}
// MARK: - Helpers
private var appDelegate: AppDelegate? {
UIApplication.shared.delegate as? AppDelegate
}
}
+11
View File
@@ -49,6 +49,17 @@
<string>Main</string> <string>Main</string>
</dict> </dict>
</array> </array>
<key>CPTemplateApplicationSceneSessionRoleApplication</key>
<array>
<dict>
<key>UISceneClassName</key>
<string>CPTemplateApplicationScene</string>
<key>UISceneConfigurationName</key>
<string>CarPlay</string>
<key>UISceneDelegateClassName</key>
<string>$(PRODUCT_MODULE_NAME).CarPlaySceneDelegate</string>
</dict>
</array>
</dict> </dict>
</dict> </dict>
<key>UIApplicationSupportsIndirectInputEvents</key> <key>UIApplicationSupportsIndirectInputEvents</key>
+13
View File
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!--
CarPlay audio entitlement. This must also be enabled on the Apple Developer Portal
(Certificates, Identifiers & Profiles → your App ID → Capabilities → CarPlay)
and added to the target in Xcode: Signing & Capabilities → + Capability → CarPlay.
-->
<key>com.apple.developer.carplay-audio</key>
<true/>
</dict>
</plist>
+29
View File
@@ -127,6 +127,10 @@ class _MainPageState extends State<MainPage> {
return 'Stopped'; return 'Stopped';
} }
static const _playbackChannel = MethodChannel(
'kryz_go_flutter/playback/methods',
);
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@@ -134,6 +138,21 @@ class _MainPageState extends State<MainPage> {
_casting.addListener(_handleCastingStateChanged); _casting.addListener(_handleCastingStateChanged);
_liveInfo.addListener(_handleLiveInfoChanged); _liveInfo.addListener(_handleLiveInfoChanged);
_liveInfo.start(); _liveInfo.start();
_playbackChannel.setMethodCallHandler(_handlePlaybackMethodCall);
// Tell native that Flutter is ready to receive method calls.
// On Android this unblocks any pending auto_play from Android Auto.
if (defaultTargetPlatform == TargetPlatform.android) {
_playbackChannel.invokeMethod<void>('flutterReady');
}
}
Future<void> _handlePlaybackMethodCall(MethodCall call) async {
switch (call.method) {
case 'play':
await _play();
case 'stop':
await _stop();
}
} }
void _handleAudioStateChanged() { void _handleAudioStateChanged() {
@@ -144,6 +163,16 @@ class _MainPageState extends State<MainPage> {
setState(() { setState(() {
// Rebuild when playback state changes from the player. // Rebuild when playback state changes from the player.
}); });
_syncAutoPlaybackState();
}
void _syncAutoPlaybackState() {
if (defaultTargetPlatform != TargetPlatform.android) return;
_playbackChannel.invokeMethod<void>('updatePlaybackState', {
'isPlaying': _audio.isPlaying,
'isBuffering': _audio.isBuffering,
});
} }
void _handleLiveInfoChanged() { void _handleLiveInfoChanged() {
+1
View File
@@ -101,6 +101,7 @@ class StreamingAudioBusinessObject extends ChangeNotifier {
Future<void> stop() async { Future<void> stop() async {
_playRequested = false; _playRequested = false;
_isSourceLoaded = false;
_setPlaybackStatus(StreamingPlaybackStatus.stopped); _setPlaybackStatus(StreamingPlaybackStatus.stopped);
await _player.stop(); await _player.stop();
} }