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

This commit is contained in:
2026-05-16 23:45:11 -07:00
parent 1c7e27b83d
commit a2b5da3669
4 changed files with 361 additions and 46 deletions
+63 -18
View File
@@ -73,12 +73,11 @@ class MainPage extends StatefulWidget {
State<MainPage> createState() => _MainPageState();
}
class _MainPageState extends State<MainPage> {
class _MainPageState extends State<MainPage> with WidgetsBindingObserver {
StreamingAudioBusinessObject get _audio => widget.audio;
CastingBusinessObject get _casting => widget.casting;
final LiveInfoBusinessObject _liveInfo = LiveInfoBusinessObject();
static const List<int> _intervalOptions = <int>[5, 10, 15, 30, 60];
bool _castStreamStartedForActiveSession = false;
bool _isStartingCastStream = false;
bool _pendingRemotePlay = false;
@@ -133,11 +132,33 @@ class _MainPageState extends State<MainPage> {
@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<void> _handlePlaybackMethodCall(MethodCall call) async {
@@ -172,15 +193,27 @@ class _MainPageState extends State<MainPage> {
}
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<MainPage> {
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<void> _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<MainPage> {
return;
}
if (_usesExternalCastPlayback &&
_casting.status == CastingStatus.connecting) {
_pendingRemotePlay = true;
return;
}
_pendingRemotePlay = false;
await _audio.play();
}
@@ -251,7 +282,21 @@ class _MainPageState extends State<MainPage> {
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<MainPage> {
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_audio.removeListener(_handleAudioStateChanged);
_casting.removeListener(_handleCastingStateChanged);
_liveInfo.removeListener(_handleLiveInfoChanged);
@@ -412,8 +458,7 @@ class _MainPageState extends State<MainPage> {
),
ElevatedButton.icon(
onPressed:
_casting.status == CastingStatus.connecting ||
_casting.status == CastingStatus.unavailable
_casting.status == CastingStatus.connecting
? null
: _toggleCasting,
icon: Icon(_castingIcon()),