Skip to content

Commit 753f516

Browse files
CollotsSpotclaude
andcommitted
Fix AA reconnect race condition and audio focus handling
Make checkAndReconnect idempotent: concurrent callers now join the existing reconnect attempt instead of each creating a new WebSocket connection. This prevents the cascading "API disposed" errors seen when Android Auto browses multiple categories simultaneously. Activate audio session when Sendspin/remote playback starts so Android delivers audio focus interruption events. Ensemble now pauses when another app (Spotify, YouTube, etc.) takes audio focus, and resumes when focus returns. Remove switch-player button from Android Auto controls (not useful in car context). Fixes #25 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent e4cdb40 commit 753f516

2 files changed

Lines changed: 79 additions & 50 deletions

File tree

lib/providers/music_assistant_provider.dart

Lines changed: 60 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@ class MusicAssistantProvider with ChangeNotifier {
117117
TrackMetadata? _pendingTrackMetadata;
118118
TrackMetadata? _currentNotificationMetadata;
119119
Completer<void>? _registrationInProgress;
120+
Completer<void>? _reconnectInProgress;
120121

121122
// Local player service
122123
late final LocalPlayerService _localPlayer;
@@ -1946,67 +1947,83 @@ class MusicAssistantProvider with ChangeNotifier {
19461947
}
19471948

19481949
Future<void> checkAndReconnect() async {
1950+
// Deduplicate: if a reconnect is already in progress, join it
1951+
if (_reconnectInProgress != null) {
1952+
_logger.log('🔄 checkAndReconnect called — already in progress, joining existing attempt');
1953+
return _reconnectInProgress!.future;
1954+
}
1955+
19491956
_logger.log('🔄 checkAndReconnect called - state: $_connectionState');
19501957

19511958
if (_serverUrl == null) {
19521959
_logger.log('🔄 No server URL saved, skipping reconnect');
19531960
return;
19541961
}
19551962

1956-
// IMMEDIATELY load cached players for instant UI display
1957-
// This makes mini player and device button appear instantly on app resume
1958-
if (_availablePlayers.isEmpty && _cacheService.hasCachedPlayers) {
1959-
_availablePlayers = _cacheService.getCachedPlayers()!;
1963+
_reconnectInProgress = Completer<void>();
19601964

1961-
// Sort cached players immediately so list appears in correct order
1962-
final smartSort = await SettingsService.getSmartSortPlayers();
1963-
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
1964-
_sortPlayersSync(_availablePlayers, smartSort, builtinPlayerId);
1965+
try {
1966+
// IMMEDIATELY load cached players for instant UI display
1967+
// This makes mini player and device button appear instantly on app resume
1968+
if (_availablePlayers.isEmpty && _cacheService.hasCachedPlayers) {
1969+
_availablePlayers = _cacheService.getCachedPlayers()!;
19651970

1966-
_selectedPlayer = _cacheService.getCachedSelectedPlayer();
1967-
// Also try to restore from settings if cache doesn't have selected player
1968-
if (_selectedPlayer == null && _availablePlayers.isNotEmpty) {
1969-
final lastSelectedPlayerId = await SettingsService.getLastSelectedPlayerId();
1970-
if (lastSelectedPlayerId != null) {
1971-
try {
1972-
_selectedPlayer = _availablePlayers.firstWhere(
1973-
(p) => p.playerId == lastSelectedPlayerId,
1974-
);
1975-
} catch (e) {
1971+
// Sort cached players immediately so list appears in correct order
1972+
final smartSort = await SettingsService.getSmartSortPlayers();
1973+
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
1974+
_sortPlayersSync(_availablePlayers, smartSort, builtinPlayerId);
1975+
1976+
_selectedPlayer = _cacheService.getCachedSelectedPlayer();
1977+
// Also try to restore from settings if cache doesn't have selected player
1978+
if (_selectedPlayer == null && _availablePlayers.isNotEmpty) {
1979+
final lastSelectedPlayerId = await SettingsService.getLastSelectedPlayerId();
1980+
if (lastSelectedPlayerId != null) {
1981+
try {
1982+
_selectedPlayer = _availablePlayers.firstWhere(
1983+
(p) => p.playerId == lastSelectedPlayerId,
1984+
);
1985+
} catch (e) {
1986+
_selectedPlayer = _availablePlayers.first;
1987+
}
1988+
} else {
19761989
_selectedPlayer = _availablePlayers.first;
19771990
}
1978-
} else {
1979-
_selectedPlayer = _availablePlayers.first;
19801991
}
1992+
_logger.log('⚡ Loaded ${_availablePlayers.length} cached players instantly (sorted)');
1993+
notifyListeners(); // Update UI immediately with cached data
19811994
}
1982-
_logger.log('⚡ Loaded ${_availablePlayers.length} cached players instantly (sorted)');
1983-
notifyListeners(); // Update UI immediately with cached data
1984-
}
19851995

1986-
if (_connectionState != MAConnectionState.connected &&
1987-
_connectionState != MAConnectionState.authenticated) {
1988-
_logger.log('🔄 Not connected, attempting reconnect to $_serverUrl');
1989-
try {
1990-
await connectToServer(_serverUrl!);
1991-
_logger.log('🔄 Reconnection successful');
1992-
} catch (e) {
1993-
_logger.log('🔄 Reconnection failed: $e');
1994-
}
1995-
} else {
1996-
_logger.log('🔄 Already connected, verifying connection...');
1997-
try {
1998-
await refreshPlayers();
1999-
await _updatePlayerState();
2000-
// Note: _preloadAdjacentPlayers is already called in refreshPlayers() -> _loadAndSelectPlayers()
2001-
_logger.log('🔄 Connection verified, players and state refreshed');
2002-
} catch (e) {
2003-
_logger.log('🔄 Connection verification failed, reconnecting: $e');
1996+
if (_connectionState != MAConnectionState.connected &&
1997+
_connectionState != MAConnectionState.authenticated) {
1998+
_logger.log('🔄 Not connected, attempting reconnect to $_serverUrl');
20041999
try {
20052000
await connectToServer(_serverUrl!);
2006-
} catch (reconnectError) {
2007-
_logger.log('🔄 Reconnection failed: $reconnectError');
2001+
_logger.log('🔄 Reconnection successful');
2002+
} catch (e) {
2003+
_logger.log('🔄 Reconnection failed: $e');
2004+
}
2005+
} else {
2006+
_logger.log('🔄 Already connected, verifying connection...');
2007+
try {
2008+
await refreshPlayers();
2009+
await _updatePlayerState();
2010+
// Note: _preloadAdjacentPlayers is already called in refreshPlayers() -> _loadAndSelectPlayers()
2011+
_logger.log('🔄 Connection verified, players and state refreshed');
2012+
} catch (e) {
2013+
_logger.log('🔄 Connection verification failed, reconnecting: $e');
2014+
try {
2015+
await connectToServer(_serverUrl!);
2016+
} catch (reconnectError) {
2017+
_logger.log('🔄 Reconnection failed: $reconnectError');
2018+
}
20082019
}
20092020
}
2021+
2022+
_reconnectInProgress!.complete();
2023+
} catch (e) {
2024+
_reconnectInProgress!.completeError(e);
2025+
} finally {
2026+
_reconnectInProgress = null;
20102027
}
20112028
}
20122029

lib/services/audio/massiv_audio_handler.dart

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -63,16 +63,19 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler
6363
final session = await AudioSession.instance;
6464
await session.configure(const AudioSessionConfiguration.music());
6565

66-
// Handle audio interruptions
66+
// Handle audio interruptions (e.g., another app takes audio focus)
6767
_interruptionSubscription = session.interruptionEventStream.listen((event) {
68+
_logger.log('🔊 Audio interruption: begin=${event.begin}, type=${event.type}');
6869
if (event.begin) {
6970
switch (event.type) {
7071
case AudioInterruptionType.duck:
7172
_player.setVolume(0.5);
7273
break;
7374
case AudioInterruptionType.pause:
7475
case AudioInterruptionType.unknown:
75-
pause();
76+
// Pause both just_audio and Sendspin/remote player
77+
_player.pause();
78+
onPause?.call();
7679
break;
7780
}
7881
} else {
@@ -81,7 +84,9 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler
8184
_player.setVolume(1.0);
8285
break;
8386
case AudioInterruptionType.pause:
84-
play();
87+
// Resume both just_audio and Sendspin/remote player
88+
_player.play();
89+
onPlay?.call();
8590
break;
8691
case AudioInterruptionType.unknown:
8792
break;
@@ -115,7 +120,6 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler
115120
MediaControl.skipToPrevious,
116121
if (playing) MediaControl.pause else MediaControl.play,
117122
MediaControl.skipToNext,
118-
_switchPlayerControl, // Switch player button
119123
],
120124
// System-level actions (for headphones, car stereos, etc.)
121125
systemActions: const {
@@ -238,8 +242,13 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler
238242
required bool playing,
239243
Duration position = Duration.zero,
240244
Duration? duration,
241-
}) {
245+
}) async {
242246
_isRemoteMode = true;
247+
// Activate audio session to request focus and receive interruption events
248+
if (playing) {
249+
final session = await AudioSession.instance;
250+
await session.setActive(true);
251+
}
243252
_currentMediaItem = item;
244253
mediaItem.add(item);
245254

@@ -248,7 +257,6 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler
248257
MediaControl.skipToPrevious,
249258
if (playing) MediaControl.pause else MediaControl.play,
250259
MediaControl.skipToNext,
251-
_switchPlayerControl,
252260
],
253261
systemActions: const {
254262
MediaAction.play,
@@ -269,7 +277,7 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler
269277
}
270278

271279
/// Clear remote playback state and hide notification
272-
void clearRemotePlaybackState() {
280+
void clearRemotePlaybackState() async {
273281
_isRemoteMode = false;
274282
_currentMediaItem = null;
275283

@@ -278,6 +286,10 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler
278286
processingState: AudioProcessingState.idle,
279287
playing: false,
280288
));
289+
290+
// Release audio focus
291+
final session = await AudioSession.instance;
292+
await session.setActive(false);
281293
}
282294

283295
/// Switch to local playback mode (when builtin player is selected)

0 commit comments

Comments
 (0)