corrects problem where google cast was disconnected from player state when app launched from head unit #7

Merged
kfj001 merged 1 commits from fix_cast into main 2026-05-17 14:21:45 -07:00
4 changed files with 361 additions and 46 deletions
Showing only changes of commit a2b5da3669 - Show all commits
@@ -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 {
@@ -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")
} }
} }
@@ -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,
+51 -3
View File
@@ -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;
}
}
} }
+63 -18
View File
@@ -73,12 +73,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 +132,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 +193,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 +236,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 +263,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 +282,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 +335,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);
@@ -412,8 +458,7 @@ class _MainPageState extends State<MainPage> {
), ),
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()),