diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
index ad02b52..9a4b8cf 100644
--- a/android/app/build.gradle.kts
+++ b/android/app/build.gradle.kts
@@ -9,6 +9,7 @@ dependencies {
implementation("com.google.android.gms:play-services-cast-framework:22.1.0")
implementation("androidx.mediarouter:mediarouter:1.7.0")
implementation("androidx.appcompat:appcompat:1.7.0")
+ implementation("androidx.media:media:1.7.0")
}
android {
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index a3f7be8..84c4687 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -42,12 +42,31 @@
android:name="com.google.android.gms.cast.framework.OPTIONS_PROVIDER_CLASS_NAME"
android:value="com.example.kryz_go_flutter.CastOptionsProvider" />
+
+
+
+
+
+
+
diff --git a/android/app/src/main/kotlin/com/example/kryz_go_flutter/KryzAutoMediaBrowserService.kt b/android/app/src/main/kotlin/com/example/kryz_go_flutter/KryzAutoMediaBrowserService.kt
new file mode 100644
index 0000000..046ed96
--- /dev/null
+++ b/android/app/src/main/kotlin/com/example/kryz_go_flutter/KryzAutoMediaBrowserService.kt
@@ -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>
+ ) {
+ 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()
+ }
+}
+
diff --git a/android/app/src/main/kotlin/com/example/kryz_go_flutter/MainActivity.kt b/android/app/src/main/kotlin/com/example/kryz_go_flutter/MainActivity.kt
index 368255e..896a20f 100644
--- a/android/app/src/main/kotlin/com/example/kryz_go_flutter/MainActivity.kt
+++ b/android/app/src/main/kotlin/com/example/kryz_go_flutter/MainActivity.kt
@@ -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 = 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("isPlaying") ?: false
+ val isBuffering = call.argument("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)
diff --git a/android/app/src/main/res/values/automotive_colors.xml b/android/app/src/main/res/values/automotive_colors.xml
new file mode 100644
index 0000000..59add22
--- /dev/null
+++ b/android/app/src/main/res/values/automotive_colors.xml
@@ -0,0 +1,11 @@
+
+
+
+ #E36A18
+ #B84E08
+ #1663C7
+
diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml
index b025cbf..c8a2505 100644
--- a/android/app/src/main/res/values/styles.xml
+++ b/android/app/src/main/res/values/styles.xml
@@ -16,4 +16,14 @@
- @android:color/white
- false
+
+
+
diff --git a/android/app/src/main/res/xml/automotive_app_desc.xml b/android/app/src/main/res/xml/automotive_app_desc.xml
new file mode 100644
index 0000000..a3d9fda
--- /dev/null
+++ b/android/app/src/main/res/xml/automotive_app_desc.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj
index d623726..0dfee75 100644
--- a/ios/Runner.xcodeproj/project.pbxproj
+++ b/ios/Runner.xcodeproj/project.pbxproj
@@ -14,6 +14,7 @@
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.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 */; };
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
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 = ""; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; };
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; };
+ A1B2C3D4E5F6A7B8C9D0E1F1 /* CarPlaySceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarPlaySceneDelegate.swift; sourceTree = ""; };
+ A1B2C3D4E5F6A7B8C9D0E1F3 /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = ""; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; };
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; };
@@ -158,10 +161,12 @@
97C146FD1CF9000F007C117D /* Assets.xcassets */,
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */,
97C147021CF9000F007C117D /* Info.plist */,
+ A1B2C3D4E5F6A7B8C9D0E1F3 /* Runner.entitlements */,
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */,
+ A1B2C3D4E5F6A7B8C9D0E1F1 /* CarPlaySceneDelegate.swift */,
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
);
path = Runner;
@@ -382,6 +387,7 @@
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */,
+ A1B2C3D4E5F6A7B8C9D0E1F2 /* CarPlaySceneDelegate.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -473,6 +479,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
+ CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = SGW7CVBAMN;
ENABLE_BITCODE = NO;
@@ -656,6 +663,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
+ CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = SGW7CVBAMN;
ENABLE_BITCODE = NO;
@@ -679,6 +687,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
+ CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = SGW7CVBAMN;
ENABLE_BITCODE = NO;
diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift
index 4bf13d6..7aebd5f 100644
--- a/ios/Runner/AppDelegate.swift
+++ b/ios/Runner/AppDelegate.swift
@@ -7,12 +7,14 @@ import AVKit
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate, FlutterStreamHandler {
private let castingMethodChannelName = "kryz_go_flutter/casting/methods"
private let castingEventChannelName = "kryz_go_flutter/casting/events"
+ private let playbackMethodChannelName = "kryz_go_flutter/playback/methods"
private var castingEventSink: FlutterEventSink?
private var castingStatus: [String: Any] = [
"status": "idle",
"platform": "ios"
]
+ private var playbackMethodChannel: FlutterMethodChannel?
override func application(
_ application: UIApplication,
@@ -60,6 +62,21 @@ import AVKit
methodChannel.setMethodCallHandler { [weak self] call, result in
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() {
@@ -161,3 +178,99 @@ import AVKit
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)
+ }
+}
diff --git a/ios/Runner/CarPlaySceneDelegate.swift b/ios/Runner/CarPlaySceneDelegate.swift
new file mode 100644
index 0000000..a75dcac
--- /dev/null
+++ b/ios/Runner/CarPlaySceneDelegate.swift
@@ -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
+ }
+}
diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist
index a775db4..2c003f3 100644
--- a/ios/Runner/Info.plist
+++ b/ios/Runner/Info.plist
@@ -49,6 +49,17 @@
Main
+ CPTemplateApplicationSceneSessionRoleApplication
+
+
+ UISceneClassName
+ CPTemplateApplicationScene
+ UISceneConfigurationName
+ CarPlay
+ UISceneDelegateClassName
+ $(PRODUCT_MODULE_NAME).CarPlaySceneDelegate
+
+
UIApplicationSupportsIndirectInputEvents
diff --git a/ios/Runner/Runner.entitlements b/ios/Runner/Runner.entitlements
new file mode 100644
index 0000000..88a2153
--- /dev/null
+++ b/ios/Runner/Runner.entitlements
@@ -0,0 +1,13 @@
+
+
+
+
+
+ com.apple.developer.carplay-audio
+
+
+
diff --git a/lib/main.dart b/lib/main.dart
index db2971a..6e5ea1f 100644
--- a/lib/main.dart
+++ b/lib/main.dart
@@ -127,6 +127,10 @@ class _MainPageState extends State {
return 'Stopped';
}
+ static const _playbackChannel = MethodChannel(
+ 'kryz_go_flutter/playback/methods',
+ );
+
@override
void initState() {
super.initState();
@@ -134,6 +138,21 @@ class _MainPageState extends State {
_casting.addListener(_handleCastingStateChanged);
_liveInfo.addListener(_handleLiveInfoChanged);
_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('flutterReady');
+ }
+ }
+
+ Future _handlePlaybackMethodCall(MethodCall call) async {
+ switch (call.method) {
+ case 'play':
+ await _play();
+ case 'stop':
+ await _stop();
+ }
}
void _handleAudioStateChanged() {
@@ -144,6 +163,16 @@ class _MainPageState extends State {
setState(() {
// Rebuild when playback state changes from the player.
});
+
+ _syncAutoPlaybackState();
+ }
+
+ void _syncAutoPlaybackState() {
+ if (defaultTargetPlatform != TargetPlatform.android) return;
+ _playbackChannel.invokeMethod('updatePlaybackState', {
+ 'isPlaying': _audio.isPlaying,
+ 'isBuffering': _audio.isBuffering,
+ });
}
void _handleLiveInfoChanged() {
diff --git a/lib/streaming_audio_business_object.dart b/lib/streaming_audio_business_object.dart
index 5ecf1c0..1396220 100644
--- a/lib/streaming_audio_business_object.dart
+++ b/lib/streaming_audio_business_object.dart
@@ -101,6 +101,7 @@ class StreamingAudioBusinessObject extends ChangeNotifier {
Future stop() async {
_playRequested = false;
+ _isSourceLoaded = false;
_setPlaybackStatus(StreamingPlaybackStatus.stopped);
await _player.stop();
}