From a2b5da36699a94b416c3c6f2627104e717bb10ae Mon Sep 17 00:00:00 2001 From: kfj001 Date: Sat, 16 May 2026 23:45:11 -0700 Subject: [PATCH] corrects problem where google cast was disconnected from player state when app launched from head unit --- .../KryzAutoMediaBrowserService.kt | 27 ++ .../example/kryz_go_flutter/MainActivity.kt | 245 ++++++++++++++++-- lib/casting_business_object.dart | 54 +++- lib/main.dart | 81 ++++-- 4 files changed, 361 insertions(+), 46 deletions(-) diff --git a/android/app/src/main/kotlin/com/example/kryz_go_flutter/KryzAutoMediaBrowserService.kt b/android/app/src/main/kotlin/com/example/kryz_go_flutter/KryzAutoMediaBrowserService.kt index e15cd3f..aa9c67d 100644 --- a/android/app/src/main/kotlin/com/example/kryz_go_flutter/KryzAutoMediaBrowserService.kt +++ b/android/app/src/main/kotlin/com/example/kryz_go_flutter/KryzAutoMediaBrowserService.kt @@ -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 { diff --git a/android/app/src/main/kotlin/com/example/kryz_go_flutter/MainActivity.kt b/android/app/src/main/kotlin/com/example/kryz_go_flutter/MainActivity.kt index 7edd5f1..c86c0e7 100644 --- a/android/app/src/main/kotlin/com/example/kryz_go_flutter/MainActivity.kt +++ b/android/app/src/main/kotlin/com/example/kryz_go_flutter/MainActivity.kt @@ -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 = 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, diff --git a/lib/casting_business_object.dart b/lib/casting_business_object.dart index c3c1dd7..b60e7f9 100644 --- a/lib/casting_business_object.dart +++ b/lib/casting_business_object.dart @@ -20,6 +20,9 @@ class CastingBusinessObject extends ChangeNotifier { CastingPlaybackStatus _playbackStatus = CastingPlaybackStatus.unknown; String? _connectedDeviceName; String? _lastError; + + /// Callback for when native side requests local audio to stop + VoidCallback? onStopLocalAudioRequested; CastingStatus get status => _status; CastingPlaybackStatus get playbackStatus => _playbackStatus; @@ -55,6 +58,18 @@ class CastingBusinessObject extends ChangeNotifier { }, ); + // Set up handler for native method calls (e.g., stopLocalAudio) + _methodChannel.setMethodCallHandler(_handleMethodCall); + + await refreshStatus(); + } + + Future refreshStatus() async { + if (!isSupportedOnThisPlatform) { + _setStatus(CastingStatus.unavailable); + return; + } + try { final dynamic status = await _methodChannel.invokeMethod( 'getStatus', @@ -66,7 +81,12 @@ class CastingBusinessObject extends ChangeNotifier { } } - Future showDevicePicker() async { + Future showDevicePicker({ + bool autoplay = false, + String? streamUrl, + String? title, + String? subtitle, + }) async { if (!isSupportedOnThisPlatform) { _setStatus(CastingStatus.unavailable); return; @@ -77,7 +97,12 @@ class CastingBusinessObject extends ChangeNotifier { } try { - await _methodChannel.invokeMethod('showDevicePicker'); + await _methodChannel.invokeMethod('showDevicePicker', { + 'autoplay': autoplay, + 'streamUrl': streamUrl, + 'title': title, + 'subtitle': subtitle, + }); } on PlatformException catch (error) { _lastError = error.message ?? error.code; _setStatus(CastingStatus.error); @@ -102,7 +127,10 @@ class CastingBusinessObject extends ChangeNotifier { }); } on PlatformException catch (error) { _lastError = error.message ?? error.code; - _setStatus(CastingStatus.error); + // Do NOT set status to error here — native emits its own status events. + // Overriding status from Dart corrupts the UI when native considers the + // session still connected (e.g. during a retry after a race condition). + rethrow; } } @@ -146,6 +174,8 @@ class CastingBusinessObject extends ChangeNotifier { if (deviceName != null && deviceName.isNotEmpty) { _connectedDeviceName = deviceName; + } else if (status != 'connected') { + _connectedDeviceName = null; } if (message != null && message.isNotEmpty) { @@ -210,4 +240,22 @@ class CastingBusinessObject extends ChangeNotifier { _eventSubscription?.cancel(); super.dispose(); } + + /// Handles method calls from native code + Future _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; + } + } } diff --git a/lib/main.dart b/lib/main.dart index ad20574..546ed90 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -73,12 +73,11 @@ class MainPage extends StatefulWidget { State createState() => _MainPageState(); } -class _MainPageState extends State { +class _MainPageState extends State with WidgetsBindingObserver { StreamingAudioBusinessObject get _audio => widget.audio; CastingBusinessObject get _casting => widget.casting; final LiveInfoBusinessObject _liveInfo = LiveInfoBusinessObject(); static const List _intervalOptions = [5, 10, 15, 30, 60]; - bool _castStreamStartedForActiveSession = false; bool _isStartingCastStream = false; bool _pendingRemotePlay = false; @@ -133,11 +132,33 @@ class _MainPageState extends State { @override void initState() { super.initState(); + WidgetsBinding.instance.addObserver(this); _audio.addListener(_handleAudioStateChanged); _casting.addListener(_handleCastingStateChanged); _liveInfo.addListener(_handleLiveInfoChanged); _liveInfo.start(); _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 _handlePlaybackMethodCall(MethodCall call) async { @@ -172,15 +193,27 @@ class _MainPageState extends State { } void _handleCastingStateChanged() { + final currentStatus = _casting.status; + + if (_casting.isPlaying || _casting.isBuffering) { + _pendingRemotePlay = false; + } + + if (currentStatus != CastingStatus.connected) { + _isStartingCastStream = false; + } + if (_usesExternalCastPlayback && - (!_castStreamStartedForActiveSession || _pendingRemotePlay) && + _pendingRemotePlay && !_isStartingCastStream) { + // Only load when _pendingRemotePlay was set by _play() while connecting. + // Do NOT trigger on justConnected — native pendingCastRequest handles + // all autoplay from showDevicePicker(autoplay: true). _startCastingLiveStream(); } - if (!_usesExternalCastPlayback) { - _castStreamStartedForActiveSession = false; - _isStartingCastStream = false; + if (!_usesExternalCastPlayback && + currentStatus != CastingStatus.connecting) { _pendingRemotePlay = false; } @@ -203,21 +236,24 @@ class _MainPageState extends State { subtitle: _audio.currentSubtitle, ); - _castStreamStartedForActiveSession = true; _pendingRemotePlay = false; if (_usesExternalCastPlayback && !_audio.isStopped) { await _audio.stop(); } } catch (_) { - _castStreamStartedForActiveSession = false; - // State is already updated by the casting business object. + _pendingRemotePlay = !_audio.isStopped; } finally { _isStartingCastStream = false; } } Future _play() async { + if (_casting.status == CastingStatus.connecting) { + _pendingRemotePlay = true; + return; + } + if (_usesExternalCastPlayback) { if (_casting.isPlaying || _isStartingCastStream) { return; @@ -227,12 +263,7 @@ class _MainPageState extends State { return; } - if (_usesExternalCastPlayback && - _casting.status == CastingStatus.connecting) { - _pendingRemotePlay = true; - return; - } - + _pendingRemotePlay = false; await _audio.play(); } @@ -251,7 +282,21 @@ class _MainPageState extends State { 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() { @@ -290,6 +335,7 @@ class _MainPageState extends State { @override void dispose() { + WidgetsBinding.instance.removeObserver(this); _audio.removeListener(_handleAudioStateChanged); _casting.removeListener(_handleCastingStateChanged); _liveInfo.removeListener(_handleLiveInfoChanged); @@ -412,8 +458,7 @@ class _MainPageState extends State { ), ElevatedButton.icon( onPressed: - _casting.status == CastingStatus.connecting || - _casting.status == CastingStatus.unavailable + _casting.status == CastingStatus.connecting ? null : _toggleCasting, icon: Icon(_castingIcon()), -- 2.54.0