Compare commits

...
Author SHA1 Message Date
kfj001 c523d0121f additional carplay changes 2026-05-17 18:35:16 -07:00
kfj001 b8e86263d9 functional carplay experience in simulators 2026-05-17 18:35:01 -07:00
kfj001 e3288e044a fixes build issues related to using ios 14 apis in ios 13 project. restores runnable and listenable on ios. 2026-05-17 17:57:03 -07:00
kfj001 b8d624343b Merge pull request 'Alters the metadata sent to google cast to reflect the station rather the song when cast began.' (#8) from cast_station_name_not_current_track into main
Reviewed-on: http://192.168.1.74:30008/kfj001/kryz-go-flutter/pulls/8
2026-05-17 14:32:58 -07:00
kfj001 4c4113f66f Alters the metadata sent to google cast to reflect the station rather the song when cast began. 2026-05-17 14:32:38 -07:00
kfj001 5f3801e65f Merge pull request 'corrects problem where google cast was disconnected from player state when app launched from head unit' (#7) from fix_cast into main
Reviewed-on: http://192.168.1.74:30008/kfj001/kryz-go-flutter/pulls/7
2026-05-17 14:21:45 -07:00
kfj001 a2b5da3669 corrects problem where google cast was disconnected from player state when app launched from head unit 2026-05-16 23:45:11 -07:00
kfj001 1c7e27b83d Merge pull request 'refactors design to flutter driven service core.' (#6) from central_flutter_service into main
Reviewed-on: http://192.168.1.74:30008/kfj001/kryz-go-flutter/pulls/6
2026-05-16 21:22:16 -07:00
kfj001 df2338e5e9 higher resolution android auto logo 2026-05-16 21:21:34 -07:00
kfj001 85740b399b bug fixes for android auto 2026-05-16 21:05:34 -07:00
kfj001 5ea7442c7c refactors design to flutter driven service core. 2026-05-16 20:52:39 -07:00
kfj001 533a3d74f3 Merge pull request 'Fix: remove orphaned old class body from KryzAutoMediaBrowserService' (#5) from agents/android-auto-apple-carplay-support into main
Reviewed-on: http://192.168.1.74:30008/kfj001/kryz-go-flutter/pulls/5
2026-05-15 23:17:10 -07:00
kfj001 065d3c8e47 Merge pull request 'Fixes bug where canceling cast dialog would lockout cast button' (#4) from bug_fix_cast_button into main
Reviewed-on: http://192.168.1.74:30008/kfj001/kryz-go-flutter/pulls/4
2026-05-15 23:14:37 -07:00
13 changed files with 1446 additions and 463 deletions
+5
View File
@@ -0,0 +1,5 @@
{
"chat.tools.terminal.autoApprove": {
"flutter": true
}
}
@@ -3,36 +3,31 @@ 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.content.Intent
import android.os.Handler
import android.os.Looper
import android.support.v4.media.MediaBrowserCompat
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.MediaControllerCompat
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
import com.ryanheise.audioservice.AudioService
/**
* 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.
* Android Auto connects to this service to browse content and send transport
* controls. Playback execution is delegated to Flutter's audio_service session.
*
* Brand color (#E36A18) is declared on this service via KryzAutoTheme in styles.xml
* so Android Auto accents its media UI with the KRYZ orange.
@@ -46,45 +41,55 @@ class KryzAutoMediaBrowserService : MediaBrowserServiceCompat() {
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 var audioServiceBrowser: MediaBrowserCompat? = null
private var audioServiceController: MediaControllerCompat? = null
private val mainHandler = Handler(Looper.getMainLooper())
private var pendingPlayRequest: Boolean = false
private var pendingStopRequest: Boolean = false
private var reconnectScheduled: Boolean = false
private val mediaControllerCallback = object : MediaControllerCompat.Callback() {
override fun onPlaybackStateChanged(state: PlaybackStateCompat?) {
applyPlaybackStateFromFlutter(state)
}
override fun onMetadataChanged(metadata: MediaMetadataCompat?) {
applyNowPlayingMetadataFromFlutter(metadata)
}
}
private val browserConnectionCallback = object : MediaBrowserCompat.ConnectionCallback() {
override fun onConnected() {
val browser = audioServiceBrowser ?: return
try {
val controller = MediaControllerCompat(this@KryzAutoMediaBrowserService, browser.sessionToken)
audioServiceController?.unregisterCallback(mediaControllerCallback)
audioServiceController = controller
controller.registerCallback(mediaControllerCallback)
applyPlaybackStateFromFlutter(controller.playbackState)
applyNowPlayingMetadataFromFlutter(controller.metadata)
consumePendingTransportCommands()
Log.d(TAG, "Connected to Flutter audio_service session")
} catch (error: Exception) {
Log.e(TAG, "Unable to bind media controller: ${error.message}")
}
}
override fun onConnectionSuspended() {
Log.w(TAG, "audio_service browser connection suspended")
handleControllerDisconnected()
scheduleBrowserReconnect()
}
override fun onConnectionFailed() {
Log.e(TAG, "audio_service browser connection failed")
handleControllerDisconnected()
scheduleBrowserReconnect()
}
}
private val mediaSessionCallback = object : MediaSessionCompat.Callback() {
override fun onPlay() {
@@ -94,12 +99,7 @@ class KryzAutoMediaBrowserService : MediaBrowserServiceCompat() {
override fun onStop() {
Log.d(TAG, "onStop()")
val stop = directStopCallback
if (stop != null) {
stop()
} else {
stopServicePlayback()
}
dispatchStopCommand()
}
override fun onPlayFromMediaId(mediaId: String?, extras: Bundle?) {
@@ -114,20 +114,11 @@ class KryzAutoMediaBrowserService : MediaBrowserServiceCompat() {
}
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()
}
dispatchPlayCommand()
}
override fun onCreate() {
super.onCreate()
instance = this
Log.d(TAG, "onCreate()")
createNotificationChannel()
@@ -154,6 +145,7 @@ class KryzAutoMediaBrowserService : MediaBrowserServiceCompat() {
}
sessionToken = mediaSession.sessionToken
Log.d(TAG, "MediaSession created, sessionToken set")
connectToFlutterAudioService()
}
override fun onGetRoot(
@@ -187,76 +179,35 @@ class KryzAutoMediaBrowserService : MediaBrowserServiceCompat() {
override fun onDestroy() {
Log.d(TAG, "onDestroy()")
stopServicePlayback()
instance = null
stopFlutterPlaybackForHeadUnitDisconnect()
disconnectFromFlutterAudioService()
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)
}
override fun onTaskRemoved(rootIntent: Intent?) {
Log.d(TAG, "onTaskRemoved()")
stopFlutterPlaybackForHeadUnitDisconnect()
super.onTaskRemoved(rootIntent)
}
private fun stopServicePlayback() {
val player = serviceMediaPlayer ?: return
Log.d(TAG, "stopServicePlayback()")
serviceMediaPlayer = null
override fun onUnbind(intent: Intent?): Boolean {
Log.d(TAG, "onUnbind()")
stopFlutterPlaybackForHeadUnitDisconnect()
return super.onUnbind(intent)
}
private fun stopFlutterPlaybackForHeadUnitDisconnect() {
pendingPlayRequest = false
pendingStopRequest = false
try {
if (player.isPlaying) player.stop()
player.release()
} catch (e: Exception) {
Log.e(TAG, "Error releasing MediaPlayer: ${e.message}")
audioServiceController?.transportControls?.stop()
} catch (error: Exception) {
Log.w(TAG, "Unable to stop Flutter audio on head unit disconnect: ${error.message}")
}
abandonAudioFocus()
applyPlaybackState(isPlaying = false, isBuffering = false)
@Suppress("DEPRECATION")
stopForeground(true)
}
private fun applyPlaybackState(isPlaying: Boolean, isBuffering: Boolean) {
@@ -279,34 +230,66 @@ class KryzAutoMediaBrowserService : MediaBrowserServiceCompat() {
.setState(state, PlaybackStateCompat.PLAYBACK_POSITION_UNKNOWN, 1f)
.build()
)
startForeground(NOTIFICATION_ID, buildNotification(isPlaying = isPlaying, isBuffering = isBuffering))
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 dispatchPlayCommand() {
val controller = audioServiceController
if (controller != null) {
pendingPlayRequest = false
pendingStopRequest = false
applyPlaybackState(isPlaying = false, isBuffering = true)
controller.transportControls.play()
return
}
pendingStopRequest = false
pendingPlayRequest = true
applyPlaybackState(isPlaying = false, isBuffering = true)
connectToFlutterAudioService()
}
private fun dispatchStopCommand() {
val controller = audioServiceController
if (controller != null) {
pendingPlayRequest = false
pendingStopRequest = false
controller.transportControls.stop()
return
}
pendingPlayRequest = false
pendingStopRequest = true
applyPlaybackState(isPlaying = false, isBuffering = false)
connectToFlutterAudioService()
}
private fun consumePendingTransportCommands() {
val controller = audioServiceController ?: return
if (pendingStopRequest) {
pendingStopRequest = false
controller.transportControls.stop()
}
if (pendingPlayRequest) {
pendingPlayRequest = false
controller.transportControls.play()
}
}
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 applyPlaybackStateFromFlutter(state: PlaybackStateCompat?) {
if (state == null) {
applyPlaybackState(isPlaying = false, isBuffering = false)
return
}
val isPlaying = state.state == PlaybackStateCompat.STATE_PLAYING
val isBuffering = state.state == PlaybackStateCompat.STATE_BUFFERING ||
state.state == PlaybackStateCompat.STATE_CONNECTING
applyPlaybackState(isPlaying = isPlaying, isBuffering = isBuffering)
}
private fun createNotificationChannel() {
@@ -324,16 +307,126 @@ class KryzAutoMediaBrowserService : MediaBrowserServiceCompat() {
}
}
private fun buildNotification(isPlaying: Boolean): Notification {
private fun buildNotification(isPlaying: Boolean, isBuffering: Boolean): Notification {
val statusText = when {
isPlaying -> "Playing from Flutter audio handler"
isBuffering -> "Connecting via Flutter audio handler..."
else -> "Stopped"
}
return NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
.setContentTitle("KRYZ Radio")
.setContentText(if (isPlaying) "Playing via Android Auto" else "Connecting…")
.setContentText(statusText)
.setSmallIcon(R.mipmap.ic_launcher)
.setOngoing(isPlaying)
.setOngoing(isPlaying || isBuffering)
.setSilent(true)
.build()
}
private fun applyNowPlayingMetadata(
title: String?,
subtitle: String?,
album: String?,
mediaUri: String?,
) {
if (!::mediaSession.isInitialized) return
val iconUri = Uri.parse("android.resource://${packageName}/drawable/kryz_auto_art")
mediaSession.setMetadata(
MediaMetadataCompat.Builder()
.putString(MediaMetadataCompat.METADATA_KEY_TITLE, title ?: "KRYZ Live Stream")
.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, subtitle ?: "KRYZ Radio")
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM, album ?: "Live")
.putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, MEDIA_ID_LIVE)
.putString(MediaMetadataCompat.METADATA_KEY_MEDIA_URI, mediaUri ?: STREAM_URL)
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, iconUri.toString())
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI, iconUri.toString())
.build()
)
}
private fun applyNowPlayingMetadataFromFlutter(metadata: MediaMetadataCompat?) {
if (metadata == null) {
applyNowPlayingMetadata(
title = "KRYZ Live Stream",
subtitle = "KRYZ Radio",
album = "Live",
mediaUri = STREAM_URL,
)
return
}
applyNowPlayingMetadata(
title = metadata.getString(MediaMetadataCompat.METADATA_KEY_TITLE),
subtitle = metadata.getString(MediaMetadataCompat.METADATA_KEY_ARTIST),
album = metadata.getString(MediaMetadataCompat.METADATA_KEY_ALBUM),
mediaUri = metadata.getString(MediaMetadataCompat.METADATA_KEY_MEDIA_URI),
)
}
private fun connectToFlutterAudioService() {
if (audioServiceController != null) {
return
}
val existing = audioServiceBrowser
if (existing?.isConnected == true) {
return
}
if (existing != null) {
try {
existing.connect()
} catch (error: Exception) {
Log.w(TAG, "Failed reconnect attempt: ${error.message}")
}
return
}
val browser = MediaBrowserCompat(
this,
ComponentName(this, AudioService::class.java),
browserConnectionCallback,
null,
)
audioServiceBrowser = browser
browser.connect()
}
private fun disconnectFromFlutterAudioService() {
reconnectScheduled = false
mainHandler.removeCallbacksAndMessages(null)
handleControllerDisconnected()
audioServiceBrowser?.let { browser ->
if (browser.isConnected) {
browser.disconnect()
}
}
audioServiceBrowser = null
}
private fun handleControllerDisconnected() {
audioServiceController?.unregisterCallback(mediaControllerCallback)
audioServiceController = null
}
private fun scheduleBrowserReconnect() {
if (reconnectScheduled) {
return
}
reconnectScheduled = true
mainHandler.postDelayed(
{
reconnectScheduled = false
connectToFlutterAudioService()
},
1200,
)
}
private fun buildLiveStreamMetadata(): MediaMetadataCompat {
val iconUri = Uri.parse("android.resource://${packageName}/mipmap/ic_launcher")
return MediaMetadataCompat.Builder()
@@ -27,17 +27,26 @@ 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 const val MAX_PENDING_CAST_AUTOPLAY_RETRIES = 5
private const val REMOTE_STATUS_POLL_INTERVAL_MS = 500L
}
private data class PendingCastRequest(
val streamUrl: String,
val title: String,
val subtitle: String,
)
private var castContext: CastContext? = null
private var methodChannel: MethodChannel? = null
private var eventSink: EventChannel.EventSink? = null
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 pendingCastRequest: PendingCastRequest? = null
private var pendingCastRequestInFlight: Boolean = false
private var pendingCastAutoplayRetryCount: Int = 0
private var remoteCastStatusPollingRunnable: Runnable? = null
private var statusPayload: MutableMap<String, Any?> = mutableMapOf(
"status" to "idle",
@@ -62,10 +71,13 @@ class MainActivity : AudioServiceActivity(), MethodChannel.MethodCallHandler, Ev
override fun onSessionStarted(session: CastSession, sessionId: String) {
attachRemoteClientCallback(session)
startRemoteStatusPolling()
emitStatus("connected", session.castDevice?.friendlyName)
startPendingCastRequest(session)
}
override fun onSessionStartFailed(session: CastSession, error: Int) {
clearPendingCastRequest()
emitStatus("error", message = "Cast session failed to start ($error)")
}
@@ -75,6 +87,10 @@ class MainActivity : AudioServiceActivity(), MethodChannel.MethodCallHandler, Ev
}
override fun onSessionEnded(session: CastSession, error: Int) {
stopRemoteStatusPolling()
clearPendingCastRequest()
// If app was launched from Android Auto, restart audio service on cast disconnect
restartAudioServiceSession()
emitStatus("idle")
}
@@ -84,14 +100,18 @@ class MainActivity : AudioServiceActivity(), MethodChannel.MethodCallHandler, Ev
override fun onSessionResumed(session: CastSession, wasSuspended: Boolean) {
attachRemoteClientCallback(session)
startRemoteStatusPolling()
emitStatus("connected", session.castDevice?.friendlyName)
startPendingCastRequest(session)
}
override fun onSessionResumeFailed(session: CastSession, error: Int) {
clearPendingCastRequest()
emitStatus("error", message = "Cast session failed to resume ($error)")
}
override fun onSessionSuspended(session: CastSession, reason: Int) {
stopRemoteStatusPolling()
detachRemoteClientCallback()
emitStatus("idle")
}
@@ -100,44 +120,12 @@ class MainActivity : AudioServiceActivity(), MethodChannel.MethodCallHandler, Ev
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, METHOD_CHANNEL)
.setMethodCallHandler(this)
methodChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, METHOD_CHANNEL)
.apply { setMethodCallHandler(this@MainActivity) }
EventChannel(flutterEngine.dartExecutor.binaryMessenger, EVENT_CHANNEL)
.setStreamHandler(this)
playbackMethodChannel = MethodChannel(
flutterEngine.dartExecutor.binaryMessenger,
PLAYBACK_METHOD_CHANNEL,
)
playbackMethodChannel?.setMethodCallHandler { call, result ->
when (call.method) {
"flutterReady" -> {
flutterReady = true
// Stop the service's MediaPlayer and hand off to Flutter.
val wasServicePlaying = KryzAutoMediaBrowserService.isServicePlaying()
KryzAutoMediaBrowserService.stopServicePlayer()
KryzAutoMediaBrowserService.registerDirectCallbacks(
play = { mainHandler.post { invokeFlutterPlay() } },
stop = { mainHandler.post { playbackMethodChannel?.invokeMethod("stop", null) } },
)
if (pendingAutoPlay || wasServicePlaying) {
pendingAutoPlay = false
invokeFlutterPlay()
}
result.success(null)
}
"updatePlaybackState" -> {
val isPlaying = call.argument<Boolean>("isPlaying") ?: false
val isBuffering = call.argument<Boolean>("isBuffering") ?: false
KryzAutoMediaBrowserService.updatePlaybackState(isPlaying, isBuffering)
result.success(null)
}
else -> result.notImplemented()
}
}
initializeCastContext()
}
@@ -150,13 +138,14 @@ class MainActivity : AudioServiceActivity(), MethodChannel.MethodCallHandler, Ev
when (call.method) {
"getStatus" -> result.success(statusPayload)
"showDevicePicker" -> {
showDevicePicker()
showDevicePicker(call)
result.success(null)
}
"startCastingLiveStream" -> {
startCastingLiveStream(call, result)
}
"disconnect" -> {
clearPendingCastRequest()
castContext?.sessionManager?.endCurrentSession(true)
emitStatus("idle")
result.success(null)
@@ -181,8 +170,6 @@ class MainActivity : AudioServiceActivity(), MethodChannel.MethodCallHandler, Ev
chooserDialog?.dismiss()
chooserDialog = null
KryzAutoMediaBrowserService.unregisterDirectCallbacks()
castContext?.sessionManager?.removeSessionManagerListener(
castSessionManagerListener,
CastSession::class.java,
@@ -192,16 +179,6 @@ 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)
@@ -222,12 +199,15 @@ class MainActivity : AudioServiceActivity(), MethodChannel.MethodCallHandler, Ev
}
}
private fun showDevicePicker() {
private fun showDevicePicker(call: MethodCall) {
if (castContext == null) {
clearPendingCastRequest()
emitStatus("unavailable", message = "Cast services unavailable")
return
}
setPendingCastRequest(parsePendingCastRequest(call.arguments))
val receiverAppId = getString(R.string.cast_receiver_app_id)
val routeSelector = MediaRouteSelector.Builder()
.addControlCategory(CastMediaControlIntent.categoryForCast(receiverAppId))
@@ -258,6 +238,7 @@ class MainActivity : AudioServiceActivity(), MethodChannel.MethodCallHandler, Ev
val currentStatus = statusPayload["status"] as? String
if (currentSession == null && currentStatus == "connecting") {
clearPendingCastRequest()
emitStatus("idle")
}
}
@@ -270,8 +251,8 @@ class MainActivity : AudioServiceActivity(), MethodChannel.MethodCallHandler, Ev
}
val streamUrl = arguments["streamUrl"] as? String
val title = arguments["title"] as? String ?: "Live Stream"
val subtitle = arguments["subtitle"] as? String ?: ""
val title = "KRYZ 98.5 LPFM Mariposa Community Radio"
val subtitle = "Live Stream"
if (streamUrl.isNullOrBlank()) {
result.error("invalid_stream_url", "streamUrl is required", null)
@@ -284,6 +265,113 @@ class MainActivity : AudioServiceActivity(), MethodChannel.MethodCallHandler, Ev
return
}
val loadRequestData = buildLoadRequestData(
streamUrl = streamUrl,
title = title,
subtitle = subtitle,
)
loadMediaWithRetry(
session = currentSession,
loadRequestData = loadRequestData,
attempt = 1,
result = result,
)
}
private fun setPendingCastRequest(request: PendingCastRequest?) {
pendingCastRequest = request
pendingCastRequestInFlight = false
pendingCastAutoplayRetryCount = 0
}
private fun clearPendingCastRequest() {
setPendingCastRequest(null)
}
private fun parsePendingCastRequest(arguments: Any?): PendingCastRequest? {
val map = arguments as? Map<*, *> ?: return null
val shouldAutoplay = map["autoplay"] as? Boolean ?: false
if (!shouldAutoplay) {
return null
}
val streamUrl = map["streamUrl"] as? String
if (streamUrl.isNullOrBlank()) {
return null
}
return PendingCastRequest(
streamUrl = streamUrl,
title = map["title"] as? String ?: "Live Stream",
subtitle = map["subtitle"] as? String ?: "",
)
}
private fun startPendingCastRequest(session: CastSession) {
val request = pendingCastRequest ?: return
if (pendingCastRequestInFlight) {
return
}
pendingCastRequestInFlight = true
loadMediaWithRetry(
session = session,
loadRequestData = buildLoadRequestData(
streamUrl = request.streamUrl,
title = request.title,
subtitle = request.subtitle,
),
attempt = 1,
result = object : MethodChannel.Result {
override fun success(result: Any?) {
clearPendingCastRequest()
}
override fun error(errorCode: String, errorMessage: String?, errorDetails: Any?) {
pendingCastRequestInFlight = false
schedulePendingCastRequestRetry(errorMessage ?: errorCode)
}
override fun notImplemented() {}
},
)
}
private fun schedulePendingCastRequestRetry(message: String) {
val request = pendingCastRequest
val currentSession = castContext?.sessionManager?.currentCastSession
if (request == null || currentSession == null || !currentSession.isConnected) {
clearPendingCastRequest()
emitStatus("error", message = message)
return
}
if (pendingCastAutoplayRetryCount >= MAX_PENDING_CAST_AUTOPLAY_RETRIES) {
clearPendingCastRequest()
emitStatus("error", message = message)
return
}
pendingCastAutoplayRetryCount += 1
mainHandler.postDelayed(
{
val resumedSession = castContext?.sessionManager?.currentCastSession
if (resumedSession != null && resumedSession.isConnected) {
startPendingCastRequest(resumedSession)
} else {
clearPendingCastRequest()
}
},
1000,
)
}
private fun buildLoadRequestData(
streamUrl: String,
title: String,
subtitle: String,
): MediaLoadRequestData {
val metadata = MediaMetadata(MediaMetadata.MEDIA_TYPE_MUSIC_TRACK).apply {
putString(MediaMetadata.KEY_TITLE, title)
putString(MediaMetadata.KEY_SUBTITLE, subtitle)
@@ -295,17 +383,10 @@ class MainActivity : AudioServiceActivity(), MethodChannel.MethodCallHandler, Ev
.setMetadata(metadata)
.build()
val loadRequestData = MediaLoadRequestData.Builder()
return MediaLoadRequestData.Builder()
.setMediaInfo(mediaInfo)
.setAutoplay(true)
.build()
loadMediaWithRetry(
session = currentSession,
loadRequestData = loadRequestData,
attempt = 1,
result = result,
)
}
private fun loadMediaWithRetry(
@@ -340,14 +421,14 @@ class MainActivity : AudioServiceActivity(), MethodChannel.MethodCallHandler, Ev
try {
remoteMediaClient.load(loadRequestData).setResultCallback { mediaChannelResult ->
if (mediaChannelResult == null) {
emitStatus("error", message = "Cast load returned no result")
result.error("cast_load_failed", "Cast load returned no result", null)
return@setResultCallback
}
if (mediaChannelResult.status.isSuccess) {
// Stop local playback immediately when remote load succeeds
// This MUST be done in Kotlin (not Flutter) because the audio service
// session may be active from Android Auto and won't respond to Flutter calls
stopAudioServiceSession()
emitStatus("connected", session.castDevice?.friendlyName)
// Force initial remote status update and continue polling
emitRemotePlaybackStatus()
result.success(null)
return@setResultCallback
@@ -379,11 +460,6 @@ class MainActivity : AudioServiceActivity(), MethodChannel.MethodCallHandler, Ev
try {
remoteMediaClient.stop().setResultCallback { stopResult ->
if (stopResult == null) {
result.error("cast_stop_failed", "Cast stop returned no result", null)
return@setResultCallback
}
if (stopResult.status.isSuccess) {
emitStatus("connected", session.castDevice?.friendlyName, playbackStatus = "stopped")
result.success(null)
@@ -414,22 +490,59 @@ class MainActivity : AudioServiceActivity(), MethodChannel.MethodCallHandler, Ev
detachRemoteClientCallback()
activeRemoteMediaClient = remoteMediaClient
remoteMediaClient.registerCallback(remoteMediaClientCallback)
startRemoteStatusPolling()
if (pendingCastRequest != null) {
startPendingCastRequest(session)
}
emitRemotePlaybackStatus()
}
private fun detachRemoteClientCallback() {
stopRemoteStatusPolling()
activeRemoteMediaClient?.unregisterCallback(remoteMediaClientCallback)
activeRemoteMediaClient = null
}
private fun startRemoteStatusPolling() {
if (remoteCastStatusPollingRunnable != null) {
return
}
val pollRunnable = object : Runnable {
override fun run() {
try {
if (activeRemoteMediaClient != null) {
emitRemotePlaybackStatus()
// Use mainHandler to keep polling alive across lifecycle changes
mainHandler.postDelayed(this, REMOTE_STATUS_POLL_INTERVAL_MS)
}
} catch (e: Exception) {
// Safely handle any errors to keep polling thread alive
e.printStackTrace()
}
}
}
remoteCastStatusPollingRunnable = pollRunnable
mainHandler.post(pollRunnable)
}
private fun stopRemoteStatusPolling() {
val runnable = remoteCastStatusPollingRunnable ?: return
mainHandler.removeCallbacks(runnable)
remoteCastStatusPollingRunnable = null
}
private fun emitRemotePlaybackStatus() {
val remoteMediaClient = activeRemoteMediaClient
val mediaStatus = remoteMediaClient?.mediaStatus
if (remoteMediaClient == null) return
val mediaStatus = remoteMediaClient.mediaStatus
val playbackStatus = when (mediaStatus?.playerState) {
MediaStatus.PLAYER_STATE_PLAYING -> "playing"
MediaStatus.PLAYER_STATE_BUFFERING,
MediaStatus.PLAYER_STATE_LOADING -> "buffering"
MediaStatus.PLAYER_STATE_PAUSED,
MediaStatus.PLAYER_STATE_PAUSED -> "stopped"
MediaStatus.PLAYER_STATE_IDLE,
MediaStatus.PLAYER_STATE_UNKNOWN,
null -> "stopped"
@@ -442,6 +555,40 @@ class MainActivity : AudioServiceActivity(), MethodChannel.MethodCallHandler, Ev
}
}
private fun stopLocalAudioPlayback() {
try {
// Tell Flutter side to stop local audio via method channel
// This prevents audio from playing on both mobile and cast device
methodChannel?.invokeMethod("stopLocalAudio", null)
} catch (e: Exception) {
// Silently handle if Flutter side doesn't implement this method
e.printStackTrace()
}
}
private fun stopAudioServiceSession() {
try {
// Stop the audio service session directly (works even if app is in background via Android Auto)
// This ensures the shared audio handler doesn't keep playing when cast device takes over
// Access the audio service through the session token if available
methodChannel?.invokeMethod("stopAudioService", null)
} catch (e: Exception) {
// Audio service might not be available, silently continue
e.printStackTrace()
}
}
private fun restartAudioServiceSession() {
try {
// When cast session ends, allow the audio service to resume management
// This re-enables local playback for the Flutter side
methodChannel?.invokeMethod("resumeLocalAudio", null)
} catch (e: Exception) {
// If Flutter handler isn't ready, that's ok - audio service will be managed externally
e.printStackTrace()
}
}
private fun emitStatus(
status: String,
deviceName: String? = null,
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

+252 -96
View File
@@ -2,12 +2,76 @@ import Flutter
import UIKit
import AVFoundation
import AVKit
import MediaPlayer
private struct CarPlayPlaybackSnapshot {
var playbackStatus: String
var title: String
var subtitle: String
var album: String
var isLive: Bool
var seekable: Bool
static let `default` = CarPlayPlaybackSnapshot(
playbackStatus: "stopped",
title: "KRYZ Live Stream",
subtitle: "KRYZ Radio",
album: "Live",
isLive: true,
seekable: false
)
init(
playbackStatus: String,
title: String,
subtitle: String,
album: String,
isLive: Bool,
seekable: Bool
) {
self.playbackStatus = playbackStatus
self.title = title
self.subtitle = subtitle
self.album = album
self.isLive = isLive
self.seekable = seekable
}
init(payload: [String: Any], fallback: CarPlayPlaybackSnapshot) {
let status = (payload["playbackStatus"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
let title = (payload["title"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
let subtitle = (payload["subtitle"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
let album = (payload["album"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
playbackStatus = (status?.isEmpty == false) ? status! : fallback.playbackStatus
self.title = (title?.isEmpty == false) ? title! : fallback.title
self.subtitle = (subtitle?.isEmpty == false) ? subtitle! : fallback.subtitle
self.album = (album?.isEmpty == false) ? album! : fallback.album
isLive = payload["isLive"] as? Bool ?? fallback.isLive
seekable = payload["seekable"] as? Bool ?? fallback.seekable
}
func asDictionary() -> [String: Any] {
[
"playbackStatus": playbackStatus,
"title": title,
"subtitle": subtitle,
"album": album,
"isLive": isLive,
"seekable": seekable
]
}
}
@main
@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 let carPlayMethodChannelName = "kryz_go_flutter/carplay/methods"
static let carPlaySnapshotDidChangeNotification = Notification.Name("KRYZCarPlaySnapshotDidChange")
static let carPlaySnapshotUserInfoKey = "snapshot"
private var castingEventSink: FlutterEventSink?
private var castingStatus: [String: Any] = [
@@ -15,6 +79,13 @@ import AVKit
"platform": "ios"
]
private var playbackMethodChannel: FlutterMethodChannel?
private var carPlayMethodChannel: FlutterMethodChannel?
private var carPlaySnapshot = CarPlayPlaybackSnapshot.default
private var headlessFlutterEngine: FlutterEngine?
private var playCommandTarget: Any?
private var togglePlayPauseCommandTarget: Any?
private var pauseCommandTarget: Any?
private var stopCommandTarget: Any?
override func application(
_ application: UIApplication,
@@ -37,14 +108,67 @@ import AVKit
)
updateCastingStatusForCurrentRoute()
ensureFlutterBridgeReadyForCarPlay()
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
configureChannels(with: engineBridge.pluginRegistry)
guard let registrar = engineBridge.pluginRegistry.registrar(forPlugin: "CastingBridge") else {
if let engine = headlessFlutterEngine {
engine.destroyContext()
headlessFlutterEngine = nil
}
}
/// Invoke play on the Flutter audio engine. Called by CarPlaySceneDelegate.
func invokePlay() {
ensureFlutterBridgeReadyForCarPlay()
playbackMethodChannel?.invokeMethod("play", arguments: nil)
}
/// Invoke stop on the Flutter audio engine. Called by CarPlaySceneDelegate.
func invokeStop() {
ensureFlutterBridgeReadyForCarPlay()
playbackMethodChannel?.invokeMethod("stop", arguments: nil)
}
func currentCarPlaySnapshot() -> [String: Any] {
carPlaySnapshot.asDictionary()
}
func requestCarPlaySnapshot() {
ensureFlutterBridgeReadyForCarPlay()
carPlayMethodChannel?.invokeMethod("getStateSnapshot", arguments: nil) { [weak self] response in
guard let payload = response as? [String: Any] else {
return
}
self?.applyCarPlaySnapshot(payload)
}
}
func ensureFlutterBridgeReadyForCarPlay() {
if playbackMethodChannel != nil, carPlayMethodChannel != nil {
return
}
if let engine = headlessFlutterEngine {
configureChannels(with: engine)
return
}
let engine = FlutterEngine(name: "kryz_carplay_headless_engine", project: nil, allowHeadlessExecution: true)
headlessFlutterEngine = engine
engine.run()
GeneratedPluginRegistrant.register(with: engine)
configureChannels(with: engine)
}
private func configureChannels(with registry: FlutterPluginRegistry) {
guard let registrar = registry.registrar(forPlugin: "CastingBridge") else {
return
}
@@ -67,16 +191,17 @@ import AVKit
name: playbackMethodChannelName,
binaryMessenger: registrar.messenger()
)
}
/// Invoke play on the Flutter audio engine. Called by CarPlaySceneDelegate.
func invokePlay() {
playbackMethodChannel?.invokeMethod("play", arguments: nil)
}
carPlayMethodChannel = FlutterMethodChannel(
name: carPlayMethodChannelName,
binaryMessenger: registrar.messenger()
)
carPlayMethodChannel?.setMethodCallHandler { [weak self] call, result in
self?.handleCarPlayMethod(call: call, result: result)
}
/// Invoke stop on the Flutter audio engine. Called by CarPlaySceneDelegate.
func invokeStop() {
playbackMethodChannel?.invokeMethod("stop", arguments: nil)
configureRemoteCommandCenterIfNeeded()
requestCarPlaySnapshot()
}
@objc private func handleAudioRouteChange() {
@@ -103,102 +228,132 @@ import AVKit
}
}
private func showAirPlayPicker() {
DispatchQueue.main.async {
guard let rootViewController = self.activeRootViewController() else {
self.emitCastingEvent(status: "error", message: "Unable to open AirPlay picker")
private func handleCarPlayMethod(call: FlutterMethodCall, result: @escaping FlutterResult) {
switch call.method {
case "syncState":
guard let payload = call.arguments as? [String: Any] else {
result(
FlutterError(
code: "invalid_payload",
message: "Expected map payload for syncState",
details: nil
)
)
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)
}
}
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()
applyCarPlaySnapshot(payload)
result(nil)
case "getCachedState":
result(carPlaySnapshot.asDictionary())
default:
result(FlutterMethodNotImplemented)
}
}
private func applyCarPlaySnapshot(_ payload: [String: Any]) {
carPlaySnapshot = CarPlayPlaybackSnapshot(payload: payload, fallback: carPlaySnapshot)
updateNowPlayingInfoCenter(using: carPlaySnapshot)
NotificationCenter.default.post(
name: Self.carPlaySnapshotDidChangeNotification,
object: self,
userInfo: [Self.carPlaySnapshotUserInfoKey: carPlaySnapshot.asDictionary()]
)
}
private func updateNowPlayingInfoCenter(using snapshot: CarPlayPlaybackSnapshot) {
var nowPlayingInfo = MPNowPlayingInfoCenter.default().nowPlayingInfo ?? [:]
nowPlayingInfo[MPMediaItemPropertyTitle] = snapshot.title
nowPlayingInfo[MPMediaItemPropertyArtist] = snapshot.subtitle
nowPlayingInfo[MPMediaItemPropertyAlbumTitle] = snapshot.album
nowPlayingInfo[MPNowPlayingInfoPropertyIsLiveStream] = snapshot.isLive
nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = snapshot.playbackStatus == "playing" ? 1.0 : 0.0
nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = 0.0
MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
if #available(iOS 13.0, *) {
let playbackState: MPNowPlayingPlaybackState = switch snapshot.playbackStatus {
case "playing":
.playing
case "buffering":
.playing
default:
.stopped
}
MPNowPlayingInfoCenter.default().playbackState = playbackState
}
}
private func configureRemoteCommandCenterIfNeeded() {
if playCommandTarget != nil ||
togglePlayPauseCommandTarget != nil ||
pauseCommandTarget != nil ||
stopCommandTarget != nil {
return
}
let commandCenter = MPRemoteCommandCenter.shared()
commandCenter.changePlaybackPositionCommand.isEnabled = false
commandCenter.seekForwardCommand.isEnabled = false
commandCenter.seekBackwardCommand.isEnabled = false
commandCenter.nextTrackCommand.isEnabled = false
commandCenter.previousTrackCommand.isEnabled = false
commandCenter.playCommand.isEnabled = true
playCommandTarget = commandCenter.playCommand.addTarget { [weak self] _ in
self?.invokePlay()
return .success
}
commandCenter.togglePlayPauseCommand.isEnabled = true
togglePlayPauseCommandTarget = commandCenter.togglePlayPauseCommand.addTarget { [weak self] _ in
guard let self else {
return .commandFailed
}
switch self.carPlaySnapshot.playbackStatus {
case "playing", "buffering":
self.invokeStop()
default:
self.invokePlay()
}
return .success
}
commandCenter.pauseCommand.isEnabled = true
pauseCommandTarget = commandCenter.pauseCommand.addTarget { [weak self] _ in
self?.invokeStop()
return .success
}
commandCenter.stopCommand.isEnabled = true
stopCommandTarget = commandCenter.stopCommand.addTarget { [weak self] _ in
self?.invokeStop()
return .success
}
}
private func teardownRemoteCommandCenter() {
let commandCenter = MPRemoteCommandCenter.shared()
if let target = playCommandTarget {
commandCenter.playCommand.removeTarget(target)
}
if let target = togglePlayPauseCommandTarget {
commandCenter.togglePlayPauseCommand.removeTarget(target)
}
if let target = pauseCommandTarget {
commandCenter.pauseCommand.removeTarget(target)
}
if let target = stopCommandTarget {
commandCenter.stopCommand.removeTarget(target)
}
playCommandTarget = nil
togglePlayPauseCommandTarget = nil
pauseCommandTarget = nil
stopCommandTarget = nil
}
private func showAirPlayPicker() {
DispatchQueue.main.async {
guard let rootViewController = self.activeRootViewController() else {
@@ -272,5 +427,6 @@ import AVKit
deinit {
NotificationCenter.default.removeObserver(self)
teardownRemoteCommandCenter()
}
}
+156 -17
View File
@@ -14,10 +14,46 @@ import UIKit
* 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 {
class CarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate, CPListTemplateDelegate {
private struct Snapshot {
var playbackStatus: String
var title: String
var subtitle: String
var album: String
init(playbackStatus: String, title: String, subtitle: String, album: String) {
self.playbackStatus = playbackStatus
self.title = title
self.subtitle = subtitle
self.album = album
}
static let `default` = Snapshot(
playbackStatus: "stopped",
title: "KRYZ Live Stream",
subtitle: "KRYZ Radio",
album: "Live"
)
init(payload: [String: Any]?) {
let base = Snapshot.default
let status = (payload?["playbackStatus"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
let title = (payload?["title"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
let subtitle = (payload?["subtitle"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
let album = (payload?["album"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
playbackStatus = (status?.isEmpty == false) ? status! : base.playbackStatus
self.title = (title?.isEmpty == false) ? title! : base.title
self.subtitle = (subtitle?.isEmpty == false) ? subtitle! : base.subtitle
self.album = (album?.isEmpty == false) ? album! : base.album
}
}
private var carPlayInterfaceController: CPInterfaceController?
private var listTemplate: CPListTemplate?
private var streamItem: CPListItem?
private var snapshot: Snapshot = .default
// MARK: - CPTemplateApplicationSceneDelegate
@@ -25,14 +61,36 @@ class CarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate {
_ templateApplicationScene: CPTemplateApplicationScene,
didConnect interfaceController: CPInterfaceController
) {
appDelegate?.ensureFlutterBridgeReadyForCarPlay()
carPlayInterfaceController = interfaceController
interfaceController.setRootTemplate(makeListTemplate(), animated: false, completion: nil)
snapshot = Snapshot(payload: appDelegate?.currentCarPlaySnapshot())
let rootTemplate = makeListTemplate()
if #available(iOS 14.0, *) {
interfaceController.setRootTemplate(rootTemplate, animated: false, completion: nil)
} else {
// iOS 13 fallback: use API variant available before iOS 14.
interfaceController.setRootTemplate(rootTemplate, animated: false)
}
NotificationCenter.default.addObserver(
self,
selector: #selector(handleCarPlaySnapshotChanged(_:)),
name: AppDelegate.carPlaySnapshotDidChangeNotification,
object: nil
)
appDelegate?.requestCarPlaySnapshot()
}
func templateApplicationScene(
_ templateApplicationScene: CPTemplateApplicationScene,
didDisconnectInterfaceController interfaceController: CPInterfaceController
) {
NotificationCenter.default.removeObserver(
self,
name: AppDelegate.carPlaySnapshotDidChangeNotification,
object: nil
)
carPlayInterfaceController = nil
listTemplate = nil
appDelegate?.invokeStop()
@@ -41,40 +99,105 @@ class CarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate {
// 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",
text: snapshot.title,
detailText: detailText(for: snapshot),
image: icon
)
item.handler = { [weak self] _, completion in
self?.handleStreamItemTapped()
completion()
streamItem = item
if #available(iOS 14.0, *) {
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
if #unavailable(iOS 14.0) {
// iOS 13 fallback: use list delegate selection callback.
template.delegate = self
}
if #available(iOS 14.0, *) {
template.tabTitle = "KRYZ Go!"
template.tabImage = icon
}
listTemplate = template
// Silence unused-variable warning
_ = artwork
return template
}
private func detailText(for snapshot: Snapshot) -> String {
let statusText: String = switch snapshot.playbackStatus {
case "playing":
"Playing"
case "buffering":
"Buffering"
default:
"Stopped"
}
if snapshot.subtitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
return statusText
}
return "\(snapshot.subtitle)\(statusText)"
}
@objc private func handleCarPlaySnapshotChanged(_ notification: Notification) {
guard
let payload = notification.userInfo?[AppDelegate.carPlaySnapshotUserInfoKey] as? [String: Any]
else {
return
}
snapshot = Snapshot(payload: payload)
refreshTemplatesForSnapshot()
}
private func refreshTemplatesForSnapshot() {
guard let interfaceController = carPlayInterfaceController else {
return
}
if #available(iOS 14.0, *), interfaceController.topTemplate is CPNowPlayingTemplate {
// Preserve Now Playing navigation stack while metadata updates are flowing.
return
}
let updatedTemplate = makeListTemplate()
if #available(iOS 14.0, *) {
interfaceController.setRootTemplate(updatedTemplate, animated: false, completion: nil)
} else {
interfaceController.setRootTemplate(updatedTemplate, animated: false)
}
}
// MARK: - Playback
private func handleStreamItemTapped() {
appDelegate?.invokePlay()
carPlayInterfaceController?.pushTemplate(
CPNowPlayingTemplate.shared,
animated: true,
completion: nil
)
appDelegate?.requestCarPlaySnapshot()
guard let interfaceController = carPlayInterfaceController else {
return
}
if #available(iOS 14.0, *) {
interfaceController.pushTemplate(
CPNowPlayingTemplate.shared,
animated: true,
completion: nil
)
} else {
// iOS 13 fallback: no CPNowPlayingTemplate.shared API.
// Keep playback running and remain on the list template.
}
}
// MARK: - Helpers
@@ -82,4 +205,20 @@ class CarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate {
private var appDelegate: AppDelegate? {
UIApplication.shared.delegate as? AppDelegate
}
// MARK: - CPListTemplateDelegate (iOS 13 fallback)
func listTemplate(
_ listTemplate: CPListTemplate,
didSelect item: CPListItem,
completionHandler: @escaping () -> Void
) {
defer { completionHandler() }
guard item == streamItem else {
return
}
handleStreamItemTapped()
}
}
+79
View File
@@ -0,0 +1,79 @@
import 'package:flutter/services.dart';
import 'streaming_audio_business_object.dart';
class CarPlayBridge {
CarPlayBridge({required StreamingAudioBusinessObject audio}) : _audio = audio;
static const MethodChannel _channel = MethodChannel(
'kryz_go_flutter/carplay/methods',
);
final StreamingAudioBusinessObject _audio;
bool _isStarted = false;
Future<void> start() async {
if (_isStarted) {
return;
}
_isStarted = true;
_channel.setMethodCallHandler(_handleNativeMethodCall);
_audio.addListener(_onAudioChanged);
await _pushSnapshotToNative();
}
Future<void> dispose() async {
if (!_isStarted) {
return;
}
_isStarted = false;
_audio.removeListener(_onAudioChanged);
_channel.setMethodCallHandler(null);
}
Map<String, dynamic> _snapshot() {
final String playbackStatus = switch (_audio.playbackStatus) {
StreamingPlaybackStatus.playing => 'playing',
StreamingPlaybackStatus.buffering => 'buffering',
StreamingPlaybackStatus.stopped => 'stopped',
};
return <String, dynamic>{
'playbackStatus': playbackStatus,
'isPlaying': _audio.isPlaying,
'isBuffering': _audio.isBuffering,
'isLive': true,
'seekable': false,
'title': _audio.currentTitle,
'subtitle': _audio.currentSubtitle,
'album': 'Live',
};
}
Future<dynamic> _handleNativeMethodCall(MethodCall call) async {
switch (call.method) {
case 'getStateSnapshot':
return _snapshot();
default:
throw MissingPluginException(
'Unknown CarPlay bridge method: ${call.method}',
);
}
}
void _onAudioChanged() {
_pushSnapshotToNative();
}
Future<void> _pushSnapshotToNative() async {
try {
await _channel.invokeMethod<void>('syncState', _snapshot());
} on MissingPluginException {
// CarPlay bridge only exists on iOS native builds.
} on PlatformException {
// Keep playback working even if native sync temporarily fails.
}
}
}
+51 -3
View File
@@ -21,6 +21,9 @@ class CastingBusinessObject extends ChangeNotifier {
String? _connectedDeviceName;
String? _lastError;
/// Callback for when native side requests local audio to stop
VoidCallback? onStopLocalAudioRequested;
CastingStatus get status => _status;
CastingPlaybackStatus get playbackStatus => _playbackStatus;
String? get connectedDeviceName => _connectedDeviceName;
@@ -55,6 +58,18 @@ class CastingBusinessObject extends ChangeNotifier {
},
);
// Set up handler for native method calls (e.g., stopLocalAudio)
_methodChannel.setMethodCallHandler(_handleMethodCall);
await refreshStatus();
}
Future<void> refreshStatus() async {
if (!isSupportedOnThisPlatform) {
_setStatus(CastingStatus.unavailable);
return;
}
try {
final dynamic status = await _methodChannel.invokeMethod<dynamic>(
'getStatus',
@@ -66,7 +81,12 @@ class CastingBusinessObject extends ChangeNotifier {
}
}
Future<void> showDevicePicker() async {
Future<void> showDevicePicker({
bool autoplay = false,
String? streamUrl,
String? title,
String? subtitle,
}) async {
if (!isSupportedOnThisPlatform) {
_setStatus(CastingStatus.unavailable);
return;
@@ -77,7 +97,12 @@ class CastingBusinessObject extends ChangeNotifier {
}
try {
await _methodChannel.invokeMethod<void>('showDevicePicker');
await _methodChannel.invokeMethod<void>('showDevicePicker', {
'autoplay': autoplay,
'streamUrl': streamUrl,
'title': title,
'subtitle': subtitle,
});
} on PlatformException catch (error) {
_lastError = error.message ?? error.code;
_setStatus(CastingStatus.error);
@@ -102,7 +127,10 @@ class CastingBusinessObject extends ChangeNotifier {
});
} on PlatformException catch (error) {
_lastError = error.message ?? error.code;
_setStatus(CastingStatus.error);
// Do NOT set status to error here — native emits its own status events.
// Overriding status from Dart corrupts the UI when native considers the
// session still connected (e.g. during a retry after a race condition).
rethrow;
}
}
@@ -146,6 +174,8 @@ class CastingBusinessObject extends ChangeNotifier {
if (deviceName != null && deviceName.isNotEmpty) {
_connectedDeviceName = deviceName;
} else if (status != 'connected') {
_connectedDeviceName = null;
}
if (message != null && message.isNotEmpty) {
@@ -210,4 +240,22 @@ class CastingBusinessObject extends ChangeNotifier {
_eventSubscription?.cancel();
super.dispose();
}
/// Handles method calls from native code
Future<dynamic> _handleMethodCall(MethodCall call) async {
switch (call.method) {
case 'stopLocalAudio':
onStopLocalAudioRequested?.call();
return null;
case 'stopAudioService':
// Stop the audio service directly (called when transferring to cast)
onStopLocalAudioRequested?.call();
return null;
case 'resumeLocalAudio':
// Audio service is resuming after cast disconnect; no action needed on Flutter side
return null;
default:
return null;
}
}
}
+234
View File
@@ -0,0 +1,234 @@
import 'dart:async';
import 'dart:convert';
import 'package:audio_service/audio_service.dart';
import 'package:audio_session/audio_session.dart';
import 'package:http/http.dart' as http;
import 'package:just_audio/just_audio.dart';
import 'live_info_business_object.dart';
class KryzAudioHandler extends BaseAudioHandler with SeekHandler {
KryzAudioHandler() {
mediaItem.add(_defaultMediaItem);
_playerStateSubscription = _player.playerStateStream.listen(
_onPlayerStateChanged,
);
}
static const String streamUrl = 'https://kryz.out.airtime.pro/kryz_a';
static const String mediaId = 'kryz-live-stream';
static const String _liveInfoEndpoint =
'http://kryz.airtime.pro/api/live-info';
static const Duration _liveInfoTimeout = Duration(seconds: 8);
static const Duration _metadataRefreshInterval = Duration(seconds: 20);
static const MediaItem _defaultMediaItem = MediaItem(
id: mediaId,
title: 'KRYZ Live Stream',
artist: 'KRYZ Radio',
album: 'Live',
extras: <String, dynamic>{'isLive': true, 'seekable': false},
);
final AudioPlayer _player = AudioPlayer();
late final StreamSubscription<PlayerState> _playerStateSubscription;
Timer? _metadataRefreshTimer;
bool _isAudioSessionConfigured = false;
bool _isSourceLoaded = false;
bool _playRequested = false;
Future<void> updateMetadataFromLiveInfo(LiveInfoSnapshot? snapshot) async {
final current = snapshot?.current;
if (current == null || current.type != 'track') {
mediaItem.add(_defaultMediaItem);
return;
}
final String subtitle = current.displaySubtitle.trim();
mediaItem.add(
MediaItem(
id: mediaId,
title: current.displayTitle,
artist: subtitle.isEmpty ? 'KRYZ Radio' : subtitle,
album: 'Live',
extras: <String, dynamic>{
'isLive': true,
'seekable': false,
'starts': current.starts,
'ends': current.ends,
'schedulerTime': snapshot?.schedulerTime,
},
),
);
}
@override
Future<void> play() async {
_playRequested = true;
playbackState.add(
playbackState.value.copyWith(
controls: const <MediaControl>[MediaControl.stop],
systemActions: const <MediaAction>{},
processingState: AudioProcessingState.buffering,
playing: false,
),
);
await _configureAudioSessionIfNeeded();
try {
if (!_isSourceLoaded) {
await _player.setAudioSource(
AudioSource.uri(Uri.parse(streamUrl), tag: mediaItem.value),
);
_isSourceLoaded = true;
}
final session = await AudioSession.instance;
await session.setActive(true);
await _player.play();
await _refreshMetadataFromApi();
_startMetadataRefreshTimer();
} catch (_) {
_playRequested = false;
playbackState.add(
playbackState.value.copyWith(
controls: const <MediaControl>[MediaControl.play],
systemActions: const <MediaAction>{},
processingState: AudioProcessingState.idle,
playing: false,
),
);
rethrow;
}
}
@override
Future<void> stop() async {
_playRequested = false;
_isSourceLoaded = false;
_metadataRefreshTimer?.cancel();
_metadataRefreshTimer = null;
await _player.stop();
playbackState.add(
playbackState.value.copyWith(
controls: const <MediaControl>[MediaControl.play],
systemActions: const <MediaAction>{},
processingState: AudioProcessingState.idle,
playing: false,
),
);
}
@override
Future<void> pause() async {
// CarPlay and lock-screen transport may issue pause for live streams.
// Treat pause as stop to keep behavior consistent across platforms.
await stop();
}
@override
Future<void> seek(Duration position) async {
// Live stream is intentionally non-seekable.
}
@override
Future<void> playFromMediaId(String mediaId, [Map<String, dynamic>? extras]) {
return play();
}
@override
Future<void> playFromSearch(String query, [Map<String, dynamic>? extras]) {
return play();
}
Future<void> dispose() async {
_metadataRefreshTimer?.cancel();
await _playerStateSubscription.cancel();
await _player.dispose();
}
Future<void> _configureAudioSessionIfNeeded() async {
if (_isAudioSessionConfigured) {
return;
}
final session = await AudioSession.instance;
await session.configure(const AudioSessionConfiguration.music());
_isAudioSessionConfigured = true;
}
void _onPlayerStateChanged(PlayerState state) {
final AudioProcessingState processingState =
switch (state.processingState) {
ProcessingState.idle => AudioProcessingState.idle,
ProcessingState.loading => AudioProcessingState.loading,
ProcessingState.buffering => AudioProcessingState.buffering,
ProcessingState.ready => AudioProcessingState.ready,
ProcessingState.completed => AudioProcessingState.completed,
};
final bool isPlaying =
state.playing && state.processingState == ProcessingState.ready;
final bool isBuffering =
(state.playing && state.processingState != ProcessingState.ready) ||
(_playRequested &&
!state.playing &&
state.processingState != ProcessingState.completed);
playbackState.add(
playbackState.value.copyWith(
controls: <MediaControl>[
if (isPlaying || isBuffering)
MediaControl.stop
else
MediaControl.play,
],
systemActions: const <MediaAction>{},
processingState: isBuffering
? AudioProcessingState.buffering
: processingState,
playing: isPlaying,
),
);
if (isPlaying || isBuffering) {
_startMetadataRefreshTimer();
} else {
_metadataRefreshTimer?.cancel();
_metadataRefreshTimer = null;
}
}
void _startMetadataRefreshTimer() {
if (_metadataRefreshTimer != null) {
return;
}
_metadataRefreshTimer = Timer.periodic(_metadataRefreshInterval, (_) {
_refreshMetadataFromApi();
});
}
Future<void> _refreshMetadataFromApi() async {
try {
final response = await http
.get(Uri.parse(_liveInfoEndpoint))
.timeout(_liveInfoTimeout);
if (response.statusCode < 200 || response.statusCode >= 300) {
return;
}
final dynamic decoded = jsonDecode(response.body);
if (decoded is! Map<String, dynamic>) {
return;
}
final snapshot = LiveInfoSnapshot.fromJson(decoded);
await updateMetadataFromLiveInfo(snapshot);
} catch (_) {
// Keep existing metadata if live-info fetch fails.
}
}
}
+83 -48
View File
@@ -1,9 +1,11 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
import 'package:just_audio_background/just_audio_background.dart';
import 'package:flutter/services.dart';
import 'package:timezone/data/latest.dart' as tz;
import 'app_theme_business_object.dart';
import 'carplay_bridge.dart';
import 'casting_business_object.dart';
import 'live_info_business_object.dart';
import 'live_info_panel.dart';
@@ -12,19 +14,16 @@ import 'streaming_audio_business_object.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
tz.initializeTimeZones();
await JustAudioBackground.init(
androidNotificationChannelId: 'com.kryzgoflutter.audio.channel',
androidNotificationChannelName: 'KRYZ Audio Playback',
androidNotificationOngoing: true,
);
WidgetsFlutterBinding.ensureInitialized();
await SystemChrome.setPreferredOrientations([
DeviceOrientation.portraitUp,
DeviceOrientation.portraitDown,
]);
runApp(const MyApp());
// Keep startup non-blocking so UI can render even if background media
// services are initializing after an Android Auto cold start.
unawaited(
SystemChrome.setPreferredOrientations([
DeviceOrientation.portraitUp,
DeviceOrientation.portraitDown,
]),
);
}
class MyApp extends StatefulWidget {
@@ -37,15 +36,20 @@ class MyApp extends StatefulWidget {
class _MyAppState extends State<MyApp> {
final StreamingAudioBusinessObject _audio = StreamingAudioBusinessObject();
final CastingBusinessObject _casting = CastingBusinessObject();
late final CarPlayBridge _carPlayBridge;
@override
void initState() {
super.initState();
_carPlayBridge = CarPlayBridge(audio: _audio);
unawaited(_audio.ensureInitialized());
unawaited(_carPlayBridge.start());
_casting.initialize();
}
@override
void dispose() {
unawaited(_carPlayBridge.dispose());
_casting.dispose();
_audio.dispose();
super.dispose();
@@ -74,12 +78,11 @@ class MainPage extends StatefulWidget {
State<MainPage> createState() => _MainPageState();
}
class _MainPageState extends State<MainPage> {
class _MainPageState extends State<MainPage> with WidgetsBindingObserver {
StreamingAudioBusinessObject get _audio => widget.audio;
CastingBusinessObject get _casting => widget.casting;
final LiveInfoBusinessObject _liveInfo = LiveInfoBusinessObject();
static const List<int> _intervalOptions = <int>[5, 10, 15, 30, 60];
bool _castStreamStartedForActiveSession = false;
bool _isStartingCastStream = false;
bool _pendingRemotePlay = false;
@@ -134,15 +137,32 @@ class _MainPageState extends State<MainPage> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_audio.addListener(_handleAudioStateChanged);
_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<void>('flutterReady');
// When native code requests local audio to stop (cast media loaded)
_casting.onStopLocalAudioRequested = () {
unawaited(_audio.stop());
};
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
// Re-initialize (not just refresh) to re-establish the EventChannel
// subscription. In a cold-start Android Auto scenario the Flutter engine
// boots headlessly before MainActivity.configureFlutterEngine() runs, so
// the native EventChannel StreamHandler is not yet registered when
// initialize() first fires. The "listen" message is dropped, eventSink
// stays null, and all subsequent emitStatus() calls are silently lost.
// Calling initialize() here cancels that dead subscription and creates a
// fresh one now that the handler is registered; onListen immediately
// re-emits statusPayload so the Dart state is fully re-synced.
unawaited(_casting.initialize());
}
}
@@ -163,19 +183,11 @@ class _MainPageState extends State<MainPage> {
setState(() {
// 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() {
_audio.updateMetadataFromLiveInfo(_liveInfo.snapshot);
if (!mounted) {
return;
}
@@ -186,15 +198,27 @@ class _MainPageState extends State<MainPage> {
}
void _handleCastingStateChanged() {
final currentStatus = _casting.status;
if (_casting.isPlaying || _casting.isBuffering) {
_pendingRemotePlay = false;
}
if (currentStatus != CastingStatus.connected) {
_isStartingCastStream = false;
}
if (_usesExternalCastPlayback &&
(!_castStreamStartedForActiveSession || _pendingRemotePlay) &&
_pendingRemotePlay &&
!_isStartingCastStream) {
// Only load when _pendingRemotePlay was set by _play() while connecting.
// Do NOT trigger on justConnected — native pendingCastRequest handles
// all autoplay from showDevicePicker(autoplay: true).
_startCastingLiveStream();
}
if (!_usesExternalCastPlayback) {
_castStreamStartedForActiveSession = false;
_isStartingCastStream = false;
if (!_usesExternalCastPlayback &&
currentStatus != CastingStatus.connecting) {
_pendingRemotePlay = false;
}
@@ -213,25 +237,28 @@ class _MainPageState extends State<MainPage> {
try {
await _casting.startCastingLiveStream(
streamUrl: _audio.streamUrl,
title: 'KRYZ Live Stream',
subtitle: 'KRYZ Radio',
title: _audio.currentTitle,
subtitle: _audio.currentSubtitle,
);
_castStreamStartedForActiveSession = true;
_pendingRemotePlay = false;
if (_usesExternalCastPlayback && !_audio.isStopped) {
await _audio.stop();
}
} catch (_) {
_castStreamStartedForActiveSession = false;
// State is already updated by the casting business object.
_pendingRemotePlay = !_audio.isStopped;
} finally {
_isStartingCastStream = false;
}
}
Future<void> _play() async {
if (_casting.status == CastingStatus.connecting) {
_pendingRemotePlay = true;
return;
}
if (_usesExternalCastPlayback) {
if (_casting.isPlaying || _isStartingCastStream) {
return;
@@ -241,12 +268,7 @@ class _MainPageState extends State<MainPage> {
return;
}
if (_usesExternalCastPlayback &&
_casting.status == CastingStatus.connecting) {
_pendingRemotePlay = true;
return;
}
_pendingRemotePlay = false;
await _audio.play();
}
@@ -265,7 +287,21 @@ class _MainPageState extends State<MainPage> {
return;
}
await _casting.showDevicePicker();
final shouldAutoplay = !_audio.isStopped;
// Do NOT set _pendingRemotePlay when native handles autoplay.
// The native pendingCastRequest already handles autoplay via
// showDevicePicker(autoplay: true). Setting _pendingRemotePlay here
// causes a duplicate load from Flutter that races the native load and
// corrupts the cast status (especially in the Android Auto path).
// _pendingRemotePlay is only set by _play() when the user explicitly
// presses Play while the cast session is still connecting.
_pendingRemotePlay = false;
await _casting.showDevicePicker(
autoplay: shouldAutoplay,
streamUrl: shouldAutoplay ? _audio.streamUrl : null,
title: shouldAutoplay ? _audio.currentTitle : null,
subtitle: shouldAutoplay ? _audio.currentSubtitle : null,
);
}
String _castingLabel() {
@@ -304,6 +340,7 @@ class _MainPageState extends State<MainPage> {
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_audio.removeListener(_handleAudioStateChanged);
_casting.removeListener(_handleCastingStateChanged);
_liveInfo.removeListener(_handleLiveInfoChanged);
@@ -425,9 +462,7 @@ class _MainPageState extends State<MainPage> {
label: const Text('Stop'),
),
ElevatedButton.icon(
onPressed:
_casting.status == CastingStatus.connecting ||
_casting.status == CastingStatus.unavailable
onPressed: _casting.status == CastingStatus.connecting
? null
: _toggleCasting,
icon: Icon(_castingIcon()),
+115 -61
View File
@@ -1,48 +1,94 @@
import 'dart:async';
import 'package:audio_session/audio_session.dart';
import 'package:audio_service/audio_service.dart';
import 'package:flutter/foundation.dart';
import 'package:just_audio_background/just_audio_background.dart';
import 'package:just_audio/just_audio.dart';
import 'kryz_audio_handler.dart';
import 'live_info_business_object.dart';
enum StreamingPlaybackStatus { stopped, buffering, playing }
class StreamingAudioBusinessObject extends ChangeNotifier {
StreamingAudioBusinessObject({
this.streamUrl = 'https://kryz.out.airtime.pro/kryz_a',
}) {
_playerStateSubscription = _player.playerStateStream.listen((state) {
_setPlaybackStatus(_mapPlayerStateToStatus(state));
});
StreamingAudioBusinessObject() {
final initFuture = _audioHandlerInitFuture;
if (initFuture != null) {
initFuture.then((handler) {
if (_isDisposed) {
return;
}
_attachHandler(handler);
});
}
final handler = _audioHandler;
if (handler != null) {
_attachHandler(handler);
}
}
final String streamUrl;
final AudioPlayer _player = AudioPlayer();
late final StreamSubscription<PlayerState> _playerStateSubscription;
bool _isSourceLoaded = false;
static AudioHandler? _audioHandler;
static Future<AudioHandler>? _audioHandlerInitFuture;
static Future<void> initializeBackgroundAudio() async {
if (_audioHandler != null) {
return;
}
_audioHandlerInitFuture ??= AudioService.init(
builder: () => KryzAudioHandler(),
config: const AudioServiceConfig(
androidNotificationChannelId: 'com.kryzgoflutter.audio.channel',
androidNotificationChannelName: 'KRYZ Audio Playback',
androidNotificationOngoing: true,
androidStopForegroundOnPause: true,
),
).then((handler) {
_audioHandler = handler;
return handler;
}).catchError((error) {
_audioHandlerInitFuture = null;
throw error;
});
await _audioHandlerInitFuture;
}
String get streamUrl => KryzAudioHandler.streamUrl;
StreamSubscription<PlaybackState>? _playbackStateSubscription;
StreamSubscription<MediaItem?>? _mediaItemSubscription;
StreamingPlaybackStatus _playbackStatus = StreamingPlaybackStatus.stopped;
bool _isAudioSessionConfigured = false;
bool _playRequested = false;
bool _isDisposed = false;
bool _isAttached = false;
String _currentTitle = 'KRYZ Live Stream';
String _currentSubtitle = 'KRYZ Radio';
StreamingPlaybackStatus get playbackStatus => _playbackStatus;
bool get isPlaying => _playbackStatus == StreamingPlaybackStatus.playing;
bool get isBuffering => _playbackStatus == StreamingPlaybackStatus.buffering;
bool get isStopped => _playbackStatus == StreamingPlaybackStatus.stopped;
String get currentTitle => _currentTitle;
String get currentSubtitle => _currentSubtitle;
StreamingPlaybackStatus _mapPlayerStateToStatus(PlayerState state) {
if (state.processingState == ProcessingState.completed) {
Future<void> ensureInitialized() async {
await initializeBackgroundAudio();
}
StreamingPlaybackStatus _mapPlaybackStateToStatus(PlaybackState state) {
if (state.processingState == AudioProcessingState.completed) {
return StreamingPlaybackStatus.stopped;
}
if (state.playing) {
if (state.processingState == ProcessingState.ready) {
if (state.processingState == AudioProcessingState.ready) {
return StreamingPlaybackStatus.playing;
}
return StreamingPlaybackStatus.buffering;
}
if (_playRequested && state.processingState != ProcessingState.completed) {
if (state.processingState == AudioProcessingState.buffering ||
state.processingState == AudioProcessingState.loading) {
return StreamingPlaybackStatus.buffering;
}
@@ -58,58 +104,66 @@ class StreamingAudioBusinessObject extends ChangeNotifier {
notifyListeners();
}
Future<void> _configureAudioSessionIfNeeded() async {
if (_isAudioSessionConfigured) {
return;
}
final session = await AudioSession.instance;
await session.configure(const AudioSessionConfiguration.music());
_isAudioSessionConfigured = true;
}
Future<void> play() async {
_playRequested = true;
await _configureAudioSessionIfNeeded();
_setPlaybackStatus(StreamingPlaybackStatus.buffering);
try {
if (!_isSourceLoaded) {
await _player.setAudioSource(
AudioSource.uri(
Uri.parse(streamUrl),
tag: const MediaItem(
id: 'kryz-live-stream',
title: 'KRYZ Live Stream',
artist: 'KRYZ Radio',
album: 'Live',
),
),
);
_isSourceLoaded = true;
}
final session = await AudioSession.instance;
await session.setActive(true);
await _player.play();
} catch (_) {
_playRequested = false;
_setPlaybackStatus(StreamingPlaybackStatus.stopped);
rethrow;
}
final handler = await _requireHandler();
await handler.play();
}
Future<void> stop() async {
_playRequested = false;
_isSourceLoaded = false;
final handler = await _requireHandler();
await handler.stop();
_setPlaybackStatus(StreamingPlaybackStatus.stopped);
await _player.stop();
}
Future<void> updateMetadataFromLiveInfo(LiveInfoSnapshot? snapshot) async {
final handler = await _requireHandler();
await handler.updateMetadataFromLiveInfo(snapshot);
}
Future<KryzAudioHandler> _requireHandler() async {
await initializeBackgroundAudio();
final handler = _audioHandler;
if (handler is! KryzAudioHandler) {
throw StateError('KryzAudioHandler is not initialized');
}
_attachHandler(handler);
return handler;
}
void _attachHandler(AudioHandler handler) {
if (_isAttached) {
return;
}
_isAttached = true;
_playbackStateSubscription = handler.playbackState.listen((state) {
_setPlaybackStatus(_mapPlaybackStateToStatus(state));
});
_mediaItemSubscription = handler.mediaItem.listen((item) {
if (item == null) {
return;
}
final title = item.title.trim().isEmpty ? 'KRYZ Live Stream' : item.title;
final subtitle =
(item.artist ?? '').trim().isEmpty ? 'KRYZ Radio' : item.artist!.trim();
if (title == _currentTitle && subtitle == _currentSubtitle) {
return;
}
_currentTitle = title;
_currentSubtitle = subtitle;
notifyListeners();
});
}
@override
void dispose() {
_playerStateSubscription.cancel();
_player.dispose();
_isDisposed = true;
_playbackStateSubscription?.cancel();
_mediaItemSubscription?.cancel();
super.dispose();
}
}
+1
View File
@@ -36,6 +36,7 @@ dependencies:
cupertino_icons: ^1.0.8
just_audio: ^0.10.5
just_audio_background: ^0.0.1-beta.17
audio_service: ^0.18.18
audio_session: ^0.2.2
webview_flutter: ^4.13.0
http: ^1.5.0
+4 -12
View File
@@ -11,20 +11,12 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:kryz_go_flutter/main.dart';
void main() {
testWidgets('Counter increments smoke test', (WidgetTester tester) async {
// Build our app and trigger a frame.
testWidgets('App renders title and controls', (WidgetTester tester) async {
await tester.pumpWidget(const MyApp());
// Verify that our counter starts at 0.
expect(find.text('0'), findsOneWidget);
expect(find.text('1'), findsNothing);
// Tap the '+' icon and trigger a frame.
await tester.tap(find.byIcon(Icons.add));
await tester.pump();
// Verify that our counter has incremented.
expect(find.text('0'), findsNothing);
expect(find.text('1'), findsOneWidget);
expect(find.text('KRYZ Go!'), findsWidgets);
expect(find.widgetWithIcon(ElevatedButton, Icons.play_arrow_rounded), findsOneWidget);
expect(find.widgetWithIcon(ElevatedButton, Icons.stop_rounded), findsOneWidget);
});
}