Skip to content

Commit 0a88d14

Browse files
CollotsSpotclaude
andcommitted
Fix AA disconnect: detect via display removal, prevent auto-resume
Android Auto on projection-mode devices (com.google.android.projection.gearhead) doesn't trigger ACTION_EXIT_CAR_MODE, so the disconnect was never detected. - Add DisplayManager.DisplayListener to detect AA disconnect when virtual displays are removed (projection mode) - Add bidirectional method channel so Dart notifies native of AA connection - Add onAADisconnected callback that force-pauses (not toggles) the player - Suppress Sendspin stream/start for 2s after disconnect to handle the race where the server sends audio before processing our pause command - Guard interruption begin handler to only pause/toggle when actually playing, preventing the interrupted-while-paused -> accidental resume bug Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent c3f6710 commit 0a88d14

3 files changed

Lines changed: 85 additions & 5 deletions

File tree

android/app/src/main/kotlin/com/collotsspot/ensemble/MainActivity.kt

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,15 @@ import android.content.Context
55
import android.content.Intent
66
import android.content.IntentFilter
77
import android.database.ContentObserver
8+
import android.hardware.display.DisplayManager
89
import android.media.AudioManager
910
import android.os.Build
1011
import android.os.Handler
1112
import android.os.Looper
1213
import android.provider.Settings
1314
import android.app.UiModeManager
1415
import android.util.Log
16+
import android.view.Display
1517
import android.view.KeyEvent
1618
import com.ryanheise.audioservice.AudioServiceActivity
1719
import io.flutter.embedding.engine.FlutterEngine
@@ -38,6 +40,10 @@ class MainActivity: AudioServiceActivity() {
3840
// Guard flag to ignore volume changes triggered by our own setStreamVolume calls
3941
private var ignoringVolumeChange = false
4042

43+
// Android Auto projection tracking via display listener
44+
private var isAATracked = false
45+
private var displayListener: DisplayManager.DisplayListener? = null
46+
4147
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
4248
super.configureFlutterEngine(flutterEngine)
4349
Log.d(TAG, "Configuring Flutter engine, setting up MethodChannel")
@@ -85,11 +91,25 @@ class MainActivity: AudioServiceActivity() {
8591

8692
// Android Auto / car mode detection
8793
aaChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, AA_CHANNEL)
94+
95+
// Receive AA connection signal from Dart side
96+
aaChannel?.setMethodCallHandler { call, result ->
97+
when (call.method) {
98+
"notifyAAConnected" -> {
99+
Log.d(TAG, "AA tracked via Dart notifyAAConnected")
100+
isAATracked = true
101+
result.success(null)
102+
}
103+
else -> result.notImplemented()
104+
}
105+
}
106+
88107
carModeReceiver = object : BroadcastReceiver() {
89108
override fun onReceive(context: Context, intent: Intent) {
90109
when (intent.action) {
91110
UiModeManager.ACTION_ENTER_CAR_MODE -> {
92111
Log.d(TAG, "Car mode ENTERED")
112+
isAATracked = true
93113
aaChannel?.invokeMethod("onAndroidAutoConnected", null)
94114
}
95115
UiModeManager.ACTION_EXIT_CAR_MODE -> {
@@ -108,6 +128,25 @@ class MainActivity: AudioServiceActivity() {
108128
} else {
109129
applicationContext.registerReceiver(carModeReceiver, carModeFilter)
110130
}
131+
132+
// Detect AA disconnect via display removal (projection mode)
133+
val dm = getSystemService(Context.DISPLAY_SERVICE) as DisplayManager
134+
displayListener = object : DisplayManager.DisplayListener {
135+
override fun onDisplayAdded(displayId: Int) {}
136+
override fun onDisplayChanged(displayId: Int) {}
137+
override fun onDisplayRemoved(displayId: Int) {
138+
if (!isAATracked) return
139+
val hasExtraDisplay = dm.displays.any {
140+
it.displayId != Display.DEFAULT_DISPLAY
141+
}
142+
if (!hasExtraDisplay) {
143+
Log.d(TAG, "AA projection ended (all extra displays removed)")
144+
isAATracked = false
145+
aaChannel?.invokeMethod("onAndroidAutoDisconnected", null)
146+
}
147+
}
148+
}
149+
dm.registerDisplayListener(displayListener, Handler(Looper.getMainLooper()))
111150
}
112151

113152
/// Start observing system STREAM_MUSIC volume changes.
@@ -261,6 +300,10 @@ class MainActivity: AudioServiceActivity() {
261300
carModeReceiver?.let {
262301
try { applicationContext.unregisterReceiver(it) } catch (_: Exception) {}
263302
}
303+
displayListener?.let {
304+
val dm = getSystemService(Context.DISPLAY_SERVICE) as DisplayManager
305+
dm.unregisterDisplayListener(it)
306+
}
264307
super.onDestroy()
265308
}
266309
}

lib/providers/music_assistant_provider.dart

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2130,6 +2130,13 @@ class MusicAssistantProvider with ChangeNotifier {
21302130
audioHandler.onBrowseActivity = () {
21312131
_cancelIdleServiceTimer();
21322132
};
2133+
audioHandler.onAADisconnected = () {
2134+
_logger.log('🎵 AA disconnected: force-pausing player');
2135+
final player = _selectedPlayer;
2136+
if (player != null) {
2137+
pausePlayer(player.playerId);
2138+
}
2139+
};
21332140

21342141
// Player registration is now handled in _initializeAfterConnection()
21352142
// which runs after authentication completes (when auth is required)
@@ -2557,6 +2564,11 @@ class MusicAssistantProvider with ChangeNotifier {
25572564
/// 2. Start the foreground service to prevent background throttling
25582565
/// 3. Reset position for new track and start position timer
25592566
void _handleSendspinStreamStart(Map<String, dynamic>? trackInfo) async {
2567+
final aaDisc = audioHandler.aaDisconnectedAt;
2568+
if (aaDisc != null && DateTime.now().difference(aaDisc).inSeconds < 2) {
2569+
_logger.log('🎵 Sendspin: Ignoring stream/start (AA disconnected ${DateTime.now().difference(aaDisc).inMilliseconds}ms ago)');
2570+
return;
2571+
}
25602572
_logger.log('🎵 Sendspin: Stream starting');
25612573

25622574
// Ensure PCM player is initialized and ready

lib/services/audio/massiv_audio_handler.dart

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler
3737
Function()? onPause;
3838
Function()? onSwitchPlayer;
3939
Function()? onBrowseActivity;
40+
Function()? onAADisconnected;
4041

4142
// Track whether we're in remote control mode (controlling MA player, not playing locally)
4243
bool _isRemoteMode = false;
@@ -65,6 +66,15 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler
6566
// Suppress auto-resume after audio route changes (e.g. BT/AA disconnect)
6667
bool _suppressResume = false;
6768

69+
// Track whether music was actually playing before an interruption
70+
bool _wasPlayingBeforeInterruption = false;
71+
72+
// Timestamp of last AA disconnect — used to suppress stream/start race condition
73+
DateTime? aaDisconnectedAt;
74+
75+
// Android Auto method channel (Dart ↔ Native)
76+
static const _aaChannel = MethodChannel('com.collotsspot.ensemble/android_auto');
77+
6878
// Custom control for switching players
6979
static final _switchPlayerControl = MediaControl.custom(
7080
androidIcon: 'drawable/ic_switch_player',
@@ -94,8 +104,11 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler
94104
break;
95105
case AudioInterruptionType.pause:
96106
case AudioInterruptionType.unknown:
97-
_player.pause();
98-
onPause?.call();
107+
_wasPlayingBeforeInterruption = playbackState.value.playing;
108+
if (_wasPlayingBeforeInterruption) {
109+
_player.pause();
110+
onPause?.call();
111+
}
99112
break;
100113
}
101114
} else {
@@ -109,6 +122,10 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler
109122
_suppressResume = false;
110123
break;
111124
}
125+
if (!_wasPlayingBeforeInterruption) {
126+
_logger.log('🔊 Audio interruption ended but was not playing before');
127+
break;
128+
}
112129
_player.play();
113130
onPlay?.call();
114131
break;
@@ -138,17 +155,19 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler
138155
});
139156

140157
// Listen for Android Auto connection events from native side
141-
const aaChannel = MethodChannel('com.collotsspot.ensemble/android_auto');
142-
aaChannel.setMethodCallHandler((call) async {
158+
_aaChannel.setMethodCallHandler((call) async {
143159
switch (call.method) {
144160
case 'onAndroidAutoConnected':
145161
_logger.log('AndroidAuto: car mode connected (broadcast)');
146162
_isAndroidAutoConnected = true;
163+
_aaChannel.invokeMethod('notifyAAConnected', null);
147164
_refreshPlaybackState();
148165
case 'onAndroidAutoDisconnected':
149166
_logger.log('AndroidAuto: car mode disconnected (broadcast)');
150167
_isAndroidAutoConnected = false;
151168
_suppressResume = true;
169+
aaDisconnectedAt = DateTime.now();
170+
onAADisconnected?.call();
152171
_refreshPlaybackState();
153172
}
154173
});
@@ -203,7 +222,6 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler
203222

204223
@override
205224
Future<void> play() async {
206-
_suppressResume = false;
207225
// Always prefer callbacks — they handle both remote players and
208226
// Sendspin PCM (which uses local mode but delegates playback to MA server)
209227
if (onPlay != null) {
@@ -349,6 +367,12 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler
349367
final current = playbackState.value;
350368
playbackState.add(current.copyWith(
351369
controls: _controls,
370+
systemActions: const {
371+
MediaAction.play,
372+
MediaAction.pause,
373+
MediaAction.skipToNext,
374+
MediaAction.skipToPrevious,
375+
},
352376
));
353377
}
354378

@@ -676,6 +700,7 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler
676700
if (!_isAndroidAutoConnected) {
677701
_logger.log('AndroidAuto: detected AA connection via getChildren');
678702
_isAndroidAutoConnected = true;
703+
_aaChannel.invokeMethod('notifyAAConnected', null);
679704
_refreshPlaybackState();
680705
}
681706
final playerId = await SettingsService.getBuiltinPlayerId();

0 commit comments

Comments
 (0)