corrects problem where google cast was disconnected from player state when app launched from head unit
This commit is contained in:
+27
@@ -5,6 +5,7 @@ import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.support.v4.media.MediaBrowserCompat
|
||||
@@ -178,11 +179,37 @@ class KryzAutoMediaBrowserService : MediaBrowserServiceCompat() {
|
||||
|
||||
override fun onDestroy() {
|
||||
Log.d(TAG, "onDestroy()")
|
||||
stopFlutterPlaybackForHeadUnitDisconnect()
|
||||
disconnectFromFlutterAudioService()
|
||||
if (::mediaSession.isInitialized) mediaSession.release()
|
||||
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) {
|
||||
if (!::mediaSession.isInitialized) return
|
||||
val state = when {
|
||||
|
||||
@@ -27,13 +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 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 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",
|
||||
@@ -58,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)")
|
||||
}
|
||||
|
||||
@@ -71,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")
|
||||
}
|
||||
|
||||
@@ -80,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")
|
||||
}
|
||||
@@ -96,8 +120,8 @@ 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)
|
||||
@@ -114,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)
|
||||
@@ -174,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))
|
||||
@@ -210,6 +238,7 @@ class MainActivity : AudioServiceActivity(), MethodChannel.MethodCallHandler, Ev
|
||||
val currentStatus = statusPayload["status"] as? String
|
||||
|
||||
if (currentSession == null && currentStatus == "connecting") {
|
||||
clearPendingCastRequest()
|
||||
emitStatus("idle")
|
||||
}
|
||||
}
|
||||
@@ -236,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)
|
||||
@@ -247,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(
|
||||
@@ -292,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
|
||||
@@ -331,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)
|
||||
@@ -366,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"
|
||||
@@ -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(
|
||||
status: String,
|
||||
deviceName: String? = null,
|
||||
|
||||
Reference in New Issue
Block a user