From 52f5ec577bd006135a05be3b10d8921e2d8323c8 Mon Sep 17 00:00:00 2001 From: kfj001 Date: Fri, 15 May 2026 21:17:21 -0700 Subject: [PATCH 1/2] Fix: remove orphaned old class body from KryzAutoMediaBrowserService MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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> --- .../KryzAutoMediaBrowserService.kt | 361 ++++++++++++++++++ 1 file changed, 361 insertions(+) create mode 100644 android/app/src/main/kotlin/com/example/kryz_go_flutter/KryzAutoMediaBrowserService.kt 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..3e36858 --- /dev/null +++ b/android/app/src/main/kotlin/com/example/kryz_go_flutter/KryzAutoMediaBrowserService.kt @@ -0,0 +1,361 @@ +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 +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() + + 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) + } + } + + 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() + } +} + -- 2.54.0 From 83dd1a28b8e75c840910146d6974c1b685e76c22 Mon Sep 17 00:00:00 2001 From: kfj001 Date: Fri, 15 May 2026 21:30:23 -0700 Subject: [PATCH 2/2] First pass adding android auto and apple carplay support. Android auto tuned on emulator. --- android/app/build.gradle.kts | 1 + android/app/src/main/AndroidManifest.xml | 19 +++ .../KryzAutoMediaBrowserService.kt | 55 ++++----- .../example/kryz_go_flutter/MainActivity.kt | 54 +++++++++ .../src/main/res/values/automotive_colors.xml | 11 ++ android/app/src/main/res/values/styles.xml | 10 ++ .../src/main/res/xml/automotive_app_desc.xml | 4 + ios/Runner.xcodeproj/project.pbxproj | 9 ++ ios/Runner/AppDelegate.swift | 113 ++++++++++++++++++ ios/Runner/CarPlaySceneDelegate.swift | 85 +++++++++++++ ios/Runner/Info.plist | 11 ++ ios/Runner/Runner.entitlements | 13 ++ lib/main.dart | 29 +++++ lib/streaming_audio_business_object.dart | 1 + 14 files changed, 382 insertions(+), 33 deletions(-) create mode 100644 android/app/src/main/res/values/automotive_colors.xml create mode 100644 android/app/src/main/res/xml/automotive_app_desc.xml create mode 100644 ios/Runner/CarPlaySceneDelegate.swift create mode 100644 ios/Runner/Runner.entitlements 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 index 3e36858..046ed96 100644 --- 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 @@ -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( 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(); } -- 2.54.0