Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c523d0121f | ||
|
|
b8e86263d9 | ||
|
|
e3288e044a | ||
|
|
b8d624343b | ||
|
|
4c4113f66f | ||
|
|
5f3801e65f | ||
|
|
a2b5da3669 | ||
|
|
1c7e27b83d | ||
|
|
df2338e5e9 |
Vendored
+5
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"chat.tools.terminal.autoApprove": {
|
||||||
|
"flutter": true
|
||||||
|
}
|
||||||
|
}
|
||||||
+28
-1
@@ -5,6 +5,7 @@ import android.app.NotificationChannel
|
|||||||
import android.app.NotificationManager
|
import android.app.NotificationManager
|
||||||
import android.content.ComponentName
|
import android.content.ComponentName
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import android.support.v4.media.MediaBrowserCompat
|
import android.support.v4.media.MediaBrowserCompat
|
||||||
@@ -178,11 +179,37 @@ class KryzAutoMediaBrowserService : MediaBrowserServiceCompat() {
|
|||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
Log.d(TAG, "onDestroy()")
|
Log.d(TAG, "onDestroy()")
|
||||||
|
stopFlutterPlaybackForHeadUnitDisconnect()
|
||||||
disconnectFromFlutterAudioService()
|
disconnectFromFlutterAudioService()
|
||||||
if (::mediaSession.isInitialized) mediaSession.release()
|
if (::mediaSession.isInitialized) mediaSession.release()
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||||
|
Log.d(TAG, "onTaskRemoved()")
|
||||||
|
stopFlutterPlaybackForHeadUnitDisconnect()
|
||||||
|
super.onTaskRemoved(rootIntent)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onUnbind(intent: Intent?): Boolean {
|
||||||
|
Log.d(TAG, "onUnbind()")
|
||||||
|
stopFlutterPlaybackForHeadUnitDisconnect()
|
||||||
|
return super.onUnbind(intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stopFlutterPlaybackForHeadUnitDisconnect() {
|
||||||
|
pendingPlayRequest = false
|
||||||
|
pendingStopRequest = false
|
||||||
|
|
||||||
|
try {
|
||||||
|
audioServiceController?.transportControls?.stop()
|
||||||
|
} catch (error: Exception) {
|
||||||
|
Log.w(TAG, "Unable to stop Flutter audio on head unit disconnect: ${error.message}")
|
||||||
|
}
|
||||||
|
|
||||||
|
applyPlaybackState(isPlaying = false, isBuffering = false)
|
||||||
|
}
|
||||||
|
|
||||||
private fun applyPlaybackState(isPlaying: Boolean, isBuffering: Boolean) {
|
private fun applyPlaybackState(isPlaying: Boolean, isBuffering: Boolean) {
|
||||||
if (!::mediaSession.isInitialized) return
|
if (!::mediaSession.isInitialized) return
|
||||||
val state = when {
|
val state = when {
|
||||||
@@ -303,7 +330,7 @@ class KryzAutoMediaBrowserService : MediaBrowserServiceCompat() {
|
|||||||
) {
|
) {
|
||||||
if (!::mediaSession.isInitialized) return
|
if (!::mediaSession.isInitialized) return
|
||||||
|
|
||||||
val iconUri = Uri.parse("android.resource://${packageName}/mipmap/ic_launcher")
|
val iconUri = Uri.parse("android.resource://${packageName}/drawable/kryz_auto_art")
|
||||||
mediaSession.setMetadata(
|
mediaSession.setMetadata(
|
||||||
MediaMetadataCompat.Builder()
|
MediaMetadataCompat.Builder()
|
||||||
.putString(MediaMetadataCompat.METADATA_KEY_TITLE, title ?: "KRYZ Live Stream")
|
.putString(MediaMetadataCompat.METADATA_KEY_TITLE, title ?: "KRYZ Live Stream")
|
||||||
|
|||||||
@@ -27,13 +27,26 @@ 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 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 castContext: CastContext? = null
|
||||||
|
private var methodChannel: MethodChannel? = null
|
||||||
private var eventSink: EventChannel.EventSink? = null
|
private var eventSink: EventChannel.EventSink? = null
|
||||||
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 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(
|
private var statusPayload: MutableMap<String, Any?> = mutableMapOf(
|
||||||
"status" to "idle",
|
"status" to "idle",
|
||||||
@@ -58,10 +71,13 @@ class MainActivity : AudioServiceActivity(), MethodChannel.MethodCallHandler, Ev
|
|||||||
|
|
||||||
override fun onSessionStarted(session: CastSession, sessionId: String) {
|
override fun onSessionStarted(session: CastSession, sessionId: String) {
|
||||||
attachRemoteClientCallback(session)
|
attachRemoteClientCallback(session)
|
||||||
|
startRemoteStatusPolling()
|
||||||
emitStatus("connected", session.castDevice?.friendlyName)
|
emitStatus("connected", session.castDevice?.friendlyName)
|
||||||
|
startPendingCastRequest(session)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onSessionStartFailed(session: CastSession, error: Int) {
|
override fun onSessionStartFailed(session: CastSession, error: Int) {
|
||||||
|
clearPendingCastRequest()
|
||||||
emitStatus("error", message = "Cast session failed to start ($error)")
|
emitStatus("error", message = "Cast session failed to start ($error)")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,6 +87,10 @@ class MainActivity : AudioServiceActivity(), MethodChannel.MethodCallHandler, Ev
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onSessionEnded(session: CastSession, error: Int) {
|
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")
|
emitStatus("idle")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,14 +100,18 @@ class MainActivity : AudioServiceActivity(), MethodChannel.MethodCallHandler, Ev
|
|||||||
|
|
||||||
override fun onSessionResumed(session: CastSession, wasSuspended: Boolean) {
|
override fun onSessionResumed(session: CastSession, wasSuspended: Boolean) {
|
||||||
attachRemoteClientCallback(session)
|
attachRemoteClientCallback(session)
|
||||||
|
startRemoteStatusPolling()
|
||||||
emitStatus("connected", session.castDevice?.friendlyName)
|
emitStatus("connected", session.castDevice?.friendlyName)
|
||||||
|
startPendingCastRequest(session)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onSessionResumeFailed(session: CastSession, error: Int) {
|
override fun onSessionResumeFailed(session: CastSession, error: Int) {
|
||||||
|
clearPendingCastRequest()
|
||||||
emitStatus("error", message = "Cast session failed to resume ($error)")
|
emitStatus("error", message = "Cast session failed to resume ($error)")
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onSessionSuspended(session: CastSession, reason: Int) {
|
override fun onSessionSuspended(session: CastSession, reason: Int) {
|
||||||
|
stopRemoteStatusPolling()
|
||||||
detachRemoteClientCallback()
|
detachRemoteClientCallback()
|
||||||
emitStatus("idle")
|
emitStatus("idle")
|
||||||
}
|
}
|
||||||
@@ -96,8 +120,8 @@ class MainActivity : AudioServiceActivity(), MethodChannel.MethodCallHandler, Ev
|
|||||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||||
super.configureFlutterEngine(flutterEngine)
|
super.configureFlutterEngine(flutterEngine)
|
||||||
|
|
||||||
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, METHOD_CHANNEL)
|
methodChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, METHOD_CHANNEL)
|
||||||
.setMethodCallHandler(this)
|
.apply { setMethodCallHandler(this@MainActivity) }
|
||||||
|
|
||||||
EventChannel(flutterEngine.dartExecutor.binaryMessenger, EVENT_CHANNEL)
|
EventChannel(flutterEngine.dartExecutor.binaryMessenger, EVENT_CHANNEL)
|
||||||
.setStreamHandler(this)
|
.setStreamHandler(this)
|
||||||
@@ -114,13 +138,14 @@ class MainActivity : AudioServiceActivity(), MethodChannel.MethodCallHandler, Ev
|
|||||||
when (call.method) {
|
when (call.method) {
|
||||||
"getStatus" -> result.success(statusPayload)
|
"getStatus" -> result.success(statusPayload)
|
||||||
"showDevicePicker" -> {
|
"showDevicePicker" -> {
|
||||||
showDevicePicker()
|
showDevicePicker(call)
|
||||||
result.success(null)
|
result.success(null)
|
||||||
}
|
}
|
||||||
"startCastingLiveStream" -> {
|
"startCastingLiveStream" -> {
|
||||||
startCastingLiveStream(call, result)
|
startCastingLiveStream(call, result)
|
||||||
}
|
}
|
||||||
"disconnect" -> {
|
"disconnect" -> {
|
||||||
|
clearPendingCastRequest()
|
||||||
castContext?.sessionManager?.endCurrentSession(true)
|
castContext?.sessionManager?.endCurrentSession(true)
|
||||||
emitStatus("idle")
|
emitStatus("idle")
|
||||||
result.success(null)
|
result.success(null)
|
||||||
@@ -174,12 +199,15 @@ class MainActivity : AudioServiceActivity(), MethodChannel.MethodCallHandler, Ev
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun showDevicePicker() {
|
private fun showDevicePicker(call: MethodCall) {
|
||||||
if (castContext == null) {
|
if (castContext == null) {
|
||||||
|
clearPendingCastRequest()
|
||||||
emitStatus("unavailable", message = "Cast services unavailable")
|
emitStatus("unavailable", message = "Cast services unavailable")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setPendingCastRequest(parsePendingCastRequest(call.arguments))
|
||||||
|
|
||||||
val receiverAppId = getString(R.string.cast_receiver_app_id)
|
val receiverAppId = getString(R.string.cast_receiver_app_id)
|
||||||
val routeSelector = MediaRouteSelector.Builder()
|
val routeSelector = MediaRouteSelector.Builder()
|
||||||
.addControlCategory(CastMediaControlIntent.categoryForCast(receiverAppId))
|
.addControlCategory(CastMediaControlIntent.categoryForCast(receiverAppId))
|
||||||
@@ -210,6 +238,7 @@ class MainActivity : AudioServiceActivity(), MethodChannel.MethodCallHandler, Ev
|
|||||||
val currentStatus = statusPayload["status"] as? String
|
val currentStatus = statusPayload["status"] as? String
|
||||||
|
|
||||||
if (currentSession == null && currentStatus == "connecting") {
|
if (currentSession == null && currentStatus == "connecting") {
|
||||||
|
clearPendingCastRequest()
|
||||||
emitStatus("idle")
|
emitStatus("idle")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -222,8 +251,8 @@ class MainActivity : AudioServiceActivity(), MethodChannel.MethodCallHandler, Ev
|
|||||||
}
|
}
|
||||||
|
|
||||||
val streamUrl = arguments["streamUrl"] as? String
|
val streamUrl = arguments["streamUrl"] as? String
|
||||||
val title = arguments["title"] as? String ?: "Live Stream"
|
val title = "KRYZ 98.5 LPFM Mariposa Community Radio"
|
||||||
val subtitle = arguments["subtitle"] as? String ?: ""
|
val subtitle = "Live Stream"
|
||||||
|
|
||||||
if (streamUrl.isNullOrBlank()) {
|
if (streamUrl.isNullOrBlank()) {
|
||||||
result.error("invalid_stream_url", "streamUrl is required", null)
|
result.error("invalid_stream_url", "streamUrl is required", null)
|
||||||
@@ -236,6 +265,113 @@ class MainActivity : AudioServiceActivity(), MethodChannel.MethodCallHandler, Ev
|
|||||||
return
|
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 {
|
val metadata = MediaMetadata(MediaMetadata.MEDIA_TYPE_MUSIC_TRACK).apply {
|
||||||
putString(MediaMetadata.KEY_TITLE, title)
|
putString(MediaMetadata.KEY_TITLE, title)
|
||||||
putString(MediaMetadata.KEY_SUBTITLE, subtitle)
|
putString(MediaMetadata.KEY_SUBTITLE, subtitle)
|
||||||
@@ -247,17 +383,10 @@ class MainActivity : AudioServiceActivity(), MethodChannel.MethodCallHandler, Ev
|
|||||||
.setMetadata(metadata)
|
.setMetadata(metadata)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
val loadRequestData = MediaLoadRequestData.Builder()
|
return MediaLoadRequestData.Builder()
|
||||||
.setMediaInfo(mediaInfo)
|
.setMediaInfo(mediaInfo)
|
||||||
.setAutoplay(true)
|
.setAutoplay(true)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
loadMediaWithRetry(
|
|
||||||
session = currentSession,
|
|
||||||
loadRequestData = loadRequestData,
|
|
||||||
attempt = 1,
|
|
||||||
result = result,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun loadMediaWithRetry(
|
private fun loadMediaWithRetry(
|
||||||
@@ -292,14 +421,14 @@ class MainActivity : AudioServiceActivity(), MethodChannel.MethodCallHandler, Ev
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
remoteMediaClient.load(loadRequestData).setResultCallback { mediaChannelResult ->
|
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) {
|
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)
|
emitStatus("connected", session.castDevice?.friendlyName)
|
||||||
|
// Force initial remote status update and continue polling
|
||||||
emitRemotePlaybackStatus()
|
emitRemotePlaybackStatus()
|
||||||
result.success(null)
|
result.success(null)
|
||||||
return@setResultCallback
|
return@setResultCallback
|
||||||
@@ -331,11 +460,6 @@ class MainActivity : AudioServiceActivity(), MethodChannel.MethodCallHandler, Ev
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
remoteMediaClient.stop().setResultCallback { stopResult ->
|
remoteMediaClient.stop().setResultCallback { stopResult ->
|
||||||
if (stopResult == null) {
|
|
||||||
result.error("cast_stop_failed", "Cast stop returned no result", null)
|
|
||||||
return@setResultCallback
|
|
||||||
}
|
|
||||||
|
|
||||||
if (stopResult.status.isSuccess) {
|
if (stopResult.status.isSuccess) {
|
||||||
emitStatus("connected", session.castDevice?.friendlyName, playbackStatus = "stopped")
|
emitStatus("connected", session.castDevice?.friendlyName, playbackStatus = "stopped")
|
||||||
result.success(null)
|
result.success(null)
|
||||||
@@ -366,22 +490,59 @@ class MainActivity : AudioServiceActivity(), MethodChannel.MethodCallHandler, Ev
|
|||||||
detachRemoteClientCallback()
|
detachRemoteClientCallback()
|
||||||
activeRemoteMediaClient = remoteMediaClient
|
activeRemoteMediaClient = remoteMediaClient
|
||||||
remoteMediaClient.registerCallback(remoteMediaClientCallback)
|
remoteMediaClient.registerCallback(remoteMediaClientCallback)
|
||||||
|
startRemoteStatusPolling()
|
||||||
|
if (pendingCastRequest != null) {
|
||||||
|
startPendingCastRequest(session)
|
||||||
|
}
|
||||||
emitRemotePlaybackStatus()
|
emitRemotePlaybackStatus()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun detachRemoteClientCallback() {
|
private fun detachRemoteClientCallback() {
|
||||||
|
stopRemoteStatusPolling()
|
||||||
activeRemoteMediaClient?.unregisterCallback(remoteMediaClientCallback)
|
activeRemoteMediaClient?.unregisterCallback(remoteMediaClientCallback)
|
||||||
activeRemoteMediaClient = null
|
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() {
|
private fun emitRemotePlaybackStatus() {
|
||||||
val remoteMediaClient = activeRemoteMediaClient
|
val remoteMediaClient = activeRemoteMediaClient
|
||||||
val mediaStatus = remoteMediaClient?.mediaStatus
|
if (remoteMediaClient == null) return
|
||||||
|
|
||||||
|
val mediaStatus = remoteMediaClient.mediaStatus
|
||||||
val playbackStatus = when (mediaStatus?.playerState) {
|
val playbackStatus = when (mediaStatus?.playerState) {
|
||||||
MediaStatus.PLAYER_STATE_PLAYING -> "playing"
|
MediaStatus.PLAYER_STATE_PLAYING -> "playing"
|
||||||
MediaStatus.PLAYER_STATE_BUFFERING,
|
MediaStatus.PLAYER_STATE_BUFFERING,
|
||||||
MediaStatus.PLAYER_STATE_LOADING -> "buffering"
|
MediaStatus.PLAYER_STATE_LOADING -> "buffering"
|
||||||
MediaStatus.PLAYER_STATE_PAUSED,
|
MediaStatus.PLAYER_STATE_PAUSED -> "stopped"
|
||||||
MediaStatus.PLAYER_STATE_IDLE,
|
MediaStatus.PLAYER_STATE_IDLE,
|
||||||
MediaStatus.PLAYER_STATE_UNKNOWN,
|
MediaStatus.PLAYER_STATE_UNKNOWN,
|
||||||
null -> "stopped"
|
null -> "stopped"
|
||||||
@@ -394,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(
|
private fun emitStatus(
|
||||||
status: String,
|
status: String,
|
||||||
deviceName: String? = null,
|
deviceName: String? = null,
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
@@ -2,12 +2,76 @@ import Flutter
|
|||||||
import UIKit
|
import UIKit
|
||||||
import AVFoundation
|
import AVFoundation
|
||||||
import AVKit
|
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
|
@main
|
||||||
@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 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 castingEventSink: FlutterEventSink?
|
||||||
private var castingStatus: [String: Any] = [
|
private var castingStatus: [String: Any] = [
|
||||||
@@ -15,6 +79,13 @@ import AVKit
|
|||||||
"platform": "ios"
|
"platform": "ios"
|
||||||
]
|
]
|
||||||
private var playbackMethodChannel: FlutterMethodChannel?
|
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(
|
override func application(
|
||||||
_ application: UIApplication,
|
_ application: UIApplication,
|
||||||
@@ -37,14 +108,67 @@ import AVKit
|
|||||||
)
|
)
|
||||||
|
|
||||||
updateCastingStatusForCurrentRoute()
|
updateCastingStatusForCurrentRoute()
|
||||||
|
ensureFlutterBridgeReadyForCarPlay()
|
||||||
|
|
||||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||||
}
|
}
|
||||||
|
|
||||||
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
|
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
|
||||||
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,16 +191,17 @@ import AVKit
|
|||||||
name: playbackMethodChannelName,
|
name: playbackMethodChannelName,
|
||||||
binaryMessenger: registrar.messenger()
|
binaryMessenger: registrar.messenger()
|
||||||
)
|
)
|
||||||
}
|
|
||||||
|
|
||||||
/// Invoke play on the Flutter audio engine. Called by CarPlaySceneDelegate.
|
carPlayMethodChannel = FlutterMethodChannel(
|
||||||
func invokePlay() {
|
name: carPlayMethodChannelName,
|
||||||
playbackMethodChannel?.invokeMethod("play", arguments: nil)
|
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.
|
configureRemoteCommandCenterIfNeeded()
|
||||||
func invokeStop() {
|
requestCarPlaySnapshot()
|
||||||
playbackMethodChannel?.invokeMethod("stop", arguments: nil)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func handleAudioRouteChange() {
|
@objc private func handleAudioRouteChange() {
|
||||||
@@ -103,6 +228,132 @@ import AVKit
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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() {
|
private func showAirPlayPicker() {
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
guard let rootViewController = self.activeRootViewController() else {
|
guard let rootViewController = self.activeRootViewController() else {
|
||||||
@@ -176,5 +427,6 @@ import AVKit
|
|||||||
|
|
||||||
deinit {
|
deinit {
|
||||||
NotificationCenter.default.removeObserver(self)
|
NotificationCenter.default.removeObserver(self)
|
||||||
|
teardownRemoteCommandCenter()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,10 +14,46 @@ import UIKit
|
|||||||
* Note: the com.apple.developer.carplay-audio entitlement must be enabled in the
|
* 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.
|
* 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 carPlayInterfaceController: CPInterfaceController?
|
||||||
private var listTemplate: CPListTemplate?
|
private var listTemplate: CPListTemplate?
|
||||||
|
private var streamItem: CPListItem?
|
||||||
|
private var snapshot: Snapshot = .default
|
||||||
|
|
||||||
// MARK: - CPTemplateApplicationSceneDelegate
|
// MARK: - CPTemplateApplicationSceneDelegate
|
||||||
|
|
||||||
@@ -25,14 +61,36 @@ class CarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate {
|
|||||||
_ templateApplicationScene: CPTemplateApplicationScene,
|
_ templateApplicationScene: CPTemplateApplicationScene,
|
||||||
didConnect interfaceController: CPInterfaceController
|
didConnect interfaceController: CPInterfaceController
|
||||||
) {
|
) {
|
||||||
|
appDelegate?.ensureFlutterBridgeReadyForCarPlay()
|
||||||
carPlayInterfaceController = interfaceController
|
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(
|
func templateApplicationScene(
|
||||||
_ templateApplicationScene: CPTemplateApplicationScene,
|
_ templateApplicationScene: CPTemplateApplicationScene,
|
||||||
didDisconnectInterfaceController interfaceController: CPInterfaceController
|
didDisconnectInterfaceController interfaceController: CPInterfaceController
|
||||||
) {
|
) {
|
||||||
|
NotificationCenter.default.removeObserver(
|
||||||
|
self,
|
||||||
|
name: AppDelegate.carPlaySnapshotDidChangeNotification,
|
||||||
|
object: nil
|
||||||
|
)
|
||||||
carPlayInterfaceController = nil
|
carPlayInterfaceController = nil
|
||||||
listTemplate = nil
|
listTemplate = nil
|
||||||
appDelegate?.invokeStop()
|
appDelegate?.invokeStop()
|
||||||
@@ -41,40 +99,105 @@ class CarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate {
|
|||||||
// MARK: - Template construction
|
// MARK: - Template construction
|
||||||
|
|
||||||
private func makeListTemplate() -> CPListTemplate {
|
private func makeListTemplate() -> CPListTemplate {
|
||||||
let artwork = CPListItem.maximumImageSize
|
|
||||||
let icon = UIImage(named: "AppIcon")?
|
let icon = UIImage(named: "AppIcon")?
|
||||||
.withRenderingMode(.alwaysOriginal)
|
.withRenderingMode(.alwaysOriginal)
|
||||||
|
|
||||||
let item = CPListItem(
|
let item = CPListItem(
|
||||||
text: "KRYZ Live Stream",
|
text: snapshot.title,
|
||||||
detailText: "KRYZ Radio",
|
detailText: detailText(for: snapshot),
|
||||||
image: icon
|
image: icon
|
||||||
)
|
)
|
||||||
item.handler = { [weak self] _, completion in
|
streamItem = item
|
||||||
self?.handleStreamItemTapped()
|
|
||||||
completion()
|
if #available(iOS 14.0, *) {
|
||||||
|
item.handler = { [weak self] _, completion in
|
||||||
|
self?.handleStreamItemTapped()
|
||||||
|
completion()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let section = CPListSection(items: [item])
|
let section = CPListSection(items: [item])
|
||||||
let template = CPListTemplate(title: "KRYZ Go!", sections: [section])
|
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
|
listTemplate = template
|
||||||
// Silence unused-variable warning
|
|
||||||
_ = artwork
|
|
||||||
return template
|
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
|
// MARK: - Playback
|
||||||
|
|
||||||
private func handleStreamItemTapped() {
|
private func handleStreamItemTapped() {
|
||||||
appDelegate?.invokePlay()
|
appDelegate?.invokePlay()
|
||||||
carPlayInterfaceController?.pushTemplate(
|
appDelegate?.requestCarPlaySnapshot()
|
||||||
CPNowPlayingTemplate.shared,
|
guard let interfaceController = carPlayInterfaceController else {
|
||||||
animated: true,
|
return
|
||||||
completion: nil
|
}
|
||||||
)
|
|
||||||
|
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
|
// MARK: - Helpers
|
||||||
@@ -82,4 +205,20 @@ class CarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate {
|
|||||||
private var appDelegate: AppDelegate? {
|
private var appDelegate: AppDelegate? {
|
||||||
UIApplication.shared.delegate as? 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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,9 @@ class CastingBusinessObject extends ChangeNotifier {
|
|||||||
String? _connectedDeviceName;
|
String? _connectedDeviceName;
|
||||||
String? _lastError;
|
String? _lastError;
|
||||||
|
|
||||||
|
/// Callback for when native side requests local audio to stop
|
||||||
|
VoidCallback? onStopLocalAudioRequested;
|
||||||
|
|
||||||
CastingStatus get status => _status;
|
CastingStatus get status => _status;
|
||||||
CastingPlaybackStatus get playbackStatus => _playbackStatus;
|
CastingPlaybackStatus get playbackStatus => _playbackStatus;
|
||||||
String? get connectedDeviceName => _connectedDeviceName;
|
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 {
|
try {
|
||||||
final dynamic status = await _methodChannel.invokeMethod<dynamic>(
|
final dynamic status = await _methodChannel.invokeMethod<dynamic>(
|
||||||
'getStatus',
|
'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) {
|
if (!isSupportedOnThisPlatform) {
|
||||||
_setStatus(CastingStatus.unavailable);
|
_setStatus(CastingStatus.unavailable);
|
||||||
return;
|
return;
|
||||||
@@ -77,7 +97,12 @@ class CastingBusinessObject extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await _methodChannel.invokeMethod<void>('showDevicePicker');
|
await _methodChannel.invokeMethod<void>('showDevicePicker', {
|
||||||
|
'autoplay': autoplay,
|
||||||
|
'streamUrl': streamUrl,
|
||||||
|
'title': title,
|
||||||
|
'subtitle': subtitle,
|
||||||
|
});
|
||||||
} on PlatformException catch (error) {
|
} on PlatformException catch (error) {
|
||||||
_lastError = error.message ?? error.code;
|
_lastError = error.message ?? error.code;
|
||||||
_setStatus(CastingStatus.error);
|
_setStatus(CastingStatus.error);
|
||||||
@@ -102,7 +127,10 @@ class CastingBusinessObject extends ChangeNotifier {
|
|||||||
});
|
});
|
||||||
} on PlatformException catch (error) {
|
} on PlatformException catch (error) {
|
||||||
_lastError = error.message ?? error.code;
|
_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) {
|
if (deviceName != null && deviceName.isNotEmpty) {
|
||||||
_connectedDeviceName = deviceName;
|
_connectedDeviceName = deviceName;
|
||||||
|
} else if (status != 'connected') {
|
||||||
|
_connectedDeviceName = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (message != null && message.isNotEmpty) {
|
if (message != null && message.isNotEmpty) {
|
||||||
@@ -210,4 +240,22 @@ class CastingBusinessObject extends ChangeNotifier {
|
|||||||
_eventSubscription?.cancel();
|
_eventSubscription?.cancel();
|
||||||
super.dispose();
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+33
-21
@@ -18,7 +18,8 @@ class KryzAudioHandler extends BaseAudioHandler with SeekHandler {
|
|||||||
|
|
||||||
static const String streamUrl = 'https://kryz.out.airtime.pro/kryz_a';
|
static const String streamUrl = 'https://kryz.out.airtime.pro/kryz_a';
|
||||||
static const String mediaId = 'kryz-live-stream';
|
static const String mediaId = 'kryz-live-stream';
|
||||||
static const String _liveInfoEndpoint = 'http://kryz.airtime.pro/api/live-info';
|
static const String _liveInfoEndpoint =
|
||||||
|
'http://kryz.airtime.pro/api/live-info';
|
||||||
static const Duration _liveInfoTimeout = Duration(seconds: 8);
|
static const Duration _liveInfoTimeout = Duration(seconds: 8);
|
||||||
static const Duration _metadataRefreshInterval = Duration(seconds: 20);
|
static const Duration _metadataRefreshInterval = Duration(seconds: 20);
|
||||||
|
|
||||||
@@ -27,10 +28,7 @@ class KryzAudioHandler extends BaseAudioHandler with SeekHandler {
|
|||||||
title: 'KRYZ Live Stream',
|
title: 'KRYZ Live Stream',
|
||||||
artist: 'KRYZ Radio',
|
artist: 'KRYZ Radio',
|
||||||
album: 'Live',
|
album: 'Live',
|
||||||
extras: <String, dynamic>{
|
extras: <String, dynamic>{'isLive': true, 'seekable': false},
|
||||||
'isLive': true,
|
|
||||||
'seekable': false,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
final AudioPlayer _player = AudioPlayer();
|
final AudioPlayer _player = AudioPlayer();
|
||||||
@@ -82,10 +80,7 @@ class KryzAudioHandler extends BaseAudioHandler with SeekHandler {
|
|||||||
try {
|
try {
|
||||||
if (!_isSourceLoaded) {
|
if (!_isSourceLoaded) {
|
||||||
await _player.setAudioSource(
|
await _player.setAudioSource(
|
||||||
AudioSource.uri(
|
AudioSource.uri(Uri.parse(streamUrl), tag: mediaItem.value),
|
||||||
Uri.parse(streamUrl),
|
|
||||||
tag: mediaItem.value,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
_isSourceLoaded = true;
|
_isSourceLoaded = true;
|
||||||
}
|
}
|
||||||
@@ -126,6 +121,13 @@ class KryzAudioHandler extends BaseAudioHandler with SeekHandler {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@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
|
@override
|
||||||
Future<void> seek(Duration position) async {
|
Future<void> seek(Duration position) async {
|
||||||
// Live stream is intentionally non-seekable.
|
// Live stream is intentionally non-seekable.
|
||||||
@@ -158,25 +160,35 @@ class KryzAudioHandler extends BaseAudioHandler with SeekHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _onPlayerStateChanged(PlayerState state) {
|
void _onPlayerStateChanged(PlayerState state) {
|
||||||
final AudioProcessingState processingState = switch (state.processingState) {
|
final AudioProcessingState processingState =
|
||||||
ProcessingState.idle => AudioProcessingState.idle,
|
switch (state.processingState) {
|
||||||
ProcessingState.loading => AudioProcessingState.loading,
|
ProcessingState.idle => AudioProcessingState.idle,
|
||||||
ProcessingState.buffering => AudioProcessingState.buffering,
|
ProcessingState.loading => AudioProcessingState.loading,
|
||||||
ProcessingState.ready => AudioProcessingState.ready,
|
ProcessingState.buffering => AudioProcessingState.buffering,
|
||||||
ProcessingState.completed => AudioProcessingState.completed,
|
ProcessingState.ready => AudioProcessingState.ready,
|
||||||
};
|
ProcessingState.completed => AudioProcessingState.completed,
|
||||||
|
};
|
||||||
|
|
||||||
final bool isPlaying = state.playing && state.processingState == ProcessingState.ready;
|
final bool isPlaying =
|
||||||
final bool isBuffering = (state.playing && state.processingState != ProcessingState.ready) ||
|
state.playing && state.processingState == ProcessingState.ready;
|
||||||
(_playRequested && !state.playing && state.processingState != ProcessingState.completed);
|
final bool isBuffering =
|
||||||
|
(state.playing && state.processingState != ProcessingState.ready) ||
|
||||||
|
(_playRequested &&
|
||||||
|
!state.playing &&
|
||||||
|
state.processingState != ProcessingState.completed);
|
||||||
|
|
||||||
playbackState.add(
|
playbackState.add(
|
||||||
playbackState.value.copyWith(
|
playbackState.value.copyWith(
|
||||||
controls: <MediaControl>[
|
controls: <MediaControl>[
|
||||||
if (isPlaying || isBuffering) MediaControl.stop else MediaControl.play,
|
if (isPlaying || isBuffering)
|
||||||
|
MediaControl.stop
|
||||||
|
else
|
||||||
|
MediaControl.play,
|
||||||
],
|
],
|
||||||
systemActions: const <MediaAction>{},
|
systemActions: const <MediaAction>{},
|
||||||
processingState: isBuffering ? AudioProcessingState.buffering : processingState,
|
processingState: isBuffering
|
||||||
|
? AudioProcessingState.buffering
|
||||||
|
: processingState,
|
||||||
playing: isPlaying,
|
playing: isPlaying,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
+68
-19
@@ -5,6 +5,7 @@ import 'package:flutter/foundation.dart';
|
|||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:timezone/data/latest.dart' as tz;
|
import 'package:timezone/data/latest.dart' as tz;
|
||||||
import 'app_theme_business_object.dart';
|
import 'app_theme_business_object.dart';
|
||||||
|
import 'carplay_bridge.dart';
|
||||||
import 'casting_business_object.dart';
|
import 'casting_business_object.dart';
|
||||||
import 'live_info_business_object.dart';
|
import 'live_info_business_object.dart';
|
||||||
import 'live_info_panel.dart';
|
import 'live_info_panel.dart';
|
||||||
@@ -35,16 +36,20 @@ class MyApp extends StatefulWidget {
|
|||||||
class _MyAppState extends State<MyApp> {
|
class _MyAppState extends State<MyApp> {
|
||||||
final StreamingAudioBusinessObject _audio = StreamingAudioBusinessObject();
|
final StreamingAudioBusinessObject _audio = StreamingAudioBusinessObject();
|
||||||
final CastingBusinessObject _casting = CastingBusinessObject();
|
final CastingBusinessObject _casting = CastingBusinessObject();
|
||||||
|
late final CarPlayBridge _carPlayBridge;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
_carPlayBridge = CarPlayBridge(audio: _audio);
|
||||||
unawaited(_audio.ensureInitialized());
|
unawaited(_audio.ensureInitialized());
|
||||||
|
unawaited(_carPlayBridge.start());
|
||||||
_casting.initialize();
|
_casting.initialize();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
unawaited(_carPlayBridge.dispose());
|
||||||
_casting.dispose();
|
_casting.dispose();
|
||||||
_audio.dispose();
|
_audio.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
@@ -73,12 +78,11 @@ class MainPage extends StatefulWidget {
|
|||||||
State<MainPage> createState() => _MainPageState();
|
State<MainPage> createState() => _MainPageState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _MainPageState extends State<MainPage> {
|
class _MainPageState extends State<MainPage> with WidgetsBindingObserver {
|
||||||
StreamingAudioBusinessObject get _audio => widget.audio;
|
StreamingAudioBusinessObject get _audio => widget.audio;
|
||||||
CastingBusinessObject get _casting => widget.casting;
|
CastingBusinessObject get _casting => widget.casting;
|
||||||
final LiveInfoBusinessObject _liveInfo = LiveInfoBusinessObject();
|
final LiveInfoBusinessObject _liveInfo = LiveInfoBusinessObject();
|
||||||
static const List<int> _intervalOptions = <int>[5, 10, 15, 30, 60];
|
static const List<int> _intervalOptions = <int>[5, 10, 15, 30, 60];
|
||||||
bool _castStreamStartedForActiveSession = false;
|
|
||||||
bool _isStartingCastStream = false;
|
bool _isStartingCastStream = false;
|
||||||
bool _pendingRemotePlay = false;
|
bool _pendingRemotePlay = false;
|
||||||
|
|
||||||
@@ -133,11 +137,33 @@ class _MainPageState extends State<MainPage> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
WidgetsBinding.instance.addObserver(this);
|
||||||
_audio.addListener(_handleAudioStateChanged);
|
_audio.addListener(_handleAudioStateChanged);
|
||||||
_casting.addListener(_handleCastingStateChanged);
|
_casting.addListener(_handleCastingStateChanged);
|
||||||
_liveInfo.addListener(_handleLiveInfoChanged);
|
_liveInfo.addListener(_handleLiveInfoChanged);
|
||||||
_liveInfo.start();
|
_liveInfo.start();
|
||||||
_playbackChannel.setMethodCallHandler(_handlePlaybackMethodCall);
|
_playbackChannel.setMethodCallHandler(_handlePlaybackMethodCall);
|
||||||
|
|
||||||
|
// 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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _handlePlaybackMethodCall(MethodCall call) async {
|
Future<void> _handlePlaybackMethodCall(MethodCall call) async {
|
||||||
@@ -172,15 +198,27 @@ class _MainPageState extends State<MainPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _handleCastingStateChanged() {
|
void _handleCastingStateChanged() {
|
||||||
|
final currentStatus = _casting.status;
|
||||||
|
|
||||||
|
if (_casting.isPlaying || _casting.isBuffering) {
|
||||||
|
_pendingRemotePlay = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentStatus != CastingStatus.connected) {
|
||||||
|
_isStartingCastStream = false;
|
||||||
|
}
|
||||||
|
|
||||||
if (_usesExternalCastPlayback &&
|
if (_usesExternalCastPlayback &&
|
||||||
(!_castStreamStartedForActiveSession || _pendingRemotePlay) &&
|
_pendingRemotePlay &&
|
||||||
!_isStartingCastStream) {
|
!_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();
|
_startCastingLiveStream();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!_usesExternalCastPlayback) {
|
if (!_usesExternalCastPlayback &&
|
||||||
_castStreamStartedForActiveSession = false;
|
currentStatus != CastingStatus.connecting) {
|
||||||
_isStartingCastStream = false;
|
|
||||||
_pendingRemotePlay = false;
|
_pendingRemotePlay = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,21 +241,24 @@ class _MainPageState extends State<MainPage> {
|
|||||||
subtitle: _audio.currentSubtitle,
|
subtitle: _audio.currentSubtitle,
|
||||||
);
|
);
|
||||||
|
|
||||||
_castStreamStartedForActiveSession = true;
|
|
||||||
_pendingRemotePlay = false;
|
_pendingRemotePlay = false;
|
||||||
|
|
||||||
if (_usesExternalCastPlayback && !_audio.isStopped) {
|
if (_usesExternalCastPlayback && !_audio.isStopped) {
|
||||||
await _audio.stop();
|
await _audio.stop();
|
||||||
}
|
}
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
_castStreamStartedForActiveSession = false;
|
_pendingRemotePlay = !_audio.isStopped;
|
||||||
// State is already updated by the casting business object.
|
|
||||||
} finally {
|
} finally {
|
||||||
_isStartingCastStream = false;
|
_isStartingCastStream = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _play() async {
|
Future<void> _play() async {
|
||||||
|
if (_casting.status == CastingStatus.connecting) {
|
||||||
|
_pendingRemotePlay = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (_usesExternalCastPlayback) {
|
if (_usesExternalCastPlayback) {
|
||||||
if (_casting.isPlaying || _isStartingCastStream) {
|
if (_casting.isPlaying || _isStartingCastStream) {
|
||||||
return;
|
return;
|
||||||
@@ -227,12 +268,7 @@ class _MainPageState extends State<MainPage> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_usesExternalCastPlayback &&
|
_pendingRemotePlay = false;
|
||||||
_casting.status == CastingStatus.connecting) {
|
|
||||||
_pendingRemotePlay = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await _audio.play();
|
await _audio.play();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -251,7 +287,21 @@ class _MainPageState extends State<MainPage> {
|
|||||||
return;
|
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() {
|
String _castingLabel() {
|
||||||
@@ -290,6 +340,7 @@ class _MainPageState extends State<MainPage> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
_audio.removeListener(_handleAudioStateChanged);
|
_audio.removeListener(_handleAudioStateChanged);
|
||||||
_casting.removeListener(_handleCastingStateChanged);
|
_casting.removeListener(_handleCastingStateChanged);
|
||||||
_liveInfo.removeListener(_handleLiveInfoChanged);
|
_liveInfo.removeListener(_handleLiveInfoChanged);
|
||||||
@@ -411,9 +462,7 @@ class _MainPageState extends State<MainPage> {
|
|||||||
label: const Text('Stop'),
|
label: const Text('Stop'),
|
||||||
),
|
),
|
||||||
ElevatedButton.icon(
|
ElevatedButton.icon(
|
||||||
onPressed:
|
onPressed: _casting.status == CastingStatus.connecting
|
||||||
_casting.status == CastingStatus.connecting ||
|
|
||||||
_casting.status == CastingStatus.unavailable
|
|
||||||
? null
|
? null
|
||||||
: _toggleCasting,
|
: _toggleCasting,
|
||||||
icon: Icon(_castingIcon()),
|
icon: Icon(_castingIcon()),
|
||||||
|
|||||||
Reference in New Issue
Block a user