Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
533a3d74f3 | ||
|
|
83dd1a28b8 | ||
|
|
52f5ec577b |
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
+350
@@ -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>
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user