Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c26e434d1e |
-27
@@ -5,7 +5,6 @@ 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
|
||||||
@@ -179,37 +178,11 @@ 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 {
|
||||||
|
|||||||
@@ -27,26 +27,13 @@ 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",
|
||||||
@@ -71,13 +58,10 @@ 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)")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,10 +71,6 @@ 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")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,18 +80,14 @@ 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")
|
||||||
}
|
}
|
||||||
@@ -120,8 +96,8 @@ class MainActivity : AudioServiceActivity(), MethodChannel.MethodCallHandler, Ev
|
|||||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||||
super.configureFlutterEngine(flutterEngine)
|
super.configureFlutterEngine(flutterEngine)
|
||||||
|
|
||||||
methodChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, METHOD_CHANNEL)
|
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, METHOD_CHANNEL)
|
||||||
.apply { setMethodCallHandler(this@MainActivity) }
|
.setMethodCallHandler(this)
|
||||||
|
|
||||||
EventChannel(flutterEngine.dartExecutor.binaryMessenger, EVENT_CHANNEL)
|
EventChannel(flutterEngine.dartExecutor.binaryMessenger, EVENT_CHANNEL)
|
||||||
.setStreamHandler(this)
|
.setStreamHandler(this)
|
||||||
@@ -138,14 +114,13 @@ class MainActivity : AudioServiceActivity(), MethodChannel.MethodCallHandler, Ev
|
|||||||
when (call.method) {
|
when (call.method) {
|
||||||
"getStatus" -> result.success(statusPayload)
|
"getStatus" -> result.success(statusPayload)
|
||||||
"showDevicePicker" -> {
|
"showDevicePicker" -> {
|
||||||
showDevicePicker(call)
|
showDevicePicker()
|
||||||
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)
|
||||||
@@ -199,15 +174,12 @@ class MainActivity : AudioServiceActivity(), MethodChannel.MethodCallHandler, Ev
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun showDevicePicker(call: MethodCall) {
|
private fun showDevicePicker() {
|
||||||
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))
|
||||||
@@ -238,7 +210,6 @@ 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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -265,113 +236,6 @@ 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)
|
||||||
@@ -383,10 +247,17 @@ class MainActivity : AudioServiceActivity(), MethodChannel.MethodCallHandler, Ev
|
|||||||
.setMetadata(metadata)
|
.setMetadata(metadata)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
return MediaLoadRequestData.Builder()
|
val loadRequestData = 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(
|
||||||
@@ -421,14 +292,14 @@ class MainActivity : AudioServiceActivity(), MethodChannel.MethodCallHandler, Ev
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
remoteMediaClient.load(loadRequestData).setResultCallback { mediaChannelResult ->
|
remoteMediaClient.load(loadRequestData).setResultCallback { mediaChannelResult ->
|
||||||
if (mediaChannelResult.status.isSuccess) {
|
if (mediaChannelResult == null) {
|
||||||
// Stop local playback immediately when remote load succeeds
|
emitStatus("error", message = "Cast load returned no result")
|
||||||
// This MUST be done in Kotlin (not Flutter) because the audio service
|
result.error("cast_load_failed", "Cast load returned no result", null)
|
||||||
// session may be active from Android Auto and won't respond to Flutter calls
|
return@setResultCallback
|
||||||
stopAudioServiceSession()
|
}
|
||||||
|
|
||||||
|
if (mediaChannelResult.status.isSuccess) {
|
||||||
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
|
||||||
@@ -460,6 +331,11 @@ 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)
|
||||||
@@ -490,59 +366,22 @@ 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
|
||||||
if (remoteMediaClient == null) return
|
val mediaStatus = remoteMediaClient?.mediaStatus
|
||||||
|
|
||||||
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 -> "stopped"
|
MediaStatus.PLAYER_STATE_PAUSED,
|
||||||
MediaStatus.PLAYER_STATE_IDLE,
|
MediaStatus.PLAYER_STATE_IDLE,
|
||||||
MediaStatus.PLAYER_STATE_UNKNOWN,
|
MediaStatus.PLAYER_STATE_UNKNOWN,
|
||||||
null -> "stopped"
|
null -> "stopped"
|
||||||
@@ -555,40 +394,6 @@ 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,
|
||||||
|
|||||||
@@ -21,9 +21,6 @@ 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;
|
||||||
@@ -58,18 +55,6 @@ 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',
|
||||||
@@ -81,12 +66,7 @@ class CastingBusinessObject extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> showDevicePicker({
|
Future<void> showDevicePicker() async {
|
||||||
bool autoplay = false,
|
|
||||||
String? streamUrl,
|
|
||||||
String? title,
|
|
||||||
String? subtitle,
|
|
||||||
}) async {
|
|
||||||
if (!isSupportedOnThisPlatform) {
|
if (!isSupportedOnThisPlatform) {
|
||||||
_setStatus(CastingStatus.unavailable);
|
_setStatus(CastingStatus.unavailable);
|
||||||
return;
|
return;
|
||||||
@@ -97,12 +77,7 @@ 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);
|
||||||
@@ -127,10 +102,7 @@ class CastingBusinessObject extends ChangeNotifier {
|
|||||||
});
|
});
|
||||||
} on PlatformException catch (error) {
|
} on PlatformException catch (error) {
|
||||||
_lastError = error.message ?? error.code;
|
_lastError = error.message ?? error.code;
|
||||||
// Do NOT set status to error here — native emits its own status events.
|
_setStatus(CastingStatus.error);
|
||||||
// Overriding status from Dart corrupts the UI when native considers the
|
|
||||||
// session still connected (e.g. during a retry after a race condition).
|
|
||||||
rethrow;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,8 +146,6 @@ 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) {
|
||||||
@@ -240,22 +210,4 @@ 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-63
@@ -73,11 +73,12 @@ class MainPage extends StatefulWidget {
|
|||||||
State<MainPage> createState() => _MainPageState();
|
State<MainPage> createState() => _MainPageState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _MainPageState extends State<MainPage> with WidgetsBindingObserver {
|
class _MainPageState extends State<MainPage> {
|
||||||
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;
|
||||||
|
|
||||||
@@ -132,33 +133,11 @@ class _MainPageState extends State<MainPage> with WidgetsBindingObserver {
|
|||||||
@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 {
|
||||||
@@ -193,27 +172,15 @@ class _MainPageState extends State<MainPage> with WidgetsBindingObserver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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 &&
|
||||||
_pendingRemotePlay &&
|
(!_castStreamStartedForActiveSession || _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) {
|
||||||
currentStatus != CastingStatus.connecting) {
|
_castStreamStartedForActiveSession = false;
|
||||||
|
_isStartingCastStream = false;
|
||||||
_pendingRemotePlay = false;
|
_pendingRemotePlay = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,24 +203,21 @@ class _MainPageState extends State<MainPage> with WidgetsBindingObserver {
|
|||||||
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 (_) {
|
||||||
_pendingRemotePlay = !_audio.isStopped;
|
_castStreamStartedForActiveSession = false;
|
||||||
|
// 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;
|
||||||
@@ -263,7 +227,12 @@ class _MainPageState extends State<MainPage> with WidgetsBindingObserver {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
_pendingRemotePlay = false;
|
if (_usesExternalCastPlayback &&
|
||||||
|
_casting.status == CastingStatus.connecting) {
|
||||||
|
_pendingRemotePlay = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await _audio.play();
|
await _audio.play();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -282,21 +251,7 @@ class _MainPageState extends State<MainPage> with WidgetsBindingObserver {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final shouldAutoplay = !_audio.isStopped;
|
await _casting.showDevicePicker();
|
||||||
// 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() {
|
||||||
@@ -335,7 +290,6 @@ class _MainPageState extends State<MainPage> with WidgetsBindingObserver {
|
|||||||
|
|
||||||
@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);
|
||||||
@@ -458,7 +412,8 @@ class _MainPageState extends State<MainPage> with WidgetsBindingObserver {
|
|||||||
),
|
),
|
||||||
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