From ad5ecf59b0a80ecc2957948ca79e537eb6caf438 Mon Sep 17 00:00:00 2001 From: Chris Date: Sun, 22 Feb 2026 10:05:31 +0000 Subject: [PATCH 01/19] Fix Android Auto images and player background flickering Fix imageproxy URLs using wrong port: Dart's Uri.hasPort returns false for default ports (80/443) even when explicitly specified, causing fallback to port 8095. Use raw string check instead. Fix player background flash: use theme surface color as fallback instead of hardcoded black, and add race guard to color extraction to prevent stale results on rapid track changes. Fixes #25, fixes #73 Co-Authored-By: Claude Opus 4.6 --- lib/services/music_assistant_api.dart | 16 ++++++++++++---- lib/widgets/expandable_player.dart | 9 +++++++-- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/lib/services/music_assistant_api.dart b/lib/services/music_assistant_api.dart index 08c30a5..05875c0 100644 --- a/lib/services/music_assistant_api.dart +++ b/lib/services/music_assistant_api.dart @@ -68,6 +68,14 @@ class MusicAssistantAPI { MusicAssistantAPI(this.serverUrl, this.authManager); + /// Check if a URL string has an explicit port (e.g., ":80", ":8095"). + /// Dart's Uri.hasPort returns false for default ports (80/443) even when + /// explicitly specified, so we check the raw string instead. + bool _hasExplicitPort(String url) { + final withoutScheme = url.replaceFirst(RegExp(r'^https?://'), ''); + return RegExp(r'^[^/:]+:\d+').hasMatch(withoutScheme); + } + // Guard to prevent multiple simultaneous connection attempts Completer? _connectionInProgress; bool _isDisposed = false; @@ -145,7 +153,7 @@ class MusicAssistantAPI { queryParameters: {'client_id': clientId}, ); _logger.log('Using custom WebSocket port from settings: $_cachedCustomPort'); - } else if (uri.hasPort) { + } else if (_hasExplicitPort(serverUrl)) { // Port is already specified in URL, keep it finalUri = Uri( scheme: uri.scheme, @@ -2484,7 +2492,7 @@ class MusicAssistantAPI { port: _cachedCustomPort, path: '/flow/$playerId/$streamId.$extension', ); - } else if (uri.hasPort) { + } else if (_hasExplicitPort(serverUrl)) { // Use port from URL finalUri = Uri( scheme: uri.scheme, @@ -3196,7 +3204,7 @@ class MusicAssistantAPI { if (_cachedCustomPort != null) { // Use cached custom port from settings baseUrl = '${uriObj.scheme}://${uriObj.host}:$_cachedCustomPort'; - } else if (!uriObj.hasPort) { + } else if (!_hasExplicitPort(serverUrl)) { if (useSecure) { // For HTTPS, don't add port 443 (it's the default) baseUrl = '${uriObj.scheme}://${uriObj.host}'; @@ -3303,7 +3311,7 @@ class MusicAssistantAPI { if (_cachedCustomPort != null) { // Use cached custom port from settings baseUrl = '${uri.scheme}://${uri.host}:$_cachedCustomPort'; - } else if (!uri.hasPort) { + } else if (!_hasExplicitPort(serverUrl)) { if (useSecure) { // For HTTPS, don't add port 443 (it's the default) baseUrl = '${uri.scheme}://${uri.host}'; diff --git a/lib/widgets/expandable_player.dart b/lib/widgets/expandable_player.dart index 52089f2..8423196 100644 --- a/lib/widgets/expandable_player.dart +++ b/lib/widgets/expandable_player.dart @@ -81,6 +81,7 @@ class ExpandablePlayerState extends State ColorScheme? _lightColorScheme; ColorScheme? _darkColorScheme; String? _lastImageUrl; + int _colorExtractionId = 0; // Queue state PlayerQueue? _queue; @@ -757,13 +758,17 @@ class ExpandablePlayerState extends State Future _extractColors(String imageUrl) async { if (_lastImageUrl == imageUrl) return; _lastImageUrl = imageUrl; + final extractionId = ++_colorExtractionId; try { final colorSchemes = await PaletteHelper.extractColorSchemes( CachedNetworkImageProvider(imageUrl), ); - if (colorSchemes != null && mounted) { + if (!mounted) return; + if (extractionId != _colorExtractionId) return; + + if (colorSchemes != null) { setState(() { _lightColorScheme = colorSchemes.$1; _darkColorScheme = colorSchemes.$2; @@ -2056,7 +2061,7 @@ class ExpandablePlayerState extends State : colorScheme.primaryContainer; // Create a darker shade for the "unplayed" portion of progress bar final collapsedBgUnplayed = Color.lerp(collapsedBg, Colors.black, 0.3)!; - final expandedBg = adaptiveScheme?.surface ?? const Color(0xFF121212); + final expandedBg = adaptiveScheme?.surface ?? colorScheme.surface; final expandedPrimary = adaptiveScheme?.primary; // Always update to current value - don't preserve stale adaptive colors _currentExpandedBgColor = expandedBg; -- 2.39.5 From e4cdb40fded50c9148f60542f064737b7dff8989 Mon Sep 17 00:00:00 2001 From: Chris Date: Sun, 22 Feb 2026 16:11:22 +0000 Subject: [PATCH 02/19] Fix home screen gradient obscuring third row text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Only show the bottom fade gradient when 4+ rows are enabled, since with ≤3 rows the content doesn't scroll behind the mini player. Reduce gradient height to mini player height only. Co-Authored-By: Claude Opus 4.6 --- lib/screens/new_home_screen.dart | 42 +++++++++++++++----------------- 1 file changed, 20 insertions(+), 22 deletions(-) diff --git a/lib/screens/new_home_screen.dart b/lib/screens/new_home_screen.dart index c154197..72163b2 100644 --- a/lib/screens/new_home_screen.dart +++ b/lib/screens/new_home_screen.dart @@ -430,29 +430,28 @@ class _NewHomeScreenState extends State with AutomaticKeepAliveCl ), ), ), - // Bottom fade gradient to mask content behind mini player - Positioned( - left: 0, - right: 0, - bottom: 0, - height: BottomSpacing.miniPlayerHeight + 22.0, - child: IgnorePointer( - child: DecoratedBox( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - colorScheme.background.withOpacity(0.0), - colorScheme.background.withOpacity(0.25), - colorScheme.background.withOpacity(0.85), - ], - stops: const [0.0, 0.5, 1.0], + // Bottom fade gradient to mask content behind mini player (4+ rows only) + if (_countEnabledRows() >= 4) + Positioned( + left: 0, + right: 0, + bottom: 0, + height: BottomSpacing.miniPlayerHeight, + child: IgnorePointer( + child: DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + colorScheme.background.withOpacity(0.0), + colorScheme.background.withOpacity(0.85), + ], + ), ), ), ), ), - ), ], ); }, @@ -503,9 +502,8 @@ class _NewHomeScreenState extends State with AutomaticKeepAliveCl builder: (context, constraints) { // Each row is always 1/3 of screen height // 1 row = 1/3, 2 rows = 2/3, 3 rows = full screen, 4+ rows scroll - // With extendBody: true, constraints include the area behind the nav bar. - // Subtract mini player overlay space (mini player + its margins sit above the nav bar). - final miniPlayerSpace = BottomSpacing.miniPlayerHeight + 22.0; // 84 + 22 = 106 + // Reserve space for the mini player overlay at the bottom. + final miniPlayerSpace = BottomSpacing.miniPlayerHeight + 22.0; final availableHeight = constraints.maxHeight - miniPlayerSpace; // Account for margins between rows (2px each, 2 margins for 3 rows). -- 2.39.5 From 753f5161fbad04ecf0d7541f720df5f3e0118202 Mon Sep 17 00:00:00 2001 From: Chris Date: Sun, 22 Feb 2026 22:07:17 +0000 Subject: [PATCH 03/19] 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 --- lib/providers/music_assistant_provider.dart | 103 +++++++++++-------- lib/services/audio/massiv_audio_handler.dart | 26 +++-- 2 files changed, 79 insertions(+), 50 deletions(-) diff --git a/lib/providers/music_assistant_provider.dart b/lib/providers/music_assistant_provider.dart index 282d3e3..e7d3f60 100644 --- a/lib/providers/music_assistant_provider.dart +++ b/lib/providers/music_assistant_provider.dart @@ -117,6 +117,7 @@ class MusicAssistantProvider with ChangeNotifier { TrackMetadata? _pendingTrackMetadata; TrackMetadata? _currentNotificationMetadata; Completer? _registrationInProgress; + Completer? _reconnectInProgress; // Local player service late final LocalPlayerService _localPlayer; @@ -1946,6 +1947,12 @@ class MusicAssistantProvider with ChangeNotifier { } Future checkAndReconnect() async { + // Deduplicate: if a reconnect is already in progress, join it + if (_reconnectInProgress != null) { + _logger.log('🔄 checkAndReconnect called — already in progress, joining existing attempt'); + return _reconnectInProgress!.future; + } + _logger.log('🔄 checkAndReconnect called - state: $_connectionState'); if (_serverUrl == null) { @@ -1953,60 +1960,70 @@ class MusicAssistantProvider with ChangeNotifier { return; } - // IMMEDIATELY load cached players for instant UI display - // This makes mini player and device button appear instantly on app resume - if (_availablePlayers.isEmpty && _cacheService.hasCachedPlayers) { - _availablePlayers = _cacheService.getCachedPlayers()!; + _reconnectInProgress = Completer(); - // Sort cached players immediately so list appears in correct order - final smartSort = await SettingsService.getSmartSortPlayers(); - final builtinPlayerId = await SettingsService.getBuiltinPlayerId(); - _sortPlayersSync(_availablePlayers, smartSort, builtinPlayerId); + try { + // IMMEDIATELY load cached players for instant UI display + // This makes mini player and device button appear instantly on app resume + if (_availablePlayers.isEmpty && _cacheService.hasCachedPlayers) { + _availablePlayers = _cacheService.getCachedPlayers()!; - _selectedPlayer = _cacheService.getCachedSelectedPlayer(); - // Also try to restore from settings if cache doesn't have selected player - if (_selectedPlayer == null && _availablePlayers.isNotEmpty) { - final lastSelectedPlayerId = await SettingsService.getLastSelectedPlayerId(); - if (lastSelectedPlayerId != null) { - try { - _selectedPlayer = _availablePlayers.firstWhere( - (p) => p.playerId == lastSelectedPlayerId, - ); - } catch (e) { + // Sort cached players immediately so list appears in correct order + final smartSort = await SettingsService.getSmartSortPlayers(); + final builtinPlayerId = await SettingsService.getBuiltinPlayerId(); + _sortPlayersSync(_availablePlayers, smartSort, builtinPlayerId); + + _selectedPlayer = _cacheService.getCachedSelectedPlayer(); + // Also try to restore from settings if cache doesn't have selected player + if (_selectedPlayer == null && _availablePlayers.isNotEmpty) { + final lastSelectedPlayerId = await SettingsService.getLastSelectedPlayerId(); + if (lastSelectedPlayerId != null) { + try { + _selectedPlayer = _availablePlayers.firstWhere( + (p) => p.playerId == lastSelectedPlayerId, + ); + } catch (e) { + _selectedPlayer = _availablePlayers.first; + } + } else { _selectedPlayer = _availablePlayers.first; } - } else { - _selectedPlayer = _availablePlayers.first; } + _logger.log('⚡ Loaded ${_availablePlayers.length} cached players instantly (sorted)'); + notifyListeners(); // Update UI immediately with cached data } - _logger.log('⚡ Loaded ${_availablePlayers.length} cached players instantly (sorted)'); - notifyListeners(); // Update UI immediately with cached data - } - if (_connectionState != MAConnectionState.connected && - _connectionState != MAConnectionState.authenticated) { - _logger.log('🔄 Not connected, attempting reconnect to $_serverUrl'); - try { - await connectToServer(_serverUrl!); - _logger.log('🔄 Reconnection successful'); - } catch (e) { - _logger.log('🔄 Reconnection failed: $e'); - } - } else { - _logger.log('🔄 Already connected, verifying connection...'); - try { - await refreshPlayers(); - await _updatePlayerState(); - // Note: _preloadAdjacentPlayers is already called in refreshPlayers() -> _loadAndSelectPlayers() - _logger.log('🔄 Connection verified, players and state refreshed'); - } catch (e) { - _logger.log('🔄 Connection verification failed, reconnecting: $e'); + if (_connectionState != MAConnectionState.connected && + _connectionState != MAConnectionState.authenticated) { + _logger.log('🔄 Not connected, attempting reconnect to $_serverUrl'); try { await connectToServer(_serverUrl!); - } catch (reconnectError) { - _logger.log('🔄 Reconnection failed: $reconnectError'); + _logger.log('🔄 Reconnection successful'); + } catch (e) { + _logger.log('🔄 Reconnection failed: $e'); + } + } else { + _logger.log('🔄 Already connected, verifying connection...'); + try { + await refreshPlayers(); + await _updatePlayerState(); + // Note: _preloadAdjacentPlayers is already called in refreshPlayers() -> _loadAndSelectPlayers() + _logger.log('🔄 Connection verified, players and state refreshed'); + } catch (e) { + _logger.log('🔄 Connection verification failed, reconnecting: $e'); + try { + await connectToServer(_serverUrl!); + } catch (reconnectError) { + _logger.log('🔄 Reconnection failed: $reconnectError'); + } } } + + _reconnectInProgress!.complete(); + } catch (e) { + _reconnectInProgress!.completeError(e); + } finally { + _reconnectInProgress = null; } } diff --git a/lib/services/audio/massiv_audio_handler.dart b/lib/services/audio/massiv_audio_handler.dart index cd24642..ca9d918 100644 --- a/lib/services/audio/massiv_audio_handler.dart +++ b/lib/services/audio/massiv_audio_handler.dart @@ -63,8 +63,9 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler final session = await AudioSession.instance; await session.configure(const AudioSessionConfiguration.music()); - // Handle audio interruptions + // Handle audio interruptions (e.g., another app takes audio focus) _interruptionSubscription = session.interruptionEventStream.listen((event) { + _logger.log('🔊 Audio interruption: begin=${event.begin}, type=${event.type}'); if (event.begin) { switch (event.type) { case AudioInterruptionType.duck: @@ -72,7 +73,9 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler break; case AudioInterruptionType.pause: case AudioInterruptionType.unknown: - pause(); + // Pause both just_audio and Sendspin/remote player + _player.pause(); + onPause?.call(); break; } } else { @@ -81,7 +84,9 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler _player.setVolume(1.0); break; case AudioInterruptionType.pause: - play(); + // Resume both just_audio and Sendspin/remote player + _player.play(); + onPlay?.call(); break; case AudioInterruptionType.unknown: break; @@ -115,7 +120,6 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler MediaControl.skipToPrevious, if (playing) MediaControl.pause else MediaControl.play, MediaControl.skipToNext, - _switchPlayerControl, // Switch player button ], // System-level actions (for headphones, car stereos, etc.) systemActions: const { @@ -238,8 +242,13 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler required bool playing, Duration position = Duration.zero, Duration? duration, - }) { + }) async { _isRemoteMode = true; + // Activate audio session to request focus and receive interruption events + if (playing) { + final session = await AudioSession.instance; + await session.setActive(true); + } _currentMediaItem = item; mediaItem.add(item); @@ -248,7 +257,6 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler MediaControl.skipToPrevious, if (playing) MediaControl.pause else MediaControl.play, MediaControl.skipToNext, - _switchPlayerControl, ], systemActions: const { MediaAction.play, @@ -269,7 +277,7 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler } /// Clear remote playback state and hide notification - void clearRemotePlaybackState() { + void clearRemotePlaybackState() async { _isRemoteMode = false; _currentMediaItem = null; @@ -278,6 +286,10 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler processingState: AudioProcessingState.idle, playing: false, )); + + // Release audio focus + final session = await AudioSession.instance; + await session.setActive(false); } /// Switch to local playback mode (when builtin player is selected) -- 2.39.5 From 59d48deaf75e96832db65149d352637b23500607 Mon Sep 17 00:00:00 2001 From: Chris Date: Mon, 23 Feb 2026 21:16:21 +0000 Subject: [PATCH 04/19] Android Auto audit: artwork ContentProvider, queue, search, polish - Add ArtworkContentProvider to serve artwork via content:// URIs (fixes broken images on many AA head units that reject HTTP URLs) - Wrap all artUri sites in handler and provider with content:// URIs - Populate AA play queue after playTracks for queue view support - Wire updateQueueIndex from provider's _updatePlayerState - Add error state communication to AA on playback failures - Rewrite search() to return grouped results (artists, albums, tracks) - Add playFromSearch() for voice command support ("Play X on Ensemble") - Add search race condition guard - Declare SEARCH_SUPPORTED in browsable root extras for search button - Add invalidateAutoChildren for library add/delete events - Add cache size limit (50 entries) with LRU eviction for track cache - Clean up track cache and children subjects in dispose() - Add audiobook completion status extras (progress bars in AA) - Add subtitle link extras on track items (tappable artist) - Reset idle timer on AA browse activity - Fix PCM player auto-recovery triggering on user-initiated pause Co-Authored-By: Claude Opus 4.6 --- android/app/src/main/AndroidManifest.xml | 6 + .../ensemble/ArtworkContentProvider.kt | 84 ++++ .../res/drawable/ic_auto_favorite_off.xml | 9 + .../src/main/res/drawable/ic_auto_repeat.xml | 9 + .../main/res/drawable/ic_auto_repeat_all.xml | 11 + .../main/res/drawable/ic_auto_repeat_one.xml | 11 + .../src/main/res/drawable/ic_auto_shuffle.xml | 9 + .../main/res/drawable/ic_auto_shuffle_on.xml | 11 + lib/main.dart | 3 + lib/providers/music_assistant_provider.dart | 47 +- lib/services/audio/massiv_audio_handler.dart | 447 +++++++++++++++++- lib/services/pcm_audio_player.dart | 7 +- 12 files changed, 615 insertions(+), 39 deletions(-) create mode 100644 android/app/src/main/kotlin/com/collotsspot/ensemble/ArtworkContentProvider.kt create mode 100644 android/app/src/main/res/drawable/ic_auto_favorite_off.xml create mode 100644 android/app/src/main/res/drawable/ic_auto_repeat.xml create mode 100644 android/app/src/main/res/drawable/ic_auto_repeat_all.xml create mode 100644 android/app/src/main/res/drawable/ic_auto_repeat_one.xml create mode 100644 android/app/src/main/res/drawable/ic_auto_shuffle.xml create mode 100644 android/app/src/main/res/drawable/ic_auto_shuffle_on.xml diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 4e59bbf..9d2d064 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -54,6 +54,12 @@ + + diff --git a/android/app/src/main/kotlin/com/collotsspot/ensemble/ArtworkContentProvider.kt b/android/app/src/main/kotlin/com/collotsspot/ensemble/ArtworkContentProvider.kt new file mode 100644 index 0000000..ab8124e --- /dev/null +++ b/android/app/src/main/kotlin/com/collotsspot/ensemble/ArtworkContentProvider.kt @@ -0,0 +1,84 @@ +package com.collotsspot.ensemble + +import android.content.ContentProvider +import android.content.ContentValues +import android.database.Cursor +import android.net.Uri +import android.os.ParcelFileDescriptor +import android.util.Base64 +import java.io.File +import java.net.HttpURLConnection +import java.net.URL +import java.security.MessageDigest + +/** + * ContentProvider that serves artwork images to Android Auto. + * + * Android Auto only supports content:// and android.resource:// URIs for artwork. + * This provider accepts a base64url-encoded HTTP URL in the path, downloads the + * image (with disk caching), and returns a ParcelFileDescriptor. + * + * URI format: content://com.collotsspot.ensemble.artwork/{base64url_encoded_http_url} + */ +class ArtworkContentProvider : ContentProvider() { + + companion object { + private const val CONNECT_TIMEOUT_MS = 5000 + private const val READ_TIMEOUT_MS = 10000 + } + + override fun onCreate(): Boolean = true + + override fun openFile(uri: Uri, mode: String): ParcelFileDescriptor? { + val encoded = uri.lastPathSegment ?: return null + val httpUrl: String + try { + httpUrl = String(Base64.decode(encoded, Base64.URL_SAFE or Base64.NO_WRAP)) + } catch (e: Exception) { + return null + } + + val cacheDir = File(context?.cacheDir, "artwork") + if (!cacheDir.exists()) cacheDir.mkdirs() + + val hash = md5(httpUrl) + val cacheFile = File(cacheDir, hash) + + if (!cacheFile.exists()) { + try { + val connection = URL(httpUrl).openConnection() as HttpURLConnection + connection.connectTimeout = CONNECT_TIMEOUT_MS + connection.readTimeout = READ_TIMEOUT_MS + connection.instanceFollowRedirects = true + try { + connection.inputStream.use { input -> + cacheFile.outputStream().use { output -> + input.copyTo(output) + } + } + } finally { + connection.disconnect() + } + } catch (e: Exception) { + cacheFile.delete() + return null + } + } + + return ParcelFileDescriptor.open(cacheFile, ParcelFileDescriptor.MODE_READ_ONLY) + } + + override fun query(uri: Uri, projection: Array?, selection: String?, + selectionArgs: Array?, sortOrder: String?): Cursor? = null + override fun getType(uri: Uri): String = "image/*" + override fun insert(uri: Uri, values: ContentValues?): Uri? = null + override fun delete(uri: Uri, selection: String?, selectionArgs: Array?): Int = 0 + override fun update(uri: Uri, values: ContentValues?, selection: String?, + selectionArgs: Array?): Int = 0 + + private fun md5(input: String): String { + val digest = MessageDigest.getInstance("MD5") + val bytes = digest.digest(input.toByteArray()) + return bytes.joinToString("") { "%02x".format(it) } + } +} diff --git a/android/app/src/main/res/drawable/ic_auto_favorite_off.xml b/android/app/src/main/res/drawable/ic_auto_favorite_off.xml new file mode 100644 index 0000000..d6e9f04 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_auto_favorite_off.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_auto_repeat.xml b/android/app/src/main/res/drawable/ic_auto_repeat.xml new file mode 100644 index 0000000..19db233 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_auto_repeat.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_auto_repeat_all.xml b/android/app/src/main/res/drawable/ic_auto_repeat_all.xml new file mode 100644 index 0000000..6782e1f --- /dev/null +++ b/android/app/src/main/res/drawable/ic_auto_repeat_all.xml @@ -0,0 +1,11 @@ + + + + diff --git a/android/app/src/main/res/drawable/ic_auto_repeat_one.xml b/android/app/src/main/res/drawable/ic_auto_repeat_one.xml new file mode 100644 index 0000000..83343c1 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_auto_repeat_one.xml @@ -0,0 +1,11 @@ + + + + diff --git a/android/app/src/main/res/drawable/ic_auto_shuffle.xml b/android/app/src/main/res/drawable/ic_auto_shuffle.xml new file mode 100644 index 0000000..9a243e6 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_auto_shuffle.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_auto_shuffle_on.xml b/android/app/src/main/res/drawable/ic_auto_shuffle_on.xml new file mode 100644 index 0000000..7aeca1f --- /dev/null +++ b/android/app/src/main/res/drawable/ic_auto_shuffle_on.xml @@ -0,0 +1,11 @@ + + + + diff --git a/lib/main.dart b/lib/main.dart index 6137b14..ac1397b 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -70,6 +70,9 @@ void main() { androidNotificationIcon: 'drawable/ic_notification', androidShowNotificationBadge: false, androidStopForegroundOnPause: false, // Keep service alive when paused for background playback + androidBrowsableRootExtras: { + 'android.media.browse.SEARCH_SUPPORTED': true, + }, ), ); _logger.log('🎵 AudioService initialized - background playback and media notifications ENABLED'); diff --git a/lib/providers/music_assistant_provider.dart b/lib/providers/music_assistant_provider.dart index e7d3f60..a57366d 100644 --- a/lib/providers/music_assistant_provider.dart +++ b/lib/providers/music_assistant_provider.dart @@ -99,6 +99,14 @@ class MusicAssistantProvider with ChangeNotifier { Timer? _idleServiceTimer; static const _idleServiceTimeout = Duration(minutes: 30); + // Artwork content:// URI helper for Android Auto compatibility + static const _artworkAuthority = 'com.collotsspot.ensemble.artwork'; + static Uri? _contentArtUri(String? httpUrl) { + if (httpUrl == null) return null; + final encoded = base64Url.encode(utf8.encode(httpUrl)); + return Uri.parse('content://$_artworkAuthority/$encoded'); + } + // Local player state bool _isLocalPlayerPowered = true; int _localPlayerVolume = 100; // Tracked MA volume for builtin player (0-100) @@ -2056,6 +2064,9 @@ class MusicAssistantProvider with ChangeNotifier { _logger.log('🎵 Notification: Switch player pressed'); selectNextPlayer(); }; + audioHandler.onBrowseActivity = () { + _cancelIdleServiceTimer(); + }; // Player registration is now handled in _initializeAfterConnection() // which runs after authentication completes (when auth is required) @@ -2532,7 +2543,7 @@ class MusicAssistantProvider with ChangeNotifier { title: title ?? 'Playing via Sendspin', artist: artist ?? 'Music Assistant', album: album, - artUri: artworkUrl != null ? Uri.parse(artworkUrl) : null, + artUri: _contentArtUri(artworkUrl), duration: durationSecs != null ? Duration(seconds: durationSecs) : null, ); @@ -2573,7 +2584,7 @@ class MusicAssistantProvider with ChangeNotifier { title: metadata?.title ?? 'Music Assistant', artist: metadata?.artist ?? 'Paused', album: metadata?.album, - artUri: metadata?.artworkUrl != null ? Uri.parse(metadata!.artworkUrl!) : null, + artUri: _contentArtUri(metadata?.artworkUrl), duration: metadata?.duration, ); @@ -2788,6 +2799,7 @@ class MusicAssistantProvider with ChangeNotifier { final mediaType = event['media_type'] as String?; _cacheService.invalidateSearchCache(); _scheduleLibraryRefresh(mediaType ?? 'artist'); + audioHandler.invalidateAutoChildren(const ['cat|favorites', 'cat|home', 'cat|fav_tracks', 'cat|fav_albums', 'cat|fav_artists']); } catch (e) { _logger.log('Error handling media item added event: $e'); } @@ -2800,6 +2812,7 @@ class MusicAssistantProvider with ChangeNotifier { final mediaType = event['media_type'] as String?; _cacheService.invalidateSearchCache(); _scheduleLibraryRefresh(mediaType ?? 'artist'); + audioHandler.invalidateAutoChildren(const ['cat|favorites', 'cat|home', 'cat|fav_tracks', 'cat|fav_albums', 'cat|fav_artists']); } catch (e) { _logger.log('Error handling media item deleted event: $e'); } @@ -4403,7 +4416,7 @@ class MusicAssistantProvider with ChangeNotifier { artist: artistWithPlayer, album: track.album?.name ?? '', duration: track.duration, - artUri: artworkUrl != null ? Uri.tryParse(artworkUrl) : null, + artUri: _contentArtUri(artworkUrl), ); // Position comes from actual player in updateLocalModeNotification audioHandler.updateLocalModeNotification( @@ -4443,7 +4456,7 @@ class MusicAssistantProvider with ChangeNotifier { artist: artistWithPlayer, album: track.album?.name ?? '', duration: track.duration, - artUri: artworkUrl != null ? Uri.tryParse(artworkUrl) : null, + artUri: _contentArtUri(artworkUrl), ); // Use position tracker for consistent position final position = _positionTracker.currentPosition; @@ -4625,7 +4638,7 @@ class MusicAssistantProvider with ChangeNotifier { artist: artistWithPlayer, album: track.album?.name ?? '', duration: track.duration, - artUri: artworkUrl != null ? Uri.tryParse(artworkUrl) : null, + artUri: _contentArtUri(artworkUrl), ); _cancelIdleServiceTimer(); @@ -5049,6 +5062,11 @@ class MusicAssistantProvider with ChangeNotifier { } if (queue != null && queue.currentItem != null) { + // Update Android Auto queue index + if (queue.currentIndex != null) { + audioHandler.updateQueueIndex(queue.currentIndex!); + } + final queueTrack = queue.currentItem!.track; final trackChanged = _currentTrack == null || _currentTrack!.uri != queueTrack.uri || @@ -5153,7 +5171,7 @@ class MusicAssistantProvider with ChangeNotifier { artist: artistWithPlayer, album: track.album?.name ?? '', duration: track.duration, - artUri: artworkUrl != null ? Uri.tryParse(artworkUrl) : null, + artUri: _contentArtUri(artworkUrl), ); // Position comes from actual player in updateLocalModeNotification audioHandler.updateLocalModeNotification( @@ -5173,7 +5191,7 @@ class MusicAssistantProvider with ChangeNotifier { artist: artistWithPlayer, album: track.album?.name ?? '', duration: track.duration, - artUri: artworkUrl != null ? Uri.tryParse(artworkUrl) : null, + artUri: _contentArtUri(artworkUrl), ); // Use position tracker for consistent position (single source of truth) final position = _positionTracker.currentPosition; @@ -5241,7 +5259,7 @@ class MusicAssistantProvider with ChangeNotifier { title: track.name, artist: track.artistsString, album: track.album?.name, - artUri: _api != null ? Uri.tryParse(_api!.getImageUrl(track, size: 512) ?? '') : null, + artUri: _contentArtUri(_api?.getImageUrl(track, size: 512)), duration: track.duration, ); audioHandler.updateLocalModeNotification( @@ -5276,15 +5294,10 @@ class MusicAssistantProvider with ChangeNotifier { // For pause: Don't refresh - we already did optimistic UI update // The player_updated event from MA will handle final state sync } else { - // If player is idle with no active queue but has a last-known track, re-queue it - final isIdle = _selectedPlayer!.state == 'idle'; - final lastKnown = _cacheService.getLastKnownTrack(_selectedPlayer!.playerId); - if (isIdle && lastKnown != null) { - _logger.log('🔄 Player idle with last-known track - re-queuing "${lastKnown.name}"'); - await playTrack(_selectedPlayer!.playerId, lastKnown); - } else { - await resumePlayer(_selectedPlayer!.playerId); - } + // Always try resume first — even if player reports "idle", the server + // queue may still be intact (e.g., Sendspin sends "stopped" on pause). + // Only fall back to re-queuing if resume fails. + await resumePlayer(_selectedPlayer!.playerId); // For resume: Refresh in background to get updated track info unawaited(refreshPlayers()); } diff --git a/lib/services/audio/massiv_audio_handler.dart b/lib/services/audio/massiv_audio_handler.dart index ca9d918..02725b4 100644 --- a/lib/services/audio/massiv_audio_handler.dart +++ b/lib/services/audio/massiv_audio_handler.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:convert'; import 'package:audio_service/audio_service.dart'; import 'package:audio_session/audio_session.dart'; import 'package:just_audio/just_audio.dart'; @@ -33,6 +34,7 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler Function()? onPlay; Function()? onPause; Function()? onSwitchPlayer; + Function()? onBrowseActivity; // Track whether we're in remote control mode (controlling MA player, not playing locally) bool _isRemoteMode = false; @@ -42,8 +44,16 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler // Android Auto: track queue cache — maps context key to ordered track list. // Populated during getChildren so playFromMediaId can queue the full album/playlist. + static const _maxTrackCacheEntries = 50; final Map> _autoTrackCache = {}; + void _cacheTrackList(String key, List tracks) { + _autoTrackCache[key] = tracks; + while (_autoTrackCache.length > _maxTrackCacheEntries) { + _autoTrackCache.remove(_autoTrackCache.keys.first); + } + } + // Android Auto: subjects for subscribeToChildren final Map>> _autoChildrenSubjects = {}; @@ -64,8 +74,11 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler await session.configure(const AudioSessionConfiguration.music()); // Handle audio interruptions (e.g., another app takes audio focus) + // Only act on interruptions when playing locally — remote MA players + // manage their own audio focus on the server side. _interruptionSubscription = session.interruptionEventStream.listen((event) { - _logger.log('🔊 Audio interruption: begin=${event.begin}, type=${event.type}'); + _logger.log('🔊 Audio interruption: begin=${event.begin}, type=${event.type}, builtinActive=$_isBuiltinPlayerActive'); + if (!_isBuiltinPlayerActive) return; if (event.begin) { switch (event.type) { case AudioInterruptionType.duck: @@ -73,7 +86,6 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler break; case AudioInterruptionType.pause: case AudioInterruptionType.unknown: - // Pause both just_audio and Sendspin/remote player _player.pause(); onPause?.call(); break; @@ -84,7 +96,6 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler _player.setVolume(1.0); break; case AudioInterruptionType.pause: - // Resume both just_audio and Sendspin/remote player _player.play(); onPlay?.call(); break; @@ -117,9 +128,13 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler playbackState.add(playbackState.value.copyWith( // Configure notification action buttons controls: [ + _shuffleControl(), MediaControl.skipToPrevious, if (playing) MediaControl.pause else MediaControl.play, MediaControl.skipToNext, + _favoriteControl(), + _radioControl, + _repeatControl(), ], // System-level actions (for headphones, car stereos, etc.) systemActions: const { @@ -133,7 +148,7 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler MediaAction.skipToPrevious, }, // Which buttons to show in compact notification (max 3) - // Show: play/pause (1), skip-next (2), switch-player (3) + // Show: prev (1), play/pause (2), next (3) androidCompactActionIndices: const [1, 2, 3], processingState: { ProcessingState.idle: AudioProcessingState.idle, @@ -200,6 +215,108 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler onSkipToPrevious?.call(); } + @override + Future customAction(String name, [Map? extras]) async { + _logger.log('AndroidAuto: customAction called: $name'); + final provider = _autoProvider; + if (provider == null) { + _logger.log('AndroidAuto: customAction: no provider'); + return; + } + // Look up fresh player state (selectedPlayer may have stale cached activeQueue) + final selectedId = provider.selectedPlayer?.playerId; + if (selectedId == null) { + _logger.log('AndroidAuto: customAction: no selectedPlayer'); + return; + } + final player = provider.availablePlayersUnfiltered + .where((p) => p.playerId == selectedId).firstOrNull ?? provider.selectedPlayer!; + _logger.log('AndroidAuto: customAction: player=${player.name}, activeQueue=${player.activeQueue}'); + + try { + switch (name) { + case 'toggleShuffle': + final queue = await provider.getQueue(player.playerId); + final newShuffle = !(queue?.shuffle ?? false); + _logger.log('AndroidAuto: toggleShuffle: current=${queue?.shuffle}, setting=$newShuffle'); + await provider.toggleShuffle(player.playerId, newShuffle); + _shuffleOn = newShuffle; + case 'toggleFavorite': + final track = provider.currentTrack; + if (track == null) { + _logger.log('AndroidAuto: toggleFavorite: no currentTrack'); + return; + } + _logger.log('AndroidAuto: toggleFavorite: track=${track.name}, favorite=${_isFavorite}, provider=${track.provider}'); + if (_isFavorite) { + int? libraryItemId; + if (track.provider == 'library') { + libraryItemId = int.tryParse(track.itemId); + } else if (track.providerMappings != null) { + final libraryMapping = track.providerMappings!.firstWhere( + (m) => m.providerInstance == 'library', + orElse: () => track.providerMappings!.first, + ); + if (libraryMapping.providerInstance == 'library') { + libraryItemId = int.tryParse(libraryMapping.itemId); + } + } + _logger.log('AndroidAuto: toggleFavorite: removing, libraryItemId=$libraryItemId'); + if (libraryItemId != null) { + await provider.removeFromFavorites( + mediaType: 'track', libraryItemId: libraryItemId); + } + } else { + _logger.log('AndroidAuto: toggleFavorite: adding'); + await provider.addToFavorites( + mediaType: 'track', itemId: track.itemId, provider: track.provider); + } + _isFavorite = !_isFavorite; + case 'startRadio': + final track = provider.currentTrack; + if (track == null) { + _logger.log('AndroidAuto: startRadio: no currentTrack'); + return; + } + _logger.log('AndroidAuto: startRadio: track=${track.name}, playerId=${player.playerId}'); + await provider.playRadio(player.playerId, track); + _logger.log('AndroidAuto: startRadio: done'); + case 'cycleRepeat': + final queue = await provider.getQueue(player.playerId); + final currentMode = queue?.repeatMode ?? 'off'; + _logger.log('AndroidAuto: cycleRepeat: current=$currentMode'); + await provider.cycleRepeatMode(player.playerId, currentMode); + // Track the new mode locally for icon update + _repeatMode = switch (currentMode) { + 'off' => 'all', + 'all' => 'one', + _ => 'off', + }; + } + // Re-broadcast playback state to update icons + _refreshPlaybackState(); + } catch (e) { + _logger.log('AndroidAuto: customAction error: $e'); + } + } + + /// Re-broadcast current playback state to update custom action icons + void _refreshPlaybackState() { + final current = playbackState.value; + final playing = current.playing; + playbackState.add(current.copyWith( + controls: [ + _shuffleControl(), + MediaControl.skipToPrevious, + if (playing) MediaControl.pause else MediaControl.play, + MediaControl.skipToNext, + _favoriteControl(), + _radioControl, + _repeatControl(), + ], + )); + } + // --- Custom methods for Ensemble --- /// Play a URL with the given metadata @@ -243,9 +360,21 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler Duration position = Duration.zero, Duration? duration, }) async { + _clearErrorState(); _isRemoteMode = true; - // Activate audio session to request focus and receive interruption events - if (playing) { + // Sync favorite state only when track changes + final trackId = '${item.id}'; + if (trackId != _lastTrackId) { + _lastTrackId = trackId; + _isFavorite = _autoProvider?.currentTrack?.favorite == true; + } + // Only activate audio session for the builtin player (local PCM playback). + // Remote MA players manage their own audio — claiming focus on the phone + // would pause them when another app (e.g. YouTube) plays. + final builtinId = await SettingsService.getBuiltinPlayerId(); + final selectedId = _autoProvider?.selectedPlayer?.playerId; + _isBuiltinPlayerActive = builtinId != null && selectedId == builtinId; + if (playing && _isBuiltinPlayerActive) { final session = await AudioSession.instance; await session.setActive(true); } @@ -254,9 +383,13 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler playbackState.add(playbackState.value.copyWith( controls: [ + _shuffleControl(), MediaControl.skipToPrevious, if (playing) MediaControl.pause else MediaControl.play, MediaControl.skipToNext, + _favoriteControl(), + _radioControl, + _repeatControl(), ], systemActions: const { MediaAction.play, @@ -266,8 +399,8 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler MediaAction.stop, }, // Which buttons to show in compact notification (max 3) - // Show: play/pause (1), skip-next (2), switch-player (3) - androidCompactActionIndices: const [1, 2, 3], + // Show: prev (0), play/pause (1), next (2) + androidCompactActionIndices: const [0, 1, 2], processingState: AudioProcessingState.ready, playing: playing, updatePosition: position, @@ -363,6 +496,11 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler static const _autoIdAlbums = 'cat|albums'; static const _autoIdFavorites = 'cat|favorites'; + // Media ID constants — Favourite subcategories + static const _autoIdFavArtists = 'cat|fav_artists'; + static const _autoIdFavAlbums = 'cat|fav_albums'; + static const _autoIdFavTracks = 'cat|fav_tracks'; + // Media ID constants — Audiobook subcategories static const _autoIdAbAuthors = 'cat|ab_authors'; static const _autoIdAbBooks = 'cat|ab_books'; @@ -386,9 +524,56 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler static final _iconPlaylist = Uri.parse('android.resource://$_iconPkg/drawable/ic_auto_playlist'); static final _iconFavorite = Uri.parse('android.resource://$_iconPkg/drawable/ic_auto_favorite'); + // Custom now-playing action buttons for Android Auto + // Icons change based on current state for visual feedback + static final _radioControl = MediaControl.custom( + androidIcon: 'drawable/ic_auto_radio', + label: 'Start Radio', + name: 'startRadio', + ); + + MediaControl _shuffleControl() { + return MediaControl.custom( + androidIcon: _shuffleOn ? 'drawable/ic_auto_shuffle_on' : 'drawable/ic_auto_shuffle', + label: 'Shuffle', + name: 'toggleShuffle', + ); + } + + MediaControl _favoriteControl() { + return MediaControl.custom( + androidIcon: _isFavorite ? 'drawable/ic_auto_favorite' : 'drawable/ic_auto_favorite_off', + label: 'Favourite', + name: 'toggleFavorite', + ); + } + + MediaControl _repeatControl() { + return MediaControl.custom( + androidIcon: switch (_repeatMode) { + 'one' => 'drawable/ic_auto_repeat_one', + 'all' => 'drawable/ic_auto_repeat_all', + _ => 'drawable/ic_auto_repeat', + }, + label: 'Repeat', + name: 'cycleRepeat', + ); + } + + // Local state tracking for icon updates (synced after each action) + bool _shuffleOn = false; + bool _isFavorite = false; + String _repeatMode = 'off'; + String? _lastTrackId; + + // Whether the currently selected player is the builtin (local PCM) player + bool _isBuiltinPlayerActive = false; + @override Future> getChildren(String parentMediaId, [Map? options]) async { + onBrowseActivity?.call(); + final provider = _autoProvider; if (provider == null) { _logger.log('AndroidAuto: getChildren("$parentMediaId") — provider not set yet'); @@ -404,6 +589,21 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler provider.checkAndReconnect(); } + // Auto-select builtin player when AA starts browsing, so playback + // goes to the phone instead of a previously-selected remote speaker + if (parentMediaId == AudioService.browsableRootId) { + final playerId = await SettingsService.getBuiltinPlayerId(); + if (playerId != null && provider.selectedPlayer?.playerId != playerId) { + final builtinPlayer = provider.availablePlayersUnfiltered + .where((p) => p.playerId == playerId) + .firstOrNull; + if (builtinPlayer != null) { + _logger.log('AndroidAuto: auto-selecting builtin player "${builtinPlayer.name}"'); + provider.selectPlayer(builtinPlayer); + } + } + } + try { final result = await _autoGetChildren(provider, parentMediaId); _logger.log('AndroidAuto: getChildren "$parentMediaId" → ${result.length} items'); @@ -435,7 +635,13 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler case _autoIdAlbums: return _autoBuildAlbumList(provider); case _autoIdFavorites: - return _autoBuildFavorites(provider); + return _autoBuildFavoriteCategories(); + case _autoIdFavArtists: + return _autoBuildFavArtists(provider); + case _autoIdFavAlbums: + return _autoBuildFavAlbums(provider); + case _autoIdFavTracks: + return _autoBuildFavTracks(provider); // Audiobooks case _autoIdAudiobooks: @@ -504,6 +710,14 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler return _autoChildrenSubjects[parentMediaId]!.stream; } + /// Notify Android Auto that children under [parentIds] may have changed. + void invalidateAutoChildren(List parentIds) { + final ts = DateTime.now().millisecondsSinceEpoch; + for (final id in parentIds) { + _autoChildrenSubjects[id]?.add({'ts': ts}); + } + } + @override Future playFromMediaId(String mediaId, [Map? extras]) async { @@ -531,6 +745,7 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler } _logger.log('AndroidAuto: playFromMediaId $mediaId'); + _clearErrorState(); try { if (mediaId.startsWith('track|')) { @@ -554,11 +769,13 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler final index = trackList.indexWhere( (t) => t.provider == tProvider && t.itemId == tItemId, ); + final startIdx = index < 0 ? 0 : index; await provider.playTracks( playerId, trackList, - startIndex: index < 0 ? 0 : index, + startIndex: startIdx, ); + _populateQueue(provider, trackList, startIdx); return; } @@ -626,29 +843,158 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler } } catch (e) { _logger.log('AndroidAuto: playFromMediaId error: $e'); + _setErrorState('Playback failed'); } } + // Search race condition guard + int _searchId = 0; + @override Future> search(String query, [Map? extras]) async { final provider = _autoProvider; if (provider == null || query.trim().isEmpty) return []; + final searchId = ++_searchId; + try { final results = await provider.searchWithCache(query); + + // Check for stale search + if (searchId != _searchId) { + _logger.log('AndroidAuto: ignoring stale search results for "$query"'); + return []; + } + + final artists = (results['artists'] ?? []).whereType().take(5).toList(); + final albums = (results['albums'] ?? []).whereType().take(5).toList(); final tracks = (results['tracks'] ?? []).whereType().toList(); const ctxKey = 'search||'; - _autoTrackCache[ctxKey] = tracks; - return tracks - .map((t) => _autoTrackItem(provider, t, ctxKey)) - .toList(); + _cacheTrackList(ctxKey, tracks); + + final items = []; + + // Group: Artists + if (artists.isNotEmpty) { + for (final a in artists) { + items.add(MediaItem( + id: 'artist|${a.name}', + title: a.name, + artUri: _autoArtUri(provider, a), + playable: false, + extras: const { + 'android.media.browse.CONTENT_STYLE_GROUP_TITLE_HINT': 'Artists', + }, + )); + } + } + + // Group: Albums + if (albums.isNotEmpty) { + for (final a in albums) { + items.add(MediaItem( + id: 'album|${a.provider}|${a.itemId}', + title: a.name, + artist: a.artistsString, + artUri: _autoArtUri(provider, a), + playable: false, + extras: const { + 'android.media.browse.CONTENT_STYLE_GROUP_TITLE_HINT': 'Albums', + }, + )); + } + } + + // Group: Tracks + for (final t in tracks) { + final item = _autoTrackItem(provider, t, ctxKey); + items.add(MediaItem( + id: item.id, + title: item.title, + artist: item.artist, + album: item.album, + duration: item.duration, + artUri: item.artUri, + playable: true, + extras: const { + 'android.media.browse.CONTENT_STYLE_GROUP_TITLE_HINT': 'Tracks', + }, + )); + } + + return items; } catch (e) { _logger.log('AndroidAuto: search error: $e'); return []; } } + @override + Future playFromSearch(String query, + [Map? extras]) async { + final provider = _autoProvider; + if (provider == null || query.trim().isEmpty) return; + + final playerId = await SettingsService.getBuiltinPlayerId(); + if (playerId == null) { + _logger.log('AndroidAuto: playFromSearch: no builtin player'); + return; + } + + _logger.log('AndroidAuto: playFromSearch "$query"'); + _clearErrorState(); + + try { + final results = await provider.searchWithCache(query); + final tracks = (results['tracks'] ?? []).whereType().toList(); + if (tracks.isEmpty) { + _logger.log('AndroidAuto: playFromSearch: no tracks found'); + return; + } + + await provider.playTracks(playerId, tracks, startIndex: 0); + _populateQueue(provider, tracks, 0); + } catch (e) { + _logger.log('AndroidAuto: playFromSearch error: $e'); + _setErrorState('Search playback failed'); + } + } + + // --- Queue & error state helpers --- + + void _populateQueue(MusicAssistantProvider provider, List tracks, int currentIndex) { + final items = tracks.map((t) => MediaItem( + id: 'q|${t.provider}|${t.itemId}', + title: t.name, + artist: t.artistsString, + album: t.album?.name, + duration: t.duration, + artUri: _autoArtUri(provider, t), + )).toList(); + queue.add(items); + playbackState.add(playbackState.value.copyWith(queueIndex: currentIndex)); + } + + void updateQueueIndex(int index) { + playbackState.add(playbackState.value.copyWith(queueIndex: index)); + } + + void _setErrorState(String message) { + playbackState.add(playbackState.value.copyWith( + processingState: AudioProcessingState.error, + errorMessage: message, + )); + } + + void _clearErrorState() { + if (playbackState.value.processingState == AudioProcessingState.error) { + playbackState.add(playbackState.value.copyWith( + processingState: AudioProcessingState.ready, + )); + } + } + // --- Root & category builders --- List _autoBuildRoot() { @@ -757,6 +1103,7 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler id: 'audiobook|${b.provider}|${b.itemId}', title: b.name, artist: b.authorsString, artUri: _autoArtUri(provider, b), playable: true, + extras: _audiobookExtras(b), )).toList(); case 'discover-audiobooks': @@ -765,6 +1112,7 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler id: 'audiobook|${b.provider}|${b.itemId}', title: b.name, artist: b.authorsString, artUri: _autoArtUri(provider, b), playable: true, + extras: _audiobookExtras(b), )).toList(); case 'discover-series': @@ -789,7 +1137,7 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler case 'favorite-tracks': final tracks = await provider.getFavoriteTracks(); const ctxKey = 'favs||'; - _autoTrackCache[ctxKey] = tracks; + _cacheTrackList(ctxKey, tracks); return tracks.map((t) => _autoTrackItem(provider, t, ctxKey)).toList(); case 'favorite-playlists': @@ -849,12 +1197,41 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler // --- Music builders --- - Future> _autoBuildFavorites( + List _autoBuildFavoriteCategories() { + return [ + MediaItem(id: _autoIdFavArtists, title: 'Favourite Artists', + playable: false, artUri: _iconArtist), + MediaItem(id: _autoIdFavAlbums, title: 'Favourite Albums', + playable: false, artUri: _iconAlbum, extras: _gridHints), + MediaItem(id: _autoIdFavTracks, title: 'Favourite Tracks', + playable: false, artUri: _iconFavorite), + ]; + } + + Future> _autoBuildFavArtists( + MusicAssistantProvider provider) async { + final artists = await provider.getFavoriteArtists(); + return artists.map((a) => MediaItem( + id: 'artist|${a.name}', title: a.name, + artUri: _autoArtUri(provider, a), playable: false, + )).toList(); + } + + Future> _autoBuildFavAlbums( + MusicAssistantProvider provider) async { + final albums = await provider.getFavoriteAlbums(); + return albums.map((a) => MediaItem( + id: 'album|${a.provider}|${a.itemId}', title: a.name, artist: a.artistsString, + artUri: _autoArtUri(provider, a), playable: false, + )).toList(); + } + + Future> _autoBuildFavTracks( MusicAssistantProvider provider) async { final tracks = await provider.getFavoriteTracks(); - _logger.log('AndroidAuto: Favorites returned ${tracks.length} tracks'); + _logger.log('AndroidAuto: Fav tracks returned ${tracks.length} tracks'); const ctxKey = 'favs||'; - _autoTrackCache[ctxKey] = tracks; + _cacheTrackList(ctxKey, tracks); return tracks.map((t) => _autoTrackItem(provider, t, ctxKey)).toList(); } @@ -875,7 +1252,7 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler final tracks = await provider.getPlaylistTracksWithCache(plProvider, plItemId); final ctxKey = 'plist|$plProvider|$plItemId'; - _autoTrackCache[ctxKey] = tracks; + _cacheTrackList(ctxKey, tracks); return tracks.map((t) => _autoTrackItem(provider, t, ctxKey)).toList(); } @@ -925,7 +1302,7 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler await provider.getAlbumTracksWithCache(alProvider, alItemId); _logger.log('AndroidAuto: Album $alProvider/$alItemId tracks: ${tracks.length}'); final ctxKey = 'album|$alProvider|$alItemId'; - _autoTrackCache[ctxKey] = tracks; + _cacheTrackList(ctxKey, tracks); return tracks.map((t) => _autoTrackItem(provider, t, ctxKey)).toList(); } @@ -962,6 +1339,7 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler artist: b.authorsString, artUri: _autoArtUri(provider, b), playable: true, + extras: _audiobookExtras(b), )).toList(); } @@ -982,6 +1360,7 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler artist: b.authorsString, artUri: _autoArtUri(provider, b), playable: true, + extras: _audiobookExtras(b), )).toList(); } @@ -1022,6 +1401,7 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler artist: b.authorsString, artUri: _autoArtUri(provider, b), playable: true, + extras: _audiobookExtras(b), )).toList(); } @@ -1073,8 +1453,17 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler // --- Helpers --- + Map? _audiobookExtras(ma.Audiobook book) { + if (book.progress <= 0 && book.fullyPlayed != true) return null; + return { + 'android.media.extra.PLAYBACK_STATUS': book.fullyPlayed == true ? 2 : 1, + 'android.media.extra.PLAYBACK_STATUS_COMPLETION_PERCENTAGE': book.progress, + }; + } + MediaItem _autoTrackItem( MusicAssistantProvider provider, ma.Track t, String ctxKey) { + final firstArtist = t.artists?.isNotEmpty == true ? t.artists!.first.name : null; return MediaItem( id: 'track|${t.provider}|${t.itemId}|$ctxKey', title: t.name, @@ -1083,12 +1472,23 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler duration: t.duration, artUri: _autoArtUri(provider, t), playable: true, + extras: firstArtist != null ? { + 'android.media.metadata.SUBTITLE_LINK_MEDIA_ID': 'artist|$firstArtist', + } : null, ); } + static const _artworkAuthority = 'com.collotsspot.ensemble.artwork'; + Uri? _autoArtUri(MusicAssistantProvider provider, ma.MediaItem item) { final url = provider.getImageUrl(item, size: 256); - return url != null ? Uri.tryParse(url) : null; + if (url == null) return null; + return _contentUriForArtwork(url); + } + + static Uri? _contentUriForArtwork(String httpUrl) { + final encoded = base64Url.encode(utf8.encode(httpUrl)); + return Uri.parse('content://$_artworkAuthority/$encoded'); } // --------------------------------------------------------------------------- @@ -1099,6 +1499,11 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler await _becomingNoisySubscription?.cancel(); await _playbackEventSubscription?.cancel(); await _currentIndexSubscription?.cancel(); + _autoTrackCache.clear(); + for (final s in _autoChildrenSubjects.values) { + await s.close(); + } + _autoChildrenSubjects.clear(); await _player.dispose(); } } diff --git a/lib/services/pcm_audio_player.dart b/lib/services/pcm_audio_player.dart index ccf4a97..989ccea 100644 --- a/lib/services/pcm_audio_player.dart +++ b/lib/services/pcm_audio_player.dart @@ -69,6 +69,7 @@ class PcmAudioPlayer { bool _isFeeding = false; bool _isStarted = false; bool _isAutoRecovering = false; // Tracks auto-recovery from setup errors + bool _userPaused = false; // True when pause is user-initiated (not transitional) Completer? _feedCompleter; // Tracks when current feed operation completes // Error callback for operation failures @@ -235,7 +236,9 @@ class PcmAudioPlayer { if (_shouldBlockFeeding) { // If we're paused and audio is arriving, this is likely a new stream // starting before the stream/start message. Queue for auto-recovery. - if (_state == PcmPlayerState.paused && !_isAutoRecovering) { + // BUT: don't auto-recover if the user explicitly paused — the arriving + // audio is just the tail end of the previous stream. + if (_state == PcmPlayerState.paused && !_isAutoRecovering && !_userPaused) { _logger.log('PcmAudioPlayer: Audio arriving while paused - initiating auto-recovery'); _isAutoRecovering = true; _addToBuffer(audioData); @@ -492,6 +495,7 @@ class PcmAudioPlayer { if (_state == PcmPlayerState.paused) { // Resume from pause - need to re-initialize since we released on pause + _userPaused = false; _logger.log('PcmAudioPlayer: Resuming from pause at ${elapsedTime.inSeconds}s'); _state = PcmPlayerState.resuming; @@ -547,6 +551,7 @@ class PcmAudioPlayer { } _logger.log('PcmAudioPlayer: Pause requested'); + _userPaused = true; // Set pausing state FIRST to stop feed loop from starting new feeds _state = PcmPlayerState.pausing; -- 2.39.5 From 823b95ec1a3b0aaaab366edf59bc3f7d44ff3e85 Mon Sep 17 00:00:00 2001 From: Chris Date: Tue, 24 Feb 2026 06:45:06 +0000 Subject: [PATCH 05/19] Fix builtin player notification conflicts and player selection fallback Use updateLocalModeNotification instead of setRemotePlaybackState for Sendspin/builtin players to avoid conflicting mediaItem.add() calls that cause artwork flickering. Add fallback to builtin player when last selected player is unavailable. Extract shared _controls getter, use MediaControl.custom for switch player, and only claim audio focus on play transitions. Co-Authored-By: Claude Opus 4.6 --- lib/providers/music_assistant_provider.dart | 219 ++++++++++++++----- lib/services/audio/massiv_audio_handler.dart | 137 ++++++++---- 2 files changed, 251 insertions(+), 105 deletions(-) diff --git a/lib/providers/music_assistant_provider.dart b/lib/providers/music_assistant_provider.dart index a57366d..2602a3a 100644 --- a/lib/providers/music_assistant_provider.dart +++ b/lib/providers/music_assistant_provider.dart @@ -894,14 +894,30 @@ class MusicAssistantProvider with ChangeNotifier { // Restore selected player from settings final lastSelectedPlayerId = await SettingsService.getLastSelectedPlayerId(); if (lastSelectedPlayerId != null) { - try { - _selectedPlayer = players.firstWhere((p) => p.playerId == lastSelectedPlayerId); - _cacheService.setCachedSelectedPlayer(_selectedPlayer); + _selectedPlayer = players.cast().firstWhere( + (p) => p!.playerId == lastSelectedPlayerId, + orElse: () => null, + ); + if (_selectedPlayer != null) { _logger.log('📦 Restored selected player from database: ${_selectedPlayer?.name}'); - } catch (_) { - // Player not in cached list } } + // Fallback to local/builtin player + if (_selectedPlayer == null) { + final builtinId = await SettingsService.getBuiltinPlayerId(); + if (builtinId != null) { + _selectedPlayer = players.cast().firstWhere( + (p) => p!.playerId == builtinId, + orElse: () => null, + ); + if (_selectedPlayer != null) { + _logger.log('📦 Defaulted to local player: ${_selectedPlayer?.name}'); + } + } + } + if (_selectedPlayer != null) { + _cacheService.setCachedSelectedPlayer(_selectedPlayer); + } _logger.log('📦 Loaded ${players.length} players from database (instant)'); notifyListeners(); @@ -1986,16 +2002,23 @@ class MusicAssistantProvider with ChangeNotifier { if (_selectedPlayer == null && _availablePlayers.isNotEmpty) { final lastSelectedPlayerId = await SettingsService.getLastSelectedPlayerId(); if (lastSelectedPlayerId != null) { - try { - _selectedPlayer = _availablePlayers.firstWhere( - (p) => p.playerId == lastSelectedPlayerId, - ); - } catch (e) { - _selectedPlayer = _availablePlayers.first; - } - } else { - _selectedPlayer = _availablePlayers.first; + _selectedPlayer = _availablePlayers.cast().firstWhere( + (p) => p!.playerId == lastSelectedPlayerId, + orElse: () => null, + ); } + // Fallback to local/builtin player + if (_selectedPlayer == null) { + final builtinId = await SettingsService.getBuiltinPlayerId(); + if (builtinId != null) { + _selectedPlayer = _availablePlayers.cast().firstWhere( + (p) => p!.playerId == builtinId, + orElse: () => null, + ); + } + } + // Last resort: first available + _selectedPlayer ??= _availablePlayers.first; } _logger.log('⚡ Loaded ${_availablePlayers.length} cached players instantly (sorted)'); notifyListeners(); // Update UI immediately with cached data @@ -2536,25 +2559,47 @@ class MusicAssistantProvider with ChangeNotifier { // Keep the foreground service active to prevent Android from throttling // the PCM audio playback when the app goes to background. - // We use setRemotePlaybackState to maintain the notification without - // actually playing audio through just_audio. - final mediaItem = audio_service.MediaItem( - id: 'sendspin_pcm_stream', - title: title ?? 'Playing via Sendspin', - artist: artist ?? 'Music Assistant', - album: album, - artUri: _contentArtUri(artworkUrl), - duration: durationSecs != null ? Duration(seconds: durationSecs) : null, - ); - - // Initialize notification with position 0 + // Use updateLocalModeNotification (NOT setRemotePlaybackState) to avoid + // conflicting mediaItem.add() calls — _updatePlayerState() also calls + // updateLocalModeNotification with the real track data. _cancelIdleServiceTimer(); - audioHandler.setRemotePlaybackState( - item: mediaItem, - playing: true, - position: Duration.zero, - duration: mediaItem.duration, - ); + if (_currentTrack != null) { + final track = _currentTrack!; + final trackArtworkUrl = _api?.getImageUrl(track, size: 512); + final artistWithPlayer = track.artistsString.isNotEmpty + ? '${track.artistsString} • ${_selectedPlayer?.name ?? ''}' + : _selectedPlayer?.name ?? ''; + final mediaItem = audio_service.MediaItem( + id: track.uri ?? track.itemId, + title: track.name, + artist: artistWithPlayer, + album: track.album?.name ?? '', + duration: track.duration, + artUri: _contentArtUri(trackArtworkUrl), + ); + audioHandler.updateLocalModeNotification( + item: mediaItem, + playing: true, + duration: track.duration, + position: Duration.zero, + ); + } else { + // No track data yet — use stream metadata as fallback + final mediaItem = audio_service.MediaItem( + id: 'sendspin_pcm_stream', + title: title ?? 'Playing via Sendspin', + artist: artist ?? 'Music Assistant', + album: album, + artUri: _contentArtUri(artworkUrl), + duration: durationSecs != null ? Duration(seconds: durationSecs) : null, + ); + audioHandler.updateLocalModeNotification( + item: mediaItem, + playing: true, + duration: mediaItem.duration, + position: Duration.zero, + ); + } // Start notification position timer for Sendspin PCM _manageNotificationPositionTimer(); @@ -2575,27 +2620,47 @@ class MusicAssistantProvider with ChangeNotifier { // Pause PCM playback (preserves position) instead of stop (resets position) await _pcmAudioPlayer?.pause(); - // Update foreground service to show paused/stopped state with last position - // Don't completely clear it - keep showing the notification - // in case user wants to resume - final metadata = _currentNotificationMetadata; - final mediaItem = audio_service.MediaItem( - id: 'sendspin_pcm_stream', - title: metadata?.title ?? 'Music Assistant', - artist: metadata?.artist ?? 'Paused', - album: metadata?.album, - artUri: _contentArtUri(metadata?.artworkUrl), - duration: metadata?.duration, - ); - - // Show paused state with preserved position + // Update foreground service to show paused state with last position. + // Use updateLocalModeNotification (NOT setRemotePlaybackState) to avoid + // conflicting mediaItem.add() calls with _updatePlayerState(). _cancelIdleServiceTimer(); - audioHandler.setRemotePlaybackState( - item: mediaItem, - playing: false, - position: lastPosition, - duration: mediaItem.duration, - ); + if (_currentTrack != null) { + final track = _currentTrack!; + final artworkUrl = _api?.getImageUrl(track, size: 512); + final artistWithPlayer = track.artistsString.isNotEmpty + ? '${track.artistsString} • ${_selectedPlayer?.name ?? ''}' + : _selectedPlayer?.name ?? ''; + final mediaItem = audio_service.MediaItem( + id: track.uri ?? track.itemId, + title: track.name, + artist: artistWithPlayer, + album: track.album?.name ?? '', + duration: track.duration, + artUri: _contentArtUri(artworkUrl), + ); + audioHandler.updateLocalModeNotification( + item: mediaItem, + playing: false, + duration: track.duration, + position: lastPosition, + ); + } else { + final metadata = _currentNotificationMetadata; + final mediaItem = audio_service.MediaItem( + id: 'sendspin_pcm_stream', + title: metadata?.title ?? 'Music Assistant', + artist: metadata?.artist ?? 'Paused', + album: metadata?.album, + artUri: _contentArtUri(metadata?.artworkUrl), + duration: metadata?.duration, + ); + audioHandler.updateLocalModeNotification( + item: mediaItem, + playing: false, + duration: mediaItem.duration, + position: lastPosition, + ); + } } void _startReportingLocalPlayerState() { @@ -4435,8 +4500,30 @@ class MusicAssistantProvider with ChangeNotifier { item: mediaItem, playing: player.state == 'playing', ); + } else if (_currentTrack != null) { + // Builtin player is idle but we have track data — show paused notification + // instead of clearing, to avoid artwork flash when AA toggles play/pause + final track = _currentTrack!; + final artworkUrl = _api?.getImageUrl(track, size: 512); + final artistWithPlayer = track.artistsString.isNotEmpty + ? '${track.artistsString} • ${player.name}' + : player.name; + final mediaItem = audio_service.MediaItem( + id: track.uri ?? track.itemId, + title: track.name, + artist: artistWithPlayer, + album: track.album?.name ?? '', + duration: track.duration, + artUri: _contentArtUri(artworkUrl), + ); + audioHandler.updateLocalModeNotification( + item: mediaItem, + playing: false, + duration: track.duration, + ); + _startIdleServiceTimer(); } else { - // Builtin player is idle - clear notification (fixes issue #42) + // Builtin player is idle with no track - clear notification audioHandler.clearRemotePlaybackState(); _startIdleServiceTimer(); } @@ -4642,12 +4729,25 @@ class MusicAssistantProvider with ChangeNotifier { ); _cancelIdleServiceTimer(); - audioHandler.setRemotePlaybackState( - item: mediaItem, - playing: isPlaying, - position: position, - duration: track.duration, - ); + + // Use updateLocalModeNotification for builtin players to avoid + // conflicting mediaItem.add() calls with _updatePlayerState() + final isBuiltinPlayer = _sendspinConnected && _pcmAudioPlayer != null; + if (isBuiltinPlayer) { + audioHandler.updateLocalModeNotification( + item: mediaItem, + playing: isPlaying, + duration: track.duration, + position: position, + ); + } else { + audioHandler.setRemotePlaybackState( + item: mediaItem, + playing: isPlaying, + position: position, + duration: track.duration, + ); + } } Future _preloadAdjacentPlayers({bool preloadAll = false}) async { @@ -5062,7 +5162,6 @@ class MusicAssistantProvider with ChangeNotifier { } if (queue != null && queue.currentItem != null) { - // Update Android Auto queue index if (queue.currentIndex != null) { audioHandler.updateQueueIndex(queue.currentIndex!); } diff --git a/lib/services/audio/massiv_audio_handler.dart b/lib/services/audio/massiv_audio_handler.dart index 02725b4..7995e0d 100644 --- a/lib/services/audio/massiv_audio_handler.dart +++ b/lib/services/audio/massiv_audio_handler.dart @@ -57,11 +57,11 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler // Android Auto: subjects for subscribeToChildren final Map>> _autoChildrenSubjects = {}; - // Custom control for switching players (uses stop action with custom icon) - static const _switchPlayerControl = MediaControl( + // Custom control for switching players + static final _switchPlayerControl = MediaControl.custom( androidIcon: 'drawable/ic_switch_player', label: 'Switch Player', - action: MediaAction.stop, + name: 'switchPlayer', ); MassivAudioHandler({required this.authManager}) { @@ -113,9 +113,11 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler // Broadcast playback state changes _playbackEventSubscription = _player.playbackEventStream.listen(_broadcastState); - // Broadcast current media item changes + // Broadcast current media item changes (only for local just_audio playback) _currentIndexSubscription = _player.currentIndexStream.listen((_) { + if (_isRemoteMode) return; // Don't resend metadata in remote/Sendspin mode if (_currentMediaItem != null) { + _logger.log('🎵 currentIndexStream: re-adding mediaItem'); mediaItem.add(_currentMediaItem); } }); @@ -123,18 +125,21 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler /// Broadcast the current playback state to the system void _broadcastState(PlaybackEvent event) { + // In remote mode, playback state is managed by setRemotePlaybackState. + // When _currentMediaItem is set, notification is managed by the provider + // via updateLocalModeNotification/setRemotePlaybackState — don't let the + // idle just_audio player overwrite it (it would send processingState=idle + // which deactivates the media session and causes artwork to flash). + if (_isRemoteMode || _currentMediaItem != null) return; + final playing = _player.playing; playbackState.add(playbackState.value.copyWith( - // Configure notification action buttons + // Only transport controls for notification — custom actions are AA-only controls: [ - _shuffleControl(), MediaControl.skipToPrevious, if (playing) MediaControl.pause else MediaControl.play, MediaControl.skipToNext, - _favoriteControl(), - _radioControl, - _repeatControl(), ], // System-level actions (for headphones, car stereos, etc.) systemActions: const { @@ -143,13 +148,12 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler MediaAction.seekBackward, MediaAction.play, MediaAction.pause, - MediaAction.stop, MediaAction.skipToNext, MediaAction.skipToPrevious, }, // Which buttons to show in compact notification (max 3) - // Show: prev (1), play/pause (2), next (3) - androidCompactActionIndices: const [1, 2, 3], + // Show: prev (0), play/pause (1), next (2) + androidCompactActionIndices: const [0, 1, 2], processingState: { ProcessingState.idle: AudioProcessingState.idle, ProcessingState.loading: AudioProcessingState.loading, @@ -168,8 +172,10 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler @override Future play() async { - if (_isRemoteMode) { - onPlay?.call(); + // Always prefer callbacks — they handle both remote players and + // Sendspin PCM (which uses local mode but delegates playback to MA server) + if (onPlay != null) { + onPlay!.call(); } else { await _player.play(); } @@ -177,8 +183,8 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler @override Future pause() async { - if (_isRemoteMode) { - onPause?.call(); + if (onPause != null) { + onPause!.call(); } else { await _player.pause(); } @@ -292,6 +298,10 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler 'all' => 'one', _ => 'off', }; + case 'switchPlayer': + _logger.log('AndroidAuto: switchPlayer'); + onSwitchPlayer?.call(); + return; } // Re-broadcast playback state to update icons _refreshPlaybackState(); @@ -300,20 +310,11 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler } } - /// Re-broadcast current playback state to update custom action icons + /// Re-broadcast current playback state to update custom action icons (AA) void _refreshPlaybackState() { final current = playbackState.value; - final playing = current.playing; playbackState.add(current.copyWith( - controls: [ - _shuffleControl(), - MediaControl.skipToPrevious, - if (playing) MediaControl.pause else MediaControl.play, - MediaControl.skipToNext, - _favoriteControl(), - _radioControl, - _repeatControl(), - ], + controls: _controls, )); } @@ -378,28 +379,25 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler final session = await AudioSession.instance; await session.setActive(true); } - _currentMediaItem = item; - mediaItem.add(item); + // Only update mediaItem when the track actually changes to avoid + // Android Auto reloading artwork on every play/pause toggle + final trackChanged = _currentMediaItem?.id != item.id || + _currentMediaItem?.title != item.title || + _currentMediaItem?.artist != item.artist; + if (trackChanged) { + _logger.log('🎵 setRemotePlaybackState: track changed, calling mediaItem.add (${item.title})'); + _currentMediaItem = item; + mediaItem.add(item); + } playbackState.add(playbackState.value.copyWith( - controls: [ - _shuffleControl(), - MediaControl.skipToPrevious, - if (playing) MediaControl.pause else MediaControl.play, - MediaControl.skipToNext, - _favoriteControl(), - _radioControl, - _repeatControl(), - ], + controls: _controls, systemActions: const { MediaAction.play, MediaAction.pause, MediaAction.skipToNext, MediaAction.skipToPrevious, - MediaAction.stop, }, - // Which buttons to show in compact notification (max 3) - // Show: prev (0), play/pause (1), next (2) androidCompactActionIndices: const [0, 1, 2], processingState: AudioProcessingState.ready, playing: playing, @@ -437,17 +435,48 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler required MediaItem item, required bool playing, Duration? duration, - }) { + Duration? position, + }) async { // Keep local mode - DON'T set _isRemoteMode = true + + // This method is only called for the builtin (local PCM) player + _isBuiltinPlayerActive = true; + + // Claim audio focus only when transitioning to playing (not on every position update). + // This avoids stealing focus back from other apps like Symphonium. + if (playing && !playbackState.value.playing) { + final session = await AudioSession.instance; + await session.setActive(true); + } + // Only update mediaItem if it changed - avoid unnecessary notification refreshes - // that cause blinking. The playbackState is managed by _broadcastState which - // responds to actual player events. + // that cause blinking. if (_currentMediaItem?.id != item.id || _currentMediaItem?.title != item.title || _currentMediaItem?.artist != item.artist) { + _logger.log('🎵 updateLocalModeNotification: track changed, calling mediaItem.add (${item.title})'); _currentMediaItem = item; mediaItem.add(item); } + + // Also update playback state (playing/paused, position) for the notification + // and foreground service activation. Use position from the PCM player or + // provider's position tracker — caller passes what they have. + playbackState.add(playbackState.value.copyWith( + controls: _controls, + systemActions: const { + MediaAction.play, + MediaAction.pause, + MediaAction.skipToNext, + MediaAction.skipToPrevious, + }, + androidCompactActionIndices: const [0, 1, 2], + processingState: AudioProcessingState.ready, + playing: playing, + updatePosition: position ?? playbackState.value.updatePosition, + bufferedPosition: duration ?? Duration.zero, + speed: 1.0, + )); } bool get isRemoteMode => _isRemoteMode; @@ -560,6 +589,23 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler ); } + /// Controls list for playback state. + /// Transport controls first (shown in notification), then AA custom actions. + /// androidCompactActionIndices [0,1,2] = prev/play/next for compact notification. + /// AA shows transport + custom actions (shuffle, favourite, radio, repeat). + /// Uses MediaControl.pause as a placeholder — the system shows play or pause + /// based on the `playing` boolean, not this list. + List get _controls => [ + MediaControl.skipToPrevious, + MediaControl.pause, + MediaControl.skipToNext, + _shuffleControl(), + _switchPlayerControl, + _favoriteControl(), + _radioControl, + _repeatControl(), + ]; + // Local state tracking for icon updates (synced after each action) bool _shuffleOn = false; bool _isFavorite = false; @@ -965,7 +1011,7 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler void _populateQueue(MusicAssistantProvider provider, List tracks, int currentIndex) { final items = tracks.map((t) => MediaItem( - id: 'q|${t.provider}|${t.itemId}', + id: t.uri ?? t.itemId, title: t.name, artist: t.artistsString, album: t.album?.name, @@ -977,6 +1023,7 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler } void updateQueueIndex(int index) { + if (playbackState.value.queueIndex == index) return; playbackState.add(playbackState.value.copyWith(queueIndex: index)); } -- 2.39.5 From b3aa55a0218a756ab2ba3722a8fcd2a3192fe63f Mon Sep 17 00:00:00 2001 From: Chris Date: Tue, 24 Feb 2026 21:53:07 +0000 Subject: [PATCH 06/19] Fix podcast/audiobook metadata in notifications and AA, position tracking - Add _trackDisplayArtist() helper to show podcast name, audiobook author, or radio station instead of "Unknown Artist" in all notification/AA contexts - Auto-restore audiobook context from cached data when URI matches - Set podcast name context when playing episodes from AA browse tree - Encode podcast ID in episode media IDs for context lookup - Fix Sendspin pause position using server-synced position tracker instead of PCM elapsed time (which resets to 0 each stream) - Fix Sendspin resume position to use position tracker instead of Duration.zero - Add 10s seek-back on audiobook resume to compensate for Sendspin buffering - Populate AA queue for podcast and radio playback via _refreshQueueAfterDelay - Show podcast name as artist in AA episode browse list Co-Authored-By: Claude Opus 4.6 --- lib/providers/music_assistant_provider.dart | 124 +++++++++++++------ lib/services/audio/massiv_audio_handler.dart | 66 ++++++++-- 2 files changed, 143 insertions(+), 47 deletions(-) diff --git a/lib/providers/music_assistant_provider.dart b/lib/providers/music_assistant_provider.dart index 2602a3a..9531e29 100644 --- a/lib/providers/music_assistant_provider.dart +++ b/lib/providers/music_assistant_provider.dart @@ -690,6 +690,40 @@ class MusicAssistantProvider with ChangeNotifier { } } + /// Get the display artist string for a track, using podcast/radio/audiobook context when applicable + String _trackDisplayArtist(Track track) { + if (isPlayingAudiobook) { + return _currentAudiobook!.authorsString; + } + if (isPlayingPodcast) { + return currentPodcastName ?? 'Podcasts'; + } + if (isPlayingRadio) { + return currentRadioStationName ?? track.artistsString; + } + // Fallback: check if track URI looks like an audiobook chapter + final uri = track.uri ?? ''; + if (uri.contains('audiobook') || uri.contains('/chapter/')) { + // Try to find audiobook from cached data + final book = _findAudiobookForTrack(track); + if (book != null) { + _currentAudiobook = book; + _logger.log('📚 Auto-restored audiobook context: ${book.name}'); + return book.authorsString; + } + } + return track.artistsString; + } + + /// Try to find the audiobook that owns this track/chapter from cached data + Audiobook? _findAudiobookForTrack(Track track) { + final uri = track.uri ?? ''; + for (final book in SyncService.instance.cachedAudiobooks) { + if (uri.contains(book.itemId)) return book; + } + return null; + } + String get lastSearchQuery => _lastSearchQuery; Map> get lastSearchResults => _lastSearchResults; @@ -2566,9 +2600,8 @@ class MusicAssistantProvider with ChangeNotifier { if (_currentTrack != null) { final track = _currentTrack!; final trackArtworkUrl = _api?.getImageUrl(track, size: 512); - final artistWithPlayer = track.artistsString.isNotEmpty - ? '${track.artistsString} • ${_selectedPlayer?.name ?? ''}' - : _selectedPlayer?.name ?? ''; + final displayArtist = _trackDisplayArtist(track); + final artistWithPlayer = '${displayArtist} • ${_selectedPlayer?.name ?? ''}'; final mediaItem = audio_service.MediaItem( id: track.uri ?? track.itemId, title: track.name, @@ -2581,7 +2614,7 @@ class MusicAssistantProvider with ChangeNotifier { item: mediaItem, playing: true, duration: track.duration, - position: Duration.zero, + position: _positionTracker.currentPosition, ); } else { // No track data yet — use stream metadata as fallback @@ -2597,7 +2630,7 @@ class MusicAssistantProvider with ChangeNotifier { item: mediaItem, playing: true, duration: mediaItem.duration, - position: Duration.zero, + position: _positionTracker.currentPosition, ); } @@ -2610,8 +2643,8 @@ class MusicAssistantProvider with ChangeNotifier { /// Handle Sendspin stream end - server stopped sending PCM audio data /// This is called when audio streaming ends (pause, stop, track end, etc.) void _handleSendspinStreamEnd() async { - // Capture current position before stopping - final lastPosition = _pcmAudioPlayer?.elapsedTime ?? Duration.zero; + // Use position tracker (server-synced) instead of PCM elapsed time which resets to 0 each stream + final lastPosition = _positionTracker.currentPosition; _logger.log('🎵 Sendspin: Stream ended at position ${lastPosition.inSeconds}s'); // Stop notification position timer @@ -2627,9 +2660,8 @@ class MusicAssistantProvider with ChangeNotifier { if (_currentTrack != null) { final track = _currentTrack!; final artworkUrl = _api?.getImageUrl(track, size: 512); - final artistWithPlayer = track.artistsString.isNotEmpty - ? '${track.artistsString} • ${_selectedPlayer?.name ?? ''}' - : _selectedPlayer?.name ?? ''; + final displayArtist = _trackDisplayArtist(track); + final artistWithPlayer = '${displayArtist} • ${_selectedPlayer?.name ?? ''}'; final mediaItem = audio_service.MediaItem( id: track.uri ?? track.itemId, title: track.name, @@ -4394,18 +4426,29 @@ class MusicAssistantProvider with ChangeNotifier { } // Priority 2: Local player (default fallback) + // Try available first, then accept unavailable (Sendspin may still be registering) if (playerToSelect == null && builtinPlayerId != null) { - try { - playerToSelect = _availablePlayers.firstWhere( - (p) => p.playerId == builtinPlayerId && p.available, - ); + playerToSelect = _availablePlayers.cast().firstWhere( + (p) => p!.playerId == builtinPlayerId && p.available, + orElse: () => null, + ); + if (playerToSelect != null) { _logger.log('📱 Selected local player: ${playerToSelect.name}'); - } catch (e) { - _logger.log('⚠️ Local player not available'); + } else { + // Player exists but not yet available (Sendspin registration in progress) + playerToSelect = _availablePlayers.cast().firstWhere( + (p) => p!.playerId == builtinPlayerId, + orElse: () => null, + ); + if (playerToSelect != null) { + _logger.log('📱 Selected local player (pending availability): ${playerToSelect.name}'); + } else { + _logger.log('⚠️ Local player not in player list'); + } } } - // Priority 3: First available player + // Priority 3: First available player (only if no local player configured) if (playerToSelect == null) { playerToSelect = _availablePlayers.firstWhere( (p) => p.available, @@ -4472,9 +4515,8 @@ class MusicAssistantProvider with ChangeNotifier { if (_currentTrack != null && (player.state == 'playing' || player.state == 'paused')) { final track = _currentTrack!; final artworkUrl = _api?.getImageUrl(track, size: 512); - final artistWithPlayer = track.artistsString.isNotEmpty - ? '${track.artistsString} • ${player.name}' - : player.name; + final displayArtist = _trackDisplayArtist(track); + final artistWithPlayer = '${displayArtist} • ${player.name}'; final mediaItem = audio_service.MediaItem( id: track.uri ?? track.itemId, title: track.name, @@ -4505,9 +4547,8 @@ class MusicAssistantProvider with ChangeNotifier { // instead of clearing, to avoid artwork flash when AA toggles play/pause final track = _currentTrack!; final artworkUrl = _api?.getImageUrl(track, size: 512); - final artistWithPlayer = track.artistsString.isNotEmpty - ? '${track.artistsString} • ${player.name}' - : player.name; + final displayArtist = _trackDisplayArtist(track); + final artistWithPlayer = '${displayArtist} • ${player.name}'; final mediaItem = audio_service.MediaItem( id: track.uri ?? track.itemId, title: track.name, @@ -4534,9 +4575,8 @@ class MusicAssistantProvider with ChangeNotifier { final track = _currentTrack!; final artworkUrl = _api?.getImageUrl(track, size: 512); // Include player name in artist line: "Artist • Player Name" - final artistWithPlayer = track.artistsString.isNotEmpty - ? '${track.artistsString} • ${player.name}' - : player.name; + final displayArtist = _trackDisplayArtist(track); + final artistWithPlayer = '${displayArtist} • ${player.name}'; final mediaItem = audio_service.MediaItem( id: track.uri ?? track.itemId, title: track.name, @@ -4716,9 +4756,8 @@ class MusicAssistantProvider with ChangeNotifier { } final artworkUrl = _api?.getImageUrl(track, size: 512); - final artistWithPlayer = track.artistsString.isNotEmpty - ? '${track.artistsString} • ${_selectedPlayer!.name}' - : _selectedPlayer!.name; + final displayArtist = _trackDisplayArtist(track); + final artistWithPlayer = '${displayArtist} • ${_selectedPlayer!.name}'; final mediaItem = audio_service.MediaItem( id: track.uri ?? track.itemId, title: track.name, @@ -5261,9 +5300,8 @@ class MusicAssistantProvider with ChangeNotifier { if (isBuiltinPlayer) { // Local playback - use local mode notification (keeps pause working) - final artistWithPlayer = track.artistsString.isNotEmpty - ? '${track.artistsString} • ${_selectedPlayer!.name}' - : _selectedPlayer!.name; + final displayArtist = _trackDisplayArtist(track); + final artistWithPlayer = '$displayArtist • ${_selectedPlayer!.name}'; final mediaItem = audio_service.MediaItem( id: track.uri ?? track.itemId, title: track.name, @@ -5281,9 +5319,8 @@ class MusicAssistantProvider with ChangeNotifier { } else { // Remote MA player - show notification via remote mode // Include player name in artist line: "Artist • Player Name" - final artistWithPlayer = track.artistsString.isNotEmpty - ? '${track.artistsString} • ${_selectedPlayer!.name}' - : _selectedPlayer!.name; + final displayArtist = _trackDisplayArtist(track); + final artistWithPlayer = '$displayArtist • ${_selectedPlayer!.name}'; final mediaItem = audio_service.MediaItem( id: track.uri ?? track.itemId, title: track.name, @@ -5356,7 +5393,7 @@ class MusicAssistantProvider with ChangeNotifier { final mediaItem = audio_service.MediaItem( id: track.uri ?? track.itemId, title: track.name, - artist: track.artistsString, + artist: _trackDisplayArtist(track), album: track.album?.name, artUri: _contentArtUri(_api?.getImageUrl(track, size: 512)), duration: track.duration, @@ -5396,7 +5433,20 @@ class MusicAssistantProvider with ChangeNotifier { // Always try resume first — even if player reports "idle", the server // queue may still be intact (e.g., Sendspin sends "stopped" on pause). // Only fall back to re-queuing if resume fails. - await resumePlayer(_selectedPlayer!.playerId); + final playerId = _selectedPlayer!.playerId; + await resumePlayer(playerId); + + // Sendspin PCM streaming has ~5s buffering delay on resume. + // For audiobooks, seek back to compensate so the user doesn't miss narration. + final builtinPlayerId = await SettingsService.getBuiltinPlayerId(); + if (builtinPlayerId != null && playerId == builtinPlayerId && _sendspinConnected && isPlayingAudiobook) { + final pos = _positionTracker.currentPosition.inSeconds; + if (pos > 10) { + _logger.log('📚 Seeking back 10s to compensate for Sendspin buffering'); + unawaited(seek(playerId, pos - 10)); + } + } + // For resume: Refresh in background to get updated track info unawaited(refreshPlayers()); } diff --git a/lib/services/audio/massiv_audio_handler.dart b/lib/services/audio/massiv_audio_handler.dart index 7995e0d..e7a5476 100644 --- a/lib/services/audio/massiv_audio_handler.dart +++ b/lib/services/audio/massiv_audio_handler.dart @@ -287,6 +287,8 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler _logger.log('AndroidAuto: startRadio: track=${track.name}, playerId=${player.playerId}'); await provider.playRadio(player.playerId, track); _logger.log('AndroidAuto: startRadio: done'); + // Fetch updated queue after radio starts and populate AA queue + _refreshQueueAfterDelay(provider, player.playerId); case 'cycleRepeat': final queue = await provider.getQueue(player.playerId); final currentMode = queue?.repeatMode ?? 'off'; @@ -865,7 +867,8 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler } if (mediaId.startsWith('podcast_ep|')) { - // Format: podcast_ep|{provider}|{itemId} + // Format: podcast_ep|{epProvider}|{epId}|{podProvider}|{podId} + // Legacy: podcast_ep|{provider}|{itemId} final parts = mediaId.split('|'); if (parts.length < 3) return; if (provider.api == null) await provider.checkAndReconnect(); @@ -873,6 +876,17 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler _logger.log('AndroidAuto: no API, cannot play podcast'); return; } + + // Set podcast name context so notification/AA shows it as artist + if (parts.length >= 5) { + final podcast = SyncService.instance.cachedPodcasts + .where((p) => p.provider == parts[3] && p.itemId == parts[4]) + .firstOrNull; + if (podcast != null) { + provider.setCurrentPodcastName(podcast.name); + } + } + // Use provider-specific URI (e.g. spotify--xxx://podcast_episode/id) // instead of library:// which fails for non-library items final uri = '${parts[1]}://podcast_episode/${parts[2]}'; @@ -885,6 +899,9 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler ); _logger.log('AndroidAuto: playing podcast episode URI: $uri'); await provider.api!.playPodcastEpisode(playerId, episode); + + // Refresh queue after a delay (server needs time to build queue) + _refreshQueueAfterDelay(provider, playerId); return; } } catch (e) { @@ -1010,14 +1027,21 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler // --- Queue & error state helpers --- void _populateQueue(MusicAssistantProvider provider, List tracks, int currentIndex) { - final items = tracks.map((t) => MediaItem( - id: t.uri ?? t.itemId, - title: t.name, - artist: t.artistsString, - album: t.album?.name, - duration: t.duration, - artUri: _autoArtUri(provider, t), - )).toList(); + final contextArtist = provider.currentPodcastName + ?? provider.currentAudiobook?.authorsString; + final items = tracks.map((t) { + final artist = (t.artists == null || t.artists!.isEmpty) + ? contextArtist + : t.artistsString; + return MediaItem( + id: t.uri ?? t.itemId, + title: t.name, + artist: artist, + album: t.album?.name, + duration: t.duration, + artUri: _autoArtUri(provider, t), + ); + }).toList(); queue.add(items); playbackState.add(playbackState.value.copyWith(queueIndex: currentIndex)); } @@ -1027,6 +1051,22 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler playbackState.add(playbackState.value.copyWith(queueIndex: index)); } + /// Fetch the server queue after a delay (e.g. after starting radio/podcast) and update AA queue. + void _refreshQueueAfterDelay(MusicAssistantProvider provider, String playerId) { + Future.delayed(const Duration(seconds: 2), () async { + try { + final q = await provider.getQueue(playerId); + if (q?.items == null || q!.items.isEmpty) return; + final tracks = q.items.map((qi) => qi.track).toList(); + final currentIndex = q.currentIndex ?? 0; + _logger.log('AndroidAuto: refreshed queue: ${tracks.length} tracks'); + _populateQueue(provider, tracks, currentIndex); + } catch (e) { + _logger.log('AndroidAuto: failed to refresh queue: $e'); + } + }); + } + void _setErrorState(String message) { playbackState.add(playbackState.value.copyWith( processingState: AudioProcessingState.error, @@ -1471,9 +1511,15 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler podItemId, provider: podProvider, ); + // Look up podcast name to show as artist for each episode + final podcast = SyncService.instance.cachedPodcasts + .where((p) => p.itemId == podItemId && p.provider == podProvider) + .firstOrNull; + // Encode podcast context in episode ID: podcast_ep|epProvider|epId|podProvider|podId return episodes.map((e) => MediaItem( - id: 'podcast_ep|${e.provider}|${e.itemId}', + id: 'podcast_ep|${e.provider}|${e.itemId}|$podProvider|$podItemId', title: e.name, + artist: podcast?.name, duration: e.duration, artUri: _autoArtUri(provider, e), playable: true, -- 2.39.5 From 68e3b74d5d8c89003db50102d8c922c410e3b3a8 Mon Sep 17 00:00:00 2001 From: Chris Date: Wed, 25 Feb 2026 06:18:08 +0000 Subject: [PATCH 07/19] Add Smart Shuffle to Android Auto and radio fallback to shuffled playback Radio calls now fall back gracefully when the provider doesn't support radio: playRadio falls back to playing the single track, playArtistRadio falls back to shuffled artist tracks via search. Direct API calls in context menu and library screen routed through provider to pick up the fallback. Android Auto gets a "Smart Shuffle" item at the top of track lists (albums, playlists, favorites) with a custom sparkle-shuffle icon that attempts radio then falls back to shuffled playback. Also fixes builtin player selection to avoid premature fallback before Sendspin registration. Co-Authored-By: Claude Opus 4.6 --- .../res/drawable/ic_auto_smart_shuffle.xml | 13 +++++ lib/providers/music_assistant_provider.dart | 58 ++++++++++++++----- lib/screens/new_library_screen.dart | 2 +- lib/services/audio/massiv_audio_handler.dart | 44 +++++++++++++- lib/widgets/media_context_menu.dart | 4 +- 5 files changed, 100 insertions(+), 21 deletions(-) create mode 100644 android/app/src/main/res/drawable/ic_auto_smart_shuffle.xml diff --git a/android/app/src/main/res/drawable/ic_auto_smart_shuffle.xml b/android/app/src/main/res/drawable/ic_auto_smart_shuffle.xml new file mode 100644 index 0000000..66c4d53 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_auto_smart_shuffle.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/lib/providers/music_assistant_provider.dart b/lib/providers/music_assistant_provider.dart index 9531e29..5f10ec6 100644 --- a/lib/providers/music_assistant_provider.dart +++ b/lib/providers/music_assistant_provider.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:convert'; +import 'dart:math'; import 'package:flutter/foundation.dart'; import 'package:flutter/painting.dart'; import 'package:audio_service/audio_service.dart' as audio_service; @@ -2051,8 +2052,13 @@ class MusicAssistantProvider with ChangeNotifier { ); } } - // Last resort: first available - _selectedPlayer ??= _availablePlayers.first; + // Last resort: first available (only if no builtin player configured) + if (_selectedPlayer == null) { + final builtinConfigured = await SettingsService.getBuiltinPlayerId(); + if (builtinConfigured == null) { + _selectedPlayer = _availablePlayers.first; + } + } } _logger.log('⚡ Loaded ${_availablePlayers.length} cached players instantly (sorted)'); notifyListeners(); // Update UI immediately with cached data @@ -4443,13 +4449,14 @@ class MusicAssistantProvider with ChangeNotifier { if (playerToSelect != null) { _logger.log('📱 Selected local player (pending availability): ${playerToSelect.name}'); } else { - _logger.log('⚠️ Local player not in player list'); + _logger.log('⚠️ Local player not in player list yet — waiting for Sendspin registration'); } } } // Priority 3: First available player (only if no local player configured) - if (playerToSelect == null) { + // Don't fall back to another player if builtin player is configured but not yet registered + if (playerToSelect == null && builtinPlayerId == null) { playerToSelect = _availablePlayers.firstWhere( (p) => p.available, orElse: () => _availablePlayers.first, @@ -4457,7 +4464,13 @@ class MusicAssistantProvider with ChangeNotifier { _logger.log('🔄 Fallback to first available player: ${playerToSelect.name}'); } - selectPlayer(playerToSelect); + if (playerToSelect != null) { + selectPlayer(playerToSelect); + } else { + // Builtin player not yet registered — keep current selection if any, + // otherwise leave unselected until Sendspin registration completes + _logger.log('⏳ No player selected — awaiting builtin player registration'); + } } // Preload track data and images in background for swipe gestures @@ -5974,11 +5987,16 @@ class MusicAssistantProvider with ChangeNotifier { try { await _api?.playRadio(playerId, track); } catch (e) { - final errorInfo = ErrorHandler.handleError(e, context: 'Play radio'); - _error = errorInfo.userMessage; - ErrorHandler.logError('Play radio', e); - notifyListeners(); - rethrow; + _logger.log('Radio failed, falling back to single track: $e'); + try { + await playTrack(playerId, track); + } catch (fallbackError) { + final errorInfo = ErrorHandler.handleError(fallbackError, context: 'Play radio fallback'); + _error = errorInfo.userMessage; + ErrorHandler.logError('Play radio fallback', fallbackError); + notifyListeners(); + rethrow; + } } } @@ -5986,11 +6004,21 @@ class MusicAssistantProvider with ChangeNotifier { try { await _api?.playArtistRadio(playerId, artist); } catch (e) { - final errorInfo = ErrorHandler.handleError(e, context: 'Play artist radio'); - _error = errorInfo.userMessage; - ErrorHandler.logError('Play artist radio', e); - notifyListeners(); - rethrow; + _logger.log('Artist radio failed, falling back to shuffled tracks: $e'); + try { + final results = await searchWithCache(artist.name); + final tracks = (results['tracks'] ?? []).whereType().toList(); + if (tracks.isEmpty) rethrow; + tracks.shuffle(Random()); + await _api?.playTracks(playerId, tracks, startIndex: 0); + await _api?.toggleShuffle(playerId, true); + } catch (fallbackError) { + final errorInfo = ErrorHandler.handleError(fallbackError, context: 'Play artist radio fallback'); + _error = errorInfo.userMessage; + ErrorHandler.logError('Play artist radio fallback', fallbackError); + notifyListeners(); + rethrow; + } } } diff --git a/lib/screens/new_library_screen.dart b/lib/screens/new_library_screen.dart index 8b3155f..68da488 100644 --- a/lib/screens/new_library_screen.dart +++ b/lib/screens/new_library_screen.dart @@ -4914,7 +4914,7 @@ class _NewLibraryScreenState extends State try { // Start radio from this track - await maProvider.api?.playRadio(player.playerId, track); + await maProvider.playRadio(player.playerId, track); } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( diff --git a/lib/services/audio/massiv_audio_handler.dart b/lib/services/audio/massiv_audio_handler.dart index e7a5476..07a305e 100644 --- a/lib/services/audio/massiv_audio_handler.dart +++ b/lib/services/audio/massiv_audio_handler.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:convert'; +import 'dart:math'; import 'package:audio_service/audio_service.dart'; import 'package:audio_session/audio_session.dart'; import 'package:just_audio/just_audio.dart'; @@ -554,6 +555,7 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler static final _iconAlbum = Uri.parse('android.resource://$_iconPkg/drawable/ic_auto_album'); static final _iconPlaylist = Uri.parse('android.resource://$_iconPkg/drawable/ic_auto_playlist'); static final _iconFavorite = Uri.parse('android.resource://$_iconPkg/drawable/ic_auto_favorite'); + static final _iconSmartShuffle = Uri.parse('android.resource://$_iconPkg/drawable/ic_auto_smart_shuffle'); // Custom now-playing action buttons for Android Auto // Icons change based on current state for visual feedback @@ -796,6 +798,27 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler _clearErrorState(); try { + if (mediaId.startsWith('smartshuffle|')) { + final ctxKey = mediaId.substring('smartshuffle|'.length); + final trackList = _autoTrackCache[ctxKey]; + if (trackList == null || trackList.isEmpty) { + _logger.log('AndroidAuto: Smart Shuffle: no cached tracks for $ctxKey'); + return; + } + final seed = trackList[Random().nextInt(trackList.length)]; + try { + await provider.api?.playRadio(playerId, seed); + _refreshQueueAfterDelay(provider, playerId); + } catch (e) { + _logger.log('AndroidAuto: Smart Shuffle radio failed, playing shuffled: $e'); + final shuffled = List.from(trackList)..shuffle(Random()); + await provider.playTracks(playerId, shuffled, startIndex: 0); + await provider.toggleShuffle(playerId, true); + _populateQueue(provider, shuffled, 0); + } + return; + } + if (mediaId.startsWith('track|')) { // Format: track|{tProvider}|{tItemId}|{ctxType}|{ctxProvider}|{ctxId} final parts = mediaId.split('|'); @@ -1319,7 +1342,9 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler _logger.log('AndroidAuto: Fav tracks returned ${tracks.length} tracks'); const ctxKey = 'favs||'; _cacheTrackList(ctxKey, tracks); - return tracks.map((t) => _autoTrackItem(provider, t, ctxKey)).toList(); + final items = tracks.map((t) => _autoTrackItem(provider, t, ctxKey)).toList(); + if (items.isNotEmpty) items.insert(0, _autoSmartShuffleItem(ctxKey)); + return items; } List _autoBuildPlaylistList(MusicAssistantProvider provider) { @@ -1340,7 +1365,9 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler await provider.getPlaylistTracksWithCache(plProvider, plItemId); final ctxKey = 'plist|$plProvider|$plItemId'; _cacheTrackList(ctxKey, tracks); - return tracks.map((t) => _autoTrackItem(provider, t, ctxKey)).toList(); + final items = tracks.map((t) => _autoTrackItem(provider, t, ctxKey)).toList(); + if (items.isNotEmpty) items.insert(0, _autoSmartShuffleItem(ctxKey)); + return items; } List _autoBuildArtistList(MusicAssistantProvider provider) { @@ -1390,7 +1417,9 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler _logger.log('AndroidAuto: Album $alProvider/$alItemId tracks: ${tracks.length}'); final ctxKey = 'album|$alProvider|$alItemId'; _cacheTrackList(ctxKey, tracks); - return tracks.map((t) => _autoTrackItem(provider, t, ctxKey)).toList(); + final items = tracks.map((t) => _autoTrackItem(provider, t, ctxKey)).toList(); + if (items.isNotEmpty) items.insert(0, _autoSmartShuffleItem(ctxKey)); + return items; } // --- Audiobook builders --- @@ -1554,6 +1583,15 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler }; } + MediaItem _autoSmartShuffleItem(String ctxKey) { + return MediaItem( + id: 'smartshuffle|$ctxKey', + title: 'Smart Shuffle', + artUri: _iconSmartShuffle, + playable: true, + ); + } + MediaItem _autoTrackItem( MusicAssistantProvider provider, ma.Track t, String ctxKey) { final firstArtist = t.artists?.isNotEmpty == true ? t.artists!.first.name : null; diff --git a/lib/widgets/media_context_menu.dart b/lib/widgets/media_context_menu.dart index a8adfe6..16b9756 100644 --- a/lib/widgets/media_context_menu.dart +++ b/lib/widgets/media_context_menu.dart @@ -403,7 +403,7 @@ class _MediaContextMenuOverlayState extends State<_MediaContextMenuOverlay> switch (widget.mediaType) { case ContextMenuMediaType.artist: final artist = widget.item as Artist; - await maProvider.api?.playArtistRadio(player.playerId, artist); + await maProvider.playArtistRadio(player.playerId, artist); break; case ContextMenuMediaType.track: final track = widget.item as Track; @@ -437,7 +437,7 @@ class _MediaContextMenuOverlayState extends State<_MediaContextMenuOverlay> switch (widget.mediaType) { case ContextMenuMediaType.artist: final artist = widget.item as Artist; - await maProvider.api?.playArtistRadio(player.playerId, artist); + await maProvider.playArtistRadio(player.playerId, artist); break; case ContextMenuMediaType.track: final track = widget.item as Track; -- 2.39.5 From c3f6710dd17e2b6964b6fb968968faf1412a235d Mon Sep 17 00:00:00 2001 From: Chris Date: Wed, 25 Feb 2026 19:03:16 +0000 Subject: [PATCH 08/19] Hide switch player in AA, fix resume on disconnect, rename Smart Shuffle - Detect Android Auto via car mode broadcasts and getChildren; hide switch player button from controls while AA is connected - Suppress audio auto-resume after becoming noisy / AA disconnect to prevent music playing from phone speaker when car turns off - Rename "Smart Shuffle" to "Start Radio" with radio icon Co-Authored-By: Claude Opus 4.6 --- .../com/collotsspot/ensemble/MainActivity.kt | 37 ++++++++++++ lib/services/audio/massiv_audio_handler.dart | 58 +++++++++++++++---- 2 files changed, 84 insertions(+), 11 deletions(-) diff --git a/android/app/src/main/kotlin/com/collotsspot/ensemble/MainActivity.kt b/android/app/src/main/kotlin/com/collotsspot/ensemble/MainActivity.kt index beceaba..77a07bc 100644 --- a/android/app/src/main/kotlin/com/collotsspot/ensemble/MainActivity.kt +++ b/android/app/src/main/kotlin/com/collotsspot/ensemble/MainActivity.kt @@ -1,11 +1,16 @@ package com.collotsspot.ensemble +import android.content.BroadcastReceiver import android.content.Context +import android.content.Intent +import android.content.IntentFilter import android.database.ContentObserver import android.media.AudioManager +import android.os.Build import android.os.Handler import android.os.Looper import android.provider.Settings +import android.app.UiModeManager import android.util.Log import android.view.KeyEvent import com.ryanheise.audioservice.AudioServiceActivity @@ -17,7 +22,10 @@ import io.flutter.plugin.common.MethodChannel class MainActivity: AudioServiceActivity() { private val TAG = "EnsembleVolume" private val CHANNEL = "com.collotsspot.ensemble/volume_buttons" + private val AA_CHANNEL = "com.collotsspot.ensemble/android_auto" private var methodChannel: MethodChannel? = null + private var aaChannel: MethodChannel? = null + private var carModeReceiver: BroadcastReceiver? = null private var isListening = false // Volume observer for lockscreen volume changes @@ -74,6 +82,32 @@ class MainActivity: AudioServiceActivity() { } } } + + // Android Auto / car mode detection + aaChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, AA_CHANNEL) + carModeReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + when (intent.action) { + UiModeManager.ACTION_ENTER_CAR_MODE -> { + Log.d(TAG, "Car mode ENTERED") + aaChannel?.invokeMethod("onAndroidAutoConnected", null) + } + UiModeManager.ACTION_EXIT_CAR_MODE -> { + Log.d(TAG, "Car mode EXITED") + aaChannel?.invokeMethod("onAndroidAutoDisconnected", null) + } + } + } + } + val carModeFilter = IntentFilter().apply { + addAction(UiModeManager.ACTION_ENTER_CAR_MODE) + addAction(UiModeManager.ACTION_EXIT_CAR_MODE) + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + applicationContext.registerReceiver(carModeReceiver, carModeFilter, Context.RECEIVER_EXPORTED) + } else { + applicationContext.registerReceiver(carModeReceiver, carModeFilter) + } } /// Start observing system STREAM_MUSIC volume changes. @@ -224,6 +258,9 @@ class MainActivity: AudioServiceActivity() { override fun onDestroy() { stopVolumeObserver() + carModeReceiver?.let { + try { applicationContext.unregisterReceiver(it) } catch (_: Exception) {} + } super.onDestroy() } } diff --git a/lib/services/audio/massiv_audio_handler.dart b/lib/services/audio/massiv_audio_handler.dart index 07a305e..acaf629 100644 --- a/lib/services/audio/massiv_audio_handler.dart +++ b/lib/services/audio/massiv_audio_handler.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'dart:math'; import 'package:audio_service/audio_service.dart'; import 'package:audio_session/audio_session.dart'; +import 'package:flutter/services.dart'; import 'package:just_audio/just_audio.dart'; import 'package:rxdart/rxdart.dart'; import '../debug_logger.dart'; @@ -58,6 +59,12 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler // Android Auto: subjects for subscribeToChildren final Map>> _autoChildrenSubjects = {}; + // Android Auto connection state — used to hide switch player button from controls + bool _isAndroidAutoConnected = false; + + // Suppress auto-resume after audio route changes (e.g. BT/AA disconnect) + bool _suppressResume = false; + // Custom control for switching players static final _switchPlayerControl = MediaControl.custom( androidIcon: 'drawable/ic_switch_player', @@ -97,6 +104,11 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler _player.setVolume(1.0); break; case AudioInterruptionType.pause: + if (_suppressResume) { + _logger.log('🔊 Audio interruption ended but resume suppressed (audio route changed)'); + _suppressResume = false; + break; + } _player.play(); onPlay?.call(); break; @@ -106,8 +118,10 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler } }); - // Handle becoming noisy (headphones unplugged) + // Handle becoming noisy (headphones unplugged, BT/AA disconnected) _becomingNoisySubscription = session.becomingNoisyEventStream.listen((_) { + _logger.log('🔊 Audio becoming noisy (route changed), suppressing resume'); + _suppressResume = true; pause(); }); @@ -122,6 +136,22 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler mediaItem.add(_currentMediaItem); } }); + + // Listen for Android Auto connection events from native side + const aaChannel = MethodChannel('com.collotsspot.ensemble/android_auto'); + aaChannel.setMethodCallHandler((call) async { + switch (call.method) { + case 'onAndroidAutoConnected': + _logger.log('AndroidAuto: car mode connected (broadcast)'); + _isAndroidAutoConnected = true; + _refreshPlaybackState(); + case 'onAndroidAutoDisconnected': + _logger.log('AndroidAuto: car mode disconnected (broadcast)'); + _isAndroidAutoConnected = false; + _suppressResume = true; + _refreshPlaybackState(); + } + }); } /// Broadcast the current playback state to the system @@ -173,6 +203,7 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler @override Future play() async { + _suppressResume = false; // Always prefer callbacks — they handle both remote players and // Sendspin PCM (which uses local mode but delegates playback to MA server) if (onPlay != null) { @@ -555,7 +586,7 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler static final _iconAlbum = Uri.parse('android.resource://$_iconPkg/drawable/ic_auto_album'); static final _iconPlaylist = Uri.parse('android.resource://$_iconPkg/drawable/ic_auto_playlist'); static final _iconFavorite = Uri.parse('android.resource://$_iconPkg/drawable/ic_auto_favorite'); - static final _iconSmartShuffle = Uri.parse('android.resource://$_iconPkg/drawable/ic_auto_smart_shuffle'); + static final _iconStartRadio = Uri.parse('android.resource://$_iconPkg/drawable/ic_auto_radio'); // Custom now-playing action buttons for Android Auto // Icons change based on current state for visual feedback @@ -604,7 +635,7 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler MediaControl.pause, MediaControl.skipToNext, _shuffleControl(), - _switchPlayerControl, + if (!_isAndroidAutoConnected) _switchPlayerControl, _favoriteControl(), _radioControl, _repeatControl(), @@ -642,6 +673,11 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler // Auto-select builtin player when AA starts browsing, so playback // goes to the phone instead of a previously-selected remote speaker if (parentMediaId == AudioService.browsableRootId) { + if (!_isAndroidAutoConnected) { + _logger.log('AndroidAuto: detected AA connection via getChildren'); + _isAndroidAutoConnected = true; + _refreshPlaybackState(); + } final playerId = await SettingsService.getBuiltinPlayerId(); if (playerId != null && provider.selectedPlayer?.playerId != playerId) { final builtinPlayer = provider.availablePlayersUnfiltered @@ -802,7 +838,7 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler final ctxKey = mediaId.substring('smartshuffle|'.length); final trackList = _autoTrackCache[ctxKey]; if (trackList == null || trackList.isEmpty) { - _logger.log('AndroidAuto: Smart Shuffle: no cached tracks for $ctxKey'); + _logger.log('AndroidAuto: Start Radio: no cached tracks for $ctxKey'); return; } final seed = trackList[Random().nextInt(trackList.length)]; @@ -810,7 +846,7 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler await provider.api?.playRadio(playerId, seed); _refreshQueueAfterDelay(provider, playerId); } catch (e) { - _logger.log('AndroidAuto: Smart Shuffle radio failed, playing shuffled: $e'); + _logger.log('AndroidAuto: Start Radio failed, playing shuffled: $e'); final shuffled = List.from(trackList)..shuffle(Random()); await provider.playTracks(playerId, shuffled, startIndex: 0); await provider.toggleShuffle(playerId, true); @@ -1343,7 +1379,7 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler const ctxKey = 'favs||'; _cacheTrackList(ctxKey, tracks); final items = tracks.map((t) => _autoTrackItem(provider, t, ctxKey)).toList(); - if (items.isNotEmpty) items.insert(0, _autoSmartShuffleItem(ctxKey)); + if (items.isNotEmpty) items.insert(0, _autoStartRadioItem(ctxKey)); return items; } @@ -1366,7 +1402,7 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler final ctxKey = 'plist|$plProvider|$plItemId'; _cacheTrackList(ctxKey, tracks); final items = tracks.map((t) => _autoTrackItem(provider, t, ctxKey)).toList(); - if (items.isNotEmpty) items.insert(0, _autoSmartShuffleItem(ctxKey)); + if (items.isNotEmpty) items.insert(0, _autoStartRadioItem(ctxKey)); return items; } @@ -1418,7 +1454,7 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler final ctxKey = 'album|$alProvider|$alItemId'; _cacheTrackList(ctxKey, tracks); final items = tracks.map((t) => _autoTrackItem(provider, t, ctxKey)).toList(); - if (items.isNotEmpty) items.insert(0, _autoSmartShuffleItem(ctxKey)); + if (items.isNotEmpty) items.insert(0, _autoStartRadioItem(ctxKey)); return items; } @@ -1583,11 +1619,11 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler }; } - MediaItem _autoSmartShuffleItem(String ctxKey) { + MediaItem _autoStartRadioItem(String ctxKey) { return MediaItem( id: 'smartshuffle|$ctxKey', - title: 'Smart Shuffle', - artUri: _iconSmartShuffle, + title: 'Start Radio', + artUri: _iconStartRadio, playable: true, ); } -- 2.39.5 From 0a88d147ed147df6a47621183feba42a0096a242 Mon Sep 17 00:00:00 2001 From: Chris Date: Wed, 25 Feb 2026 23:56:13 +0000 Subject: [PATCH 09/19] 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 --- .../com/collotsspot/ensemble/MainActivity.kt | 43 +++++++++++++++++++ lib/providers/music_assistant_provider.dart | 12 ++++++ lib/services/audio/massiv_audio_handler.dart | 35 ++++++++++++--- 3 files changed, 85 insertions(+), 5 deletions(-) diff --git a/android/app/src/main/kotlin/com/collotsspot/ensemble/MainActivity.kt b/android/app/src/main/kotlin/com/collotsspot/ensemble/MainActivity.kt index 77a07bc..0298ee8 100644 --- a/android/app/src/main/kotlin/com/collotsspot/ensemble/MainActivity.kt +++ b/android/app/src/main/kotlin/com/collotsspot/ensemble/MainActivity.kt @@ -5,6 +5,7 @@ import android.content.Context import android.content.Intent import android.content.IntentFilter import android.database.ContentObserver +import android.hardware.display.DisplayManager import android.media.AudioManager import android.os.Build import android.os.Handler @@ -12,6 +13,7 @@ import android.os.Looper import android.provider.Settings import android.app.UiModeManager import android.util.Log +import android.view.Display import android.view.KeyEvent import com.ryanheise.audioservice.AudioServiceActivity import io.flutter.embedding.engine.FlutterEngine @@ -38,6 +40,10 @@ class MainActivity: AudioServiceActivity() { // Guard flag to ignore volume changes triggered by our own setStreamVolume calls private var ignoringVolumeChange = false + // Android Auto projection tracking via display listener + private var isAATracked = false + private var displayListener: DisplayManager.DisplayListener? = null + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { super.configureFlutterEngine(flutterEngine) Log.d(TAG, "Configuring Flutter engine, setting up MethodChannel") @@ -85,11 +91,25 @@ class MainActivity: AudioServiceActivity() { // Android Auto / car mode detection aaChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, AA_CHANNEL) + + // Receive AA connection signal from Dart side + aaChannel?.setMethodCallHandler { call, result -> + when (call.method) { + "notifyAAConnected" -> { + Log.d(TAG, "AA tracked via Dart notifyAAConnected") + isAATracked = true + result.success(null) + } + else -> result.notImplemented() + } + } + carModeReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { when (intent.action) { UiModeManager.ACTION_ENTER_CAR_MODE -> { Log.d(TAG, "Car mode ENTERED") + isAATracked = true aaChannel?.invokeMethod("onAndroidAutoConnected", null) } UiModeManager.ACTION_EXIT_CAR_MODE -> { @@ -108,6 +128,25 @@ class MainActivity: AudioServiceActivity() { } else { applicationContext.registerReceiver(carModeReceiver, carModeFilter) } + + // Detect AA disconnect via display removal (projection mode) + val dm = getSystemService(Context.DISPLAY_SERVICE) as DisplayManager + displayListener = object : DisplayManager.DisplayListener { + override fun onDisplayAdded(displayId: Int) {} + override fun onDisplayChanged(displayId: Int) {} + override fun onDisplayRemoved(displayId: Int) { + if (!isAATracked) return + val hasExtraDisplay = dm.displays.any { + it.displayId != Display.DEFAULT_DISPLAY + } + if (!hasExtraDisplay) { + Log.d(TAG, "AA projection ended (all extra displays removed)") + isAATracked = false + aaChannel?.invokeMethod("onAndroidAutoDisconnected", null) + } + } + } + dm.registerDisplayListener(displayListener, Handler(Looper.getMainLooper())) } /// Start observing system STREAM_MUSIC volume changes. @@ -261,6 +300,10 @@ class MainActivity: AudioServiceActivity() { carModeReceiver?.let { try { applicationContext.unregisterReceiver(it) } catch (_: Exception) {} } + displayListener?.let { + val dm = getSystemService(Context.DISPLAY_SERVICE) as DisplayManager + dm.unregisterDisplayListener(it) + } super.onDestroy() } } diff --git a/lib/providers/music_assistant_provider.dart b/lib/providers/music_assistant_provider.dart index 5f10ec6..014cb30 100644 --- a/lib/providers/music_assistant_provider.dart +++ b/lib/providers/music_assistant_provider.dart @@ -2130,6 +2130,13 @@ class MusicAssistantProvider with ChangeNotifier { audioHandler.onBrowseActivity = () { _cancelIdleServiceTimer(); }; + audioHandler.onAADisconnected = () { + _logger.log('🎵 AA disconnected: force-pausing player'); + final player = _selectedPlayer; + if (player != null) { + pausePlayer(player.playerId); + } + }; // Player registration is now handled in _initializeAfterConnection() // which runs after authentication completes (when auth is required) @@ -2557,6 +2564,11 @@ class MusicAssistantProvider with ChangeNotifier { /// 2. Start the foreground service to prevent background throttling /// 3. Reset position for new track and start position timer void _handleSendspinStreamStart(Map? trackInfo) async { + final aaDisc = audioHandler.aaDisconnectedAt; + if (aaDisc != null && DateTime.now().difference(aaDisc).inSeconds < 2) { + _logger.log('🎵 Sendspin: Ignoring stream/start (AA disconnected ${DateTime.now().difference(aaDisc).inMilliseconds}ms ago)'); + return; + } _logger.log('🎵 Sendspin: Stream starting'); // Ensure PCM player is initialized and ready diff --git a/lib/services/audio/massiv_audio_handler.dart b/lib/services/audio/massiv_audio_handler.dart index acaf629..b8d6d3e 100644 --- a/lib/services/audio/massiv_audio_handler.dart +++ b/lib/services/audio/massiv_audio_handler.dart @@ -37,6 +37,7 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler Function()? onPause; Function()? onSwitchPlayer; Function()? onBrowseActivity; + Function()? onAADisconnected; // Track whether we're in remote control mode (controlling MA player, not playing locally) bool _isRemoteMode = false; @@ -65,6 +66,15 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler // Suppress auto-resume after audio route changes (e.g. BT/AA disconnect) bool _suppressResume = false; + // Track whether music was actually playing before an interruption + bool _wasPlayingBeforeInterruption = false; + + // Timestamp of last AA disconnect — used to suppress stream/start race condition + DateTime? aaDisconnectedAt; + + // Android Auto method channel (Dart ↔ Native) + static const _aaChannel = MethodChannel('com.collotsspot.ensemble/android_auto'); + // Custom control for switching players static final _switchPlayerControl = MediaControl.custom( androidIcon: 'drawable/ic_switch_player', @@ -94,8 +104,11 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler break; case AudioInterruptionType.pause: case AudioInterruptionType.unknown: - _player.pause(); - onPause?.call(); + _wasPlayingBeforeInterruption = playbackState.value.playing; + if (_wasPlayingBeforeInterruption) { + _player.pause(); + onPause?.call(); + } break; } } else { @@ -109,6 +122,10 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler _suppressResume = false; break; } + if (!_wasPlayingBeforeInterruption) { + _logger.log('🔊 Audio interruption ended but was not playing before'); + break; + } _player.play(); onPlay?.call(); break; @@ -138,17 +155,19 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler }); // Listen for Android Auto connection events from native side - const aaChannel = MethodChannel('com.collotsspot.ensemble/android_auto'); - aaChannel.setMethodCallHandler((call) async { + _aaChannel.setMethodCallHandler((call) async { switch (call.method) { case 'onAndroidAutoConnected': _logger.log('AndroidAuto: car mode connected (broadcast)'); _isAndroidAutoConnected = true; + _aaChannel.invokeMethod('notifyAAConnected', null); _refreshPlaybackState(); case 'onAndroidAutoDisconnected': _logger.log('AndroidAuto: car mode disconnected (broadcast)'); _isAndroidAutoConnected = false; _suppressResume = true; + aaDisconnectedAt = DateTime.now(); + onAADisconnected?.call(); _refreshPlaybackState(); } }); @@ -203,7 +222,6 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler @override Future play() async { - _suppressResume = false; // Always prefer callbacks — they handle both remote players and // Sendspin PCM (which uses local mode but delegates playback to MA server) if (onPlay != null) { @@ -349,6 +367,12 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler final current = playbackState.value; playbackState.add(current.copyWith( controls: _controls, + systemActions: const { + MediaAction.play, + MediaAction.pause, + MediaAction.skipToNext, + MediaAction.skipToPrevious, + }, )); } @@ -676,6 +700,7 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler if (!_isAndroidAutoConnected) { _logger.log('AndroidAuto: detected AA connection via getChildren'); _isAndroidAutoConnected = true; + _aaChannel.invokeMethod('notifyAAConnected', null); _refreshPlaybackState(); } final playerId = await SettingsService.getBuiltinPlayerId(); -- 2.39.5 From 0db9bb3df7c6bc95a73ed57bf7f7f1817c279cae Mon Sep 17 00:00:00 2001 From: Chris Date: Thu, 26 Feb 2026 20:54:56 +0000 Subject: [PATCH 10/19] Fix AA detection: trigger from any getChildren/playFromMediaId, not just root AA caches the browse tree after first session, so getChildren for the browsable root is never called on subsequent connects. This meant _isAndroidAutoConnected was never set, breaking both disconnect detection (display listener ignored) and playback button refresh. Co-Authored-By: Claude Opus 4.6 --- lib/services/audio/massiv_audio_handler.dart | 24 ++++++++++++-------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/lib/services/audio/massiv_audio_handler.dart b/lib/services/audio/massiv_audio_handler.dart index b8d6d3e..5601fc0 100644 --- a/lib/services/audio/massiv_audio_handler.dart +++ b/lib/services/audio/massiv_audio_handler.dart @@ -694,15 +694,14 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler provider.checkAndReconnect(); } - // Auto-select builtin player when AA starts browsing, so playback - // goes to the phone instead of a previously-selected remote speaker - if (parentMediaId == AudioService.browsableRootId) { - if (!_isAndroidAutoConnected) { - _logger.log('AndroidAuto: detected AA connection via getChildren'); - _isAndroidAutoConnected = true; - _aaChannel.invokeMethod('notifyAAConnected', null); - _refreshPlaybackState(); - } + // Detect AA connection from any getChildren call (AA may cache the root + // and only request subcategories, so we can't rely on the root request) + if (!_isAndroidAutoConnected) { + _logger.log('AndroidAuto: detected AA connection via getChildren("$parentMediaId")'); + _isAndroidAutoConnected = true; + _aaChannel.invokeMethod('notifyAAConnected', null); + _refreshPlaybackState(); + // Auto-select builtin player so playback goes to the phone final playerId = await SettingsService.getBuiltinPlayerId(); if (playerId != null && provider.selectedPlayer?.playerId != playerId) { final builtinPlayer = provider.availablePlayersUnfiltered @@ -838,6 +837,13 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler return; } + if (!_isAndroidAutoConnected) { + _logger.log('AndroidAuto: detected AA connection via playFromMediaId'); + _isAndroidAutoConnected = true; + _aaChannel.invokeMethod('notifyAAConnected', null); + _refreshPlaybackState(); + } + final playerId = await SettingsService.getBuiltinPlayerId(); if (playerId == null) { _logger.log('AndroidAuto: no builtin player ID — cannot play'); -- 2.39.5 From 9cd27553c15b548a6392e60bac4848c6e6c6fe30 Mon Sep 17 00:00:00 2001 From: Chris Date: Fri, 27 Feb 2026 06:54:43 +0000 Subject: [PATCH 11/19] Fix AA cold-start caching empty artist albums Don't cache empty artist album results when the library hasn't loaded yet. On cold start, SyncService may not have finished populating the album list, causing getArtistAlbumsWithCache to return and cache 0 results permanently until the cache expires. Co-Authored-By: Claude Opus 4.6 --- lib/providers/music_assistant_provider.dart | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/providers/music_assistant_provider.dart b/lib/providers/music_assistant_provider.dart index 014cb30..c6e076f 100644 --- a/lib/providers/music_assistant_provider.dart +++ b/lib/providers/music_assistant_provider.dart @@ -3994,7 +3994,11 @@ class MusicAssistantProvider with ChangeNotifier { } } - _cacheService.setCachedArtistAlbums(cacheKey, allAlbums); + // Only cache if the library is loaded — empty results during cold start + // are likely due to SyncService not having finished yet + if (allAlbums.isNotEmpty || _albums.isNotEmpty) { + _cacheService.setCachedArtistAlbums(cacheKey, allAlbums); + } _logger.log('✅ Cached ${allAlbums.length} albums for artist "$artistName"'); return allAlbums; } catch (e) { -- 2.39.5 From ea99b2bb11c021ac5876290e74aa47f948204685 Mon Sep 17 00:00:00 2001 From: Chris Date: Sat, 28 Feb 2026 07:50:11 +0000 Subject: [PATCH 12/19] Fix AA cold-start: async list builders and cache invalidation List builders for artists, albums, and playlists now await SyncService.loadFromCache() when data is empty (matching the existing audiobook pattern). getArtistAlbumsWithCache also falls back to SyncService when _albums is empty. After library loads (both from cache and background sync), AA browse children are invalidated so stale empty results get replaced. Fixes #25 Co-Authored-By: Claude Opus 4.6 --- lib/providers/music_assistant_provider.dart | 17 ++++++++++++ lib/services/audio/massiv_audio_handler.dart | 27 +++++++++++++++----- 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/lib/providers/music_assistant_provider.dart b/lib/providers/music_assistant_provider.dart index c6e076f..cb42946 100644 --- a/lib/providers/music_assistant_provider.dart +++ b/lib/providers/music_assistant_provider.dart @@ -1013,6 +1013,9 @@ class MusicAssistantProvider with ChangeNotifier { _logger.log('📦 Pre-loaded library for favorites: ${_albums.length} albums, ${_artists.length} artists'); _syncLibraryStatusToService(); notifyListeners(); + audioHandler.invalidateAutoChildren(const [ + 'cat|artists', 'cat|albums', 'cat|playlists', 'cat|home', + ]); } } catch (e) { _logger.log('⚠️ Error loading library from cache: $e'); @@ -3966,6 +3969,17 @@ class MusicAssistantProvider with ChangeNotifier { if (_api == null) return _cacheService.getCachedArtistAlbums(cacheKey) ?? []; try { + // Ensure library is loaded for artist matching + if (_albums.isEmpty) { + final syncService = SyncService.instance; + if (!syncService.hasCache) { + await syncService.loadFromCache(); + } + if (syncService.cachedAlbums.isNotEmpty) { + _albums = syncService.cachedAlbums; + } + } + _logger.log('🔄 Fetching albums for artist "$artistName"...'); // Use already-loaded library albums instead of re-fetching from API @@ -5583,6 +5597,9 @@ class MusicAssistantProvider with ChangeNotifier { _syncLibraryStatusToService(); notifyListeners(); _prefetchAlbumImages(); + audioHandler.invalidateAutoChildren(const [ + 'cat|artists', 'cat|albums', 'cat|playlists', 'cat|home', + ]); } syncService.removeListener(onSyncComplete); } diff --git a/lib/services/audio/massiv_audio_handler.dart b/lib/services/audio/massiv_audio_handler.dart index 5601fc0..78d0ce3 100644 --- a/lib/services/audio/massiv_audio_handler.dart +++ b/lib/services/audio/massiv_audio_handler.dart @@ -1414,8 +1414,13 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler return items; } - List _autoBuildPlaylistList(MusicAssistantProvider provider) { - final playlists = SyncService.instance.cachedPlaylists; + Future> _autoBuildPlaylistList(MusicAssistantProvider provider) async { + var playlists = SyncService.instance.cachedPlaylists; + if (playlists.isEmpty) { + _logger.log('AndroidAuto: cachedPlaylists empty, loading from cache'); + await SyncService.instance.loadFromCache(); + playlists = SyncService.instance.cachedPlaylists; + } _logger.log('AndroidAuto: Playlists: ${playlists.length}'); return playlists.map((p) => MediaItem( id: 'playlist|${p.provider}|${p.itemId}', @@ -1437,8 +1442,13 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler return items; } - List _autoBuildArtistList(MusicAssistantProvider provider) { - final artists = SyncService.instance.cachedArtists; + Future> _autoBuildArtistList(MusicAssistantProvider provider) async { + var artists = SyncService.instance.cachedArtists; + if (artists.isEmpty) { + _logger.log('AndroidAuto: cachedArtists empty, loading from cache'); + await SyncService.instance.loadFromCache(); + artists = SyncService.instance.cachedArtists; + } _logger.log('AndroidAuto: Artists: ${artists.length}'); return artists.map((a) => MediaItem( id: 'artist|${a.name}', @@ -1465,8 +1475,13 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler )).toList(); } - List _autoBuildAlbumList(MusicAssistantProvider provider) { - final albums = SyncService.instance.cachedAlbums; + Future> _autoBuildAlbumList(MusicAssistantProvider provider) async { + var albums = SyncService.instance.cachedAlbums; + if (albums.isEmpty) { + _logger.log('AndroidAuto: cachedAlbums empty, loading from cache'); + await SyncService.instance.loadFromCache(); + albums = SyncService.instance.cachedAlbums; + } _logger.log('AndroidAuto: Albums: ${albums.length}'); return albums.map((a) => MediaItem( id: 'album|${a.provider}|${a.itemId}', -- 2.39.5 From 2c071344044e91404a3ee7ccf5840c10eebc2ede Mon Sep 17 00:00:00 2001 From: Chris Date: Sat, 28 Feb 2026 21:12:03 +0000 Subject: [PATCH 13/19] Fix volume slider grab area by adding opaque hit test behavior Without HitTestBehavior.opaque, the GestureDetector only registered hits on the 20px painted track area instead of the full 48px touch target. Co-Authored-By: Claude Opus 4.6 --- lib/widgets/volume_control.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/widgets/volume_control.dart b/lib/widgets/volume_control.dart index 615d742..b079633 100644 --- a/lib/widgets/volume_control.dart +++ b/lib/widgets/volume_control.dart @@ -238,6 +238,7 @@ class _VolumeControlState extends State { children: [ // The slider with gesture detection for precision mode GestureDetector( + behavior: HitTestBehavior.opaque, onHorizontalDragStart: (details) { setState(() { _isDragging = true; -- 2.39.5 From bb46a2dee1dbf8d8dce6c8339ddfff3111c7ee21 Mon Sep 17 00:00:00 2001 From: Chris Date: Sat, 28 Feb 2026 21:51:03 +0000 Subject: [PATCH 14/19] Remove noisy periodic logging that filled the 1000-entry bug report buffer Position tracker, queue persistence, notification timer, and message receipt logs fired every few seconds, pushing out meaningful events (AA lifecycle, playback state changes) within minutes. Co-Authored-By: Claude Opus 4.6 --- lib/providers/music_assistant_provider.dart | 2 -- lib/services/music_assistant_api.dart | 2 -- lib/services/position_tracker.dart | 23 +-------------------- lib/services/sendspin_service.dart | 2 -- 4 files changed, 1 insertion(+), 28 deletions(-) diff --git a/lib/providers/music_assistant_provider.dart b/lib/providers/music_assistant_provider.dart index cb42946..df367b5 100644 --- a/lib/providers/music_assistant_provider.dart +++ b/lib/providers/music_assistant_provider.dart @@ -4756,7 +4756,6 @@ class MusicAssistantProvider with ChangeNotifier { return; } // Sendspin PCM player - continue to start timer - _logger.debug('🔔 Starting notification timer for Sendspin PCM player'); } // Only run timer if player is playing @@ -5955,7 +5954,6 @@ class MusicAssistantProvider with ChangeNotifier { final itemJsonList = queue.items.map((item) => jsonEncode(item.toJson())).toList(); await DatabaseService.instance.saveQueue(playerId, itemJsonList); - _logger.debug('💾 Persisted ${queue.items.length} queue items to database'); } catch (e) { _logger.log('⚠️ Error persisting queue to database: $e'); } diff --git a/lib/services/music_assistant_api.dart b/lib/services/music_assistant_api.dart index 05875c0..18c08b3 100644 --- a/lib/services/music_assistant_api.dart +++ b/lib/services/music_assistant_api.dart @@ -259,7 +259,6 @@ class MusicAssistantAPI { void _handleMessage(dynamic message) { try { final data = jsonDecode(message as String) as Map; - _logger.debug('Received message: ${data.keys}'); // Check for server info message (first message on connect) if (data.containsKey('server_version')) { @@ -1989,7 +1988,6 @@ class MusicAssistantAPI { currentIndex = queueResult['current_index'] as int?; shuffleEnabled = queueResult['shuffle_enabled'] as bool?; repeatMode = queueResult['repeat_mode'] as String?; - _logger.debug('🔀 Queue metadata: shuffle_enabled=$shuffleEnabled, repeat_mode=$repeatMode'); final currentItemData = queueResult['current_item'] as Map?; final currentItemName = currentItemData?['name'] as String?; diff --git a/lib/services/position_tracker.dart b/lib/services/position_tracker.dart index 0e6efb3..78c33f0 100644 --- a/lib/services/position_tracker.dart +++ b/lib/services/position_tracker.dart @@ -1,6 +1,5 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; -import 'debug_logger.dart'; /// Single source of truth for playback position tracking. /// @@ -13,8 +12,6 @@ import 'debug_logger.dart'; /// All consumers (UI, notification) should listen to [positionStream] /// instead of calculating position themselves. class PositionTracker { - final DebugLogger _logger = DebugLogger(); - // Current state String? _playerId; bool _isPlaying = false; @@ -100,7 +97,6 @@ class PositionTracker { Duration? duration, double? serverTimestamp, }) { - _logger.debug('⏱️ PositionTracker.updateFromServer: pos=${position.toStringAsFixed(1)}s, isPlaying=$isPlaying, playerId=$playerId'); final bool playerChanged = _playerId != playerId; final bool playStateChanged = _isPlaying != isPlaying; final bool durationChanged = duration != null && _duration != duration; @@ -179,11 +175,8 @@ class PositionTracker { final newAnchorPos = anchorIsGettingStale && !playerChanged && !playStateChanged && isPlaying ? currentInterpolated // Keep our interpolated position, just refresh the anchor time : anchorPos; // Use server position for other cases - _logger.debug('⏱️ PositionTracker: Anchor updated ${_anchorPosition.toStringAsFixed(1)}s -> ${newAnchorPos.toStringAsFixed(1)}s (playerChanged=$playerChanged, playStateChanged=$playStateChanged, positionDiff=${positionDiff.toStringAsFixed(1)}, suspiciousReset=$isSuspiciousReset, anchorStale=$anchorIsGettingStale)'); _anchorPosition = newAnchorPos; _anchorTime = DateTime.now(); - } else if (positionDiff > 2) { - _logger.debug('⏱️ PositionTracker: Anchor NOT updated (suspiciousReset=$isSuspiciousReset, staleTimestamp=$hasStaleTimestamp, interpolated=${currentInterpolated.toStringAsFixed(1)}s)'); } _isPlaying = isPlaying; @@ -227,7 +220,6 @@ class PositionTracker { /// Clear tracker state (e.g., when disconnecting) void clear() { - _logger.debug('⏱️ PositionTracker.clear() called'); _stopInterpolationTimer(); _playerId = null; _isPlaying = false; @@ -237,20 +229,9 @@ class PositionTracker { } void _startInterpolationTimer() { - if (_interpolationTimer != null) { - _logger.debug('⏱️ PositionTracker: Timer already running, skipping start'); - return; - } + if (_interpolationTimer != null) return; - _logger.debug('⏱️ PositionTracker: Starting interpolation timer (anchor=${_anchorPosition.toStringAsFixed(1)}s)'); _interpolationTimer = Timer.periodic(const Duration(milliseconds: 250), (timer) { - // Log every 4 ticks (1 second) if no emission happened - if (timer.tick % 4 == 0) { - final pos = currentPosition; - if (pos.inSeconds == _lastEmittedSeconds) { - _logger.debug('⏱️ PositionTracker: Timer tick ${timer.tick}, pos=${pos.inSeconds}s unchanged'); - } - } _emitPosition(); }); @@ -259,7 +240,6 @@ class PositionTracker { } void _stopInterpolationTimer() { - _logger.debug('⏱️ PositionTracker: Stopping interpolation timer (was ${_interpolationTimer != null ? "running" : "null"})'); _interpolationTimer?.cancel(); _interpolationTimer = null; } @@ -271,7 +251,6 @@ class PositionTracker { // Only emit if second changed (reduces unnecessary updates) if (seconds != _lastEmittedSeconds) { _lastEmittedSeconds = seconds; - _logger.debug('⏱️ PositionTracker: Emitting position ${seconds}s (anchor=${_anchorPosition.toStringAsFixed(1)}s, playing=$_isPlaying)'); _positionController.add(pos); } } diff --git a/lib/services/sendspin_service.dart b/lib/services/sendspin_service.dart index 8bf3954..1ee9e44 100644 --- a/lib/services/sendspin_service.dart +++ b/lib/services/sendspin_service.dart @@ -321,8 +321,6 @@ class SendspinService { final data = jsonDecode(message) as Map; final type = data['type'] as String?; - _logger.log('Sendspin: Received message type: $type'); - switch (type) { case 'server/hello': // Server acknowledged our client/hello - we're registered! -- 2.39.5 From 187720503402e884d90762563984bfb86c0ff952 Mon Sep 17 00:00:00 2001 From: Chris Date: Sat, 28 Feb 2026 22:12:08 +0000 Subject: [PATCH 15/19] Add Start Radio button to artist albums page in Android Auto Co-Authored-By: Claude Opus 4.6 --- lib/services/audio/massiv_audio_handler.dart | 37 ++++++++++++++++---- 1 file changed, 30 insertions(+), 7 deletions(-) diff --git a/lib/services/audio/massiv_audio_handler.dart b/lib/services/audio/massiv_audio_handler.dart index 78d0ce3..17b9855 100644 --- a/lib/services/audio/massiv_audio_handler.dart +++ b/lib/services/audio/massiv_audio_handler.dart @@ -865,6 +865,21 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler _clearErrorState(); try { + if (mediaId.startsWith('artistradio|')) { + final artistName = mediaId.substring('artistradio|'.length); + final artist = SyncService.instance.cachedArtists + .where((a) => a.name == artistName) + .firstOrNull; + if (artist == null) { + _logger.log('AndroidAuto: Artist radio: artist "$artistName" not found'); + return; + } + _logger.log('AndroidAuto: Starting artist radio for "$artistName"'); + await provider.api?.playArtistRadio(playerId, artist); + _refreshQueueAfterDelay(provider, playerId); + return; + } + if (mediaId.startsWith('smartshuffle|')) { final ctxKey = mediaId.substring('smartshuffle|'.length); final trackList = _autoTrackCache[ctxKey]; @@ -1466,13 +1481,21 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler albums = provider.getArtistAlbumsFromLibrary(artistName); } _logger.log('AndroidAuto: Artist "$artistName" albums: ${albums.length}'); - return albums.map((a) => MediaItem( - id: 'album|${a.provider}|${a.itemId}', - title: a.name, - artist: a.artistsString, - artUri: _autoArtUri(provider, a), - playable: false, - )).toList(); + return [ + MediaItem( + id: 'artistradio|$artistName', + title: 'Start Radio', + artUri: _iconRadio, + playable: true, + ), + ...albums.map((a) => MediaItem( + id: 'album|${a.provider}|${a.itemId}', + title: a.name, + artist: a.artistsString, + artUri: _autoArtUri(provider, a), + playable: false, + )), + ]; } Future> _autoBuildAlbumList(MusicAssistantProvider provider) async { -- 2.39.5 From a3eb3db8a262a5c7ba14648ebc5a5b87d87b0649 Mon Sep 17 00:00:00 2001 From: Chris Date: Sun, 1 Mar 2026 07:54:47 +0000 Subject: [PATCH 16/19] Fix 4 regressions: BT/AA pause, AA buttons, artist cache, AA disconnect - Make onPlay/onPause callbacks non-toggle (resumePlayer/pausePlayer instead of playPauseSelectedPlayer) so AA buttons work correctly - Fix interruption guard to treat builtin-active as "was playing" for PCM/Sendspin mode where playbackState can be stale - Keep media session alive during transient idle when PCM is playing - Only cache non-empty artist album results to prevent stale 0 counts - Only pause builtin player on AA disconnect (not remote speakers) - Auto-select builtin player on AA connect broadcast Fixes #25 Co-Authored-By: Claude Opus 4.6 --- lib/providers/music_assistant_provider.dart | 35 +++++++++++++++----- lib/services/audio/massiv_audio_handler.dart | 4 ++- 2 files changed, 29 insertions(+), 10 deletions(-) diff --git a/lib/providers/music_assistant_provider.dart b/lib/providers/music_assistant_provider.dart index df367b5..5390adb 100644 --- a/lib/providers/music_assistant_provider.dart +++ b/lib/providers/music_assistant_provider.dart @@ -2120,11 +2120,16 @@ class MusicAssistantProvider with ChangeNotifier { }; audioHandler.onPlay = () { _logger.log('🎵 Notification: Play pressed'); - playPauseSelectedPlayer(); + if (_selectedPlayer != null) { + resumePlayer(_selectedPlayer!.playerId); + unawaited(refreshPlayers()); + } }; audioHandler.onPause = () { _logger.log('🎵 Notification: Pause pressed'); - playPauseSelectedPlayer(); + if (_selectedPlayer != null) { + pausePlayer(_selectedPlayer!.playerId); + } }; audioHandler.onSwitchPlayer = () { _logger.log('🎵 Notification: Switch player pressed'); @@ -2133,11 +2138,23 @@ class MusicAssistantProvider with ChangeNotifier { audioHandler.onBrowseActivity = () { _cancelIdleServiceTimer(); }; - audioHandler.onAADisconnected = () { - _logger.log('🎵 AA disconnected: force-pausing player'); - final player = _selectedPlayer; - if (player != null) { - pausePlayer(player.playerId); + audioHandler.onAAConnected = () async { + final playerId = await SettingsService.getBuiltinPlayerId(); + if (playerId != null && _selectedPlayer?.playerId != playerId) { + final builtinPlayer = _availablePlayers + .where((p) => p.playerId == playerId) + .firstOrNull; + if (builtinPlayer != null) { + _logger.log('🎵 AA connected: auto-selecting builtin player "${builtinPlayer.name}"'); + selectPlayer(builtinPlayer); + } + } + }; + audioHandler.onAADisconnected = () async { + final builtinPlayerId = await SettingsService.getBuiltinPlayerId(); + if (builtinPlayerId != null) { + _logger.log('🎵 AA disconnected: pausing builtin player'); + pausePlayer(builtinPlayerId); } }; @@ -4010,7 +4027,7 @@ class MusicAssistantProvider with ChangeNotifier { // Only cache if the library is loaded — empty results during cold start // are likely due to SyncService not having finished yet - if (allAlbums.isNotEmpty || _albums.isNotEmpty) { + if (allAlbums.isNotEmpty) { _cacheService.setCachedArtistAlbums(cacheKey, allAlbums); } _logger.log('✅ Cached ${allAlbums.length} albums for artist "$artistName"'); @@ -5199,7 +5216,7 @@ class MusicAssistantProvider with ChangeNotifier { final isPlayingOrPaused = _selectedPlayer!.state == 'playing' || _selectedPlayer!.state == 'paused'; final isIdleWithContent = _selectedPlayer!.state == 'idle' && _selectedPlayer!.powered; - final shouldShowTrack = _selectedPlayer!.available && (isPlayingOrPaused || isIdleWithContent); + final shouldShowTrack = _selectedPlayer!.available && (isPlayingOrPaused || isIdleWithContent || isPcmPlaying); if (!shouldShowTrack) { if (_currentTrack != null) { diff --git a/lib/services/audio/massiv_audio_handler.dart b/lib/services/audio/massiv_audio_handler.dart index 17b9855..6f7b30c 100644 --- a/lib/services/audio/massiv_audio_handler.dart +++ b/lib/services/audio/massiv_audio_handler.dart @@ -37,6 +37,7 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler Function()? onPause; Function()? onSwitchPlayer; Function()? onBrowseActivity; + Function()? onAAConnected; Function()? onAADisconnected; // Track whether we're in remote control mode (controlling MA player, not playing locally) @@ -104,7 +105,7 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler break; case AudioInterruptionType.pause: case AudioInterruptionType.unknown: - _wasPlayingBeforeInterruption = playbackState.value.playing; + _wasPlayingBeforeInterruption = playbackState.value.playing || _isBuiltinPlayerActive; if (_wasPlayingBeforeInterruption) { _player.pause(); onPause?.call(); @@ -161,6 +162,7 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler _logger.log('AndroidAuto: car mode connected (broadcast)'); _isAndroidAutoConnected = true; _aaChannel.invokeMethod('notifyAAConnected', null); + onAAConnected?.call(); _refreshPlaybackState(); case 'onAndroidAutoDisconnected': _logger.log('AndroidAuto: car mode disconnected (broadcast)'); -- 2.39.5 From 76d0f0b80e5ac44ae3737a9b573aca3b4c42535d Mon Sep 17 00:00:00 2001 From: Chris Date: Mon, 2 Mar 2026 06:14:36 +0000 Subject: [PATCH 17/19] Sync shuffle/repeat state from queue when AA connects The local _shuffleOn and _repeatMode flags were initialized to off, so AA buttons always showed disabled on connect even when shuffle/repeat were actually enabled. Co-Authored-By: Claude Opus 4.6 --- lib/services/audio/massiv_audio_handler.dart | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/lib/services/audio/massiv_audio_handler.dart b/lib/services/audio/massiv_audio_handler.dart index 6f7b30c..224f04c 100644 --- a/lib/services/audio/massiv_audio_handler.dart +++ b/lib/services/audio/massiv_audio_handler.dart @@ -702,6 +702,15 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler _logger.log('AndroidAuto: detected AA connection via getChildren("$parentMediaId")'); _isAndroidAutoConnected = true; _aaChannel.invokeMethod('notifyAAConnected', null); + // Sync shuffle/repeat state from the queue so AA buttons show correctly + final selectedId = provider.selectedPlayer?.playerId; + if (selectedId != null) { + final queue = await provider.getQueue(selectedId); + if (queue != null) { + _shuffleOn = queue.shuffle; + _repeatMode = queue.repeatMode ?? 'off'; + } + } _refreshPlaybackState(); // Auto-select builtin player so playback goes to the phone final playerId = await SettingsService.getBuiltinPlayerId(); -- 2.39.5 From e15cfb545e4e922add32fa66aeba23c8eb68a1de Mon Sep 17 00:00:00 2001 From: Chris Date: Mon, 2 Mar 2026 06:47:18 +0000 Subject: [PATCH 18/19] Smooth scroll-to-hide for library filter bar and search bar Replace boolean toggle with continuous ValueNotifier so bars slide 1:1 with finger movement instead of snapping. Use ValueListenableBuilder to avoid full-screen rebuilds on every scroll frame, and Align(heightFactor) for uniform top-clip instead of SizedBox squishing. Co-Authored-By: Claude Opus 4.6 --- lib/screens/new_library_screen.dart | 66 ++++++++++++++--------------- lib/screens/search_screen.dart | 57 ++++++++++++------------- 2 files changed, 60 insertions(+), 63 deletions(-) diff --git a/lib/screens/new_library_screen.dart b/lib/screens/new_library_screen.dart index 68da488..3ee05c0 100644 --- a/lib/screens/new_library_screen.dart +++ b/lib/screens/new_library_screen.dart @@ -159,10 +159,8 @@ class _NewLibraryScreenState extends State // PERF: Separate ValueNotifier for efficient UI updates (RestorableInt doesn't implement ValueListenable) final ValueNotifier _tabIndexNotifier = ValueNotifier(0); - // Scroll-to-hide filter bars - bool _isFilterBarVisible = true; - double _lastScrollOffset = 0; - static const double _scrollThreshold = 10.0; + // Scroll-to-hide filter bars (0.0 = hidden, 1.0 = visible) + final ValueNotifier _filterBarVisibility = ValueNotifier(1.0); bool _isLetterScrollbarDragging = false; // Disable scroll-to-hide while dragging // Options menu overlay - stored to close on navigation @@ -859,17 +857,11 @@ class _NewLibraryScreenState extends State } if (notification is ScrollUpdateNotification) { - final currentOffset = notification.metrics.pixels; - final delta = currentOffset - _lastScrollOffset; - - if (delta.abs() > _scrollThreshold) { - final shouldShow = delta < 0 || currentOffset <= 0; - if (shouldShow != _isFilterBarVisible) { - setState(() { - _isFilterBarVisible = shouldShow; - }); - } - _lastScrollOffset = currentOffset; + final delta = notification.scrollDelta ?? 0.0; + if (notification.metrics.pixels <= 0) { + _filterBarVisibility.value = 1.0; + } else { + _filterBarVisibility.value = (_filterBarVisibility.value - delta / _filterRowHeight).clamp(0.0, 1.0); } } return false; @@ -911,13 +903,11 @@ class _NewLibraryScreenState extends State } void _onLetterScrollbarDragChanged(bool isDragging) { - setState(() { - _isLetterScrollbarDragging = isDragging; - // Show the filter bar when starting to drag - if (isDragging) { - _isFilterBarVisible = true; - } - }); + _isLetterScrollbarDragging = isDragging; + // Show the filter bar when starting to drag + if (isDragging) { + _filterBarVisibility.value = 1.0; + } } void _animateToCategory(int index) { @@ -942,6 +932,7 @@ class _NewLibraryScreenState extends State _pageController.dispose(); _selectedTabIndex.dispose(); _tabIndexNotifier.dispose(); + _filterBarVisibility.dispose(); _artistsScrollController.dispose(); _albumsScrollController.dispose(); _playlistsScrollController.dispose(); @@ -1449,7 +1440,12 @@ class _NewLibraryScreenState extends State child: Column( children: [ // Two-row filter: Row 1 = Media types (hides on scroll), Row 2 = Sub-categories (always visible) - _buildFilterRows(colorScheme, l10n, showLibraryTypeRow: _isFilterBarVisible), + ValueListenableBuilder( + valueListenable: _filterBarVisibility, + builder: (context, visibility, _) { + return _buildFilterRows(colorScheme, l10n, barVisibility: visibility); + }, + ), // Connecting banner when showing cached data // Hide when we have cached players - UI is functional during background reconnect if (!isConnected && syncService.hasCache && !context.read().hasCachedPlayers) @@ -1546,23 +1542,25 @@ class _NewLibraryScreenState extends State // Consistent height for filter rows static const double _filterRowHeight = 36.0; - Widget _buildFilterRows(ColorScheme colorScheme, S l10n, {required bool showLibraryTypeRow}) { + Widget _buildFilterRows(ColorScheme colorScheme, S l10n, {required double barVisibility}) { return Column( mainAxisSize: MainAxisSize.min, children: [ // Row 1: Media type chips (hides when scrolling) - AnimatedContainer( - duration: const Duration(milliseconds: 200), - curve: Curves.easeOutCubic, - height: showLibraryTypeRow ? _filterRowHeight : 0, - clipBehavior: Clip.hardEdge, - decoration: const BoxDecoration(), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12), - child: _buildMediaTypeChips(colorScheme, l10n), + ClipRect( + child: Align( + alignment: Alignment.bottomCenter, + heightFactor: barVisibility, + child: SizedBox( + height: _filterRowHeight, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: _buildMediaTypeChips(colorScheme, l10n), + ), + ), ), ), - if (showLibraryTypeRow) const SizedBox(height: 12), // Space between rows + SizedBox(height: 12 * barVisibility), // Space between rows // Row 2: Sub-category chips (left) + action buttons (right) - always visible SizedBox( height: _filterRowHeight, diff --git a/lib/screens/search_screen.dart b/lib/screens/search_screen.dart index bd4a0df..4ec48eb 100644 --- a/lib/screens/search_screen.dart +++ b/lib/screens/search_screen.dart @@ -130,10 +130,8 @@ class SearchScreenState extends State { // Advanced search scorer with fuzzy matching, stopword removal, etc. final SearchScorer _searchScorer = SearchScorer(); - // Scroll-to-hide search bar (vertical scroll only) - bool _isSearchBarVisible = true; - double _lastVerticalScrollOffset = 0; - static const double _scrollThreshold = 10.0; + // Scroll-to-hide search bar (0.0 = hidden, 1.0 = visible) + final ValueNotifier _searchBarVisibility = ValueNotifier(1.0); // Filter chip position tracking for animated sliding highlight final Map _filterKeys = {}; @@ -314,10 +312,7 @@ class SearchScreenState extends State { void requestFocus() { if (mounted) { - setState(() { - _isSearchBarVisible = true; - _lastVerticalScrollOffset = 0; - }); + _searchBarVisibility.value = 1.0; _focusNode.requestFocus(); } } @@ -336,6 +331,7 @@ class SearchScreenState extends State { _pageController.dispose(); _filterScrollController.dispose(); _activeFilterNotifier.dispose(); + _searchBarVisibility.dispose(); super.dispose(); } @@ -347,17 +343,12 @@ class SearchScreenState extends State { } if (notification is ScrollUpdateNotification) { - final currentOffset = notification.metrics.pixels; - final delta = currentOffset - _lastVerticalScrollOffset; - - if (delta.abs() > _scrollThreshold) { - final shouldShow = delta < 0 || currentOffset <= 0; - if (shouldShow != _isSearchBarVisible) { - setState(() { - _isSearchBarVisible = shouldShow; - }); - } - _lastVerticalScrollOffset = currentOffset; + final delta = notification.scrollDelta ?? 0.0; + const maxHeight = kToolbarHeight + 16; + if (notification.metrics.pixels <= 0) { + _searchBarVisibility.value = 1.0; + } else { + _searchBarVisibility.value = (_searchBarVisibility.value - delta / maxHeight).clamp(0.0, 1.0); } } return false; @@ -534,16 +525,23 @@ class SearchScreenState extends State { body: SafeArea( child: Column( children: [ - // Animated search bar - hides on scroll down - AnimatedContainer( - duration: const Duration(milliseconds: 200), - curve: Curves.easeOutCubic, - height: _isSearchBarVisible ? kToolbarHeight + 16 : 0, - clipBehavior: Clip.hardEdge, - decoration: const BoxDecoration(), - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), - child: Row( + // Search bar - slides away with scroll + ValueListenableBuilder( + valueListenable: _searchBarVisibility, + builder: (context, visibility, child) { + return ClipRect( + child: Align( + alignment: Alignment.bottomCenter, + heightFactor: visibility, + child: child!, + ), + ); + }, + child: SizedBox( + height: kToolbarHeight + 16, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), + child: Row( children: [ // Search field container Expanded( @@ -647,6 +645,7 @@ class SearchScreenState extends State { ], ), ), + ), ), // Main content Expanded( -- 2.39.5 From 40a2e2f43a07c84fe33f9dce2cb26551c5dd0552 Mon Sep 17 00:00:00 2001 From: Chris Date: Thu, 5 Mar 2026 08:27:14 +0000 Subject: [PATCH 19/19] Fix AA shuffle/repeat buttons and artist images Shuffle/repeat buttons showed disabled after starting playback because _refreshQueueAfterDelay only updated queue items without syncing the shuffle/repeat state. Now syncs both flags and refreshes playback state. Artist images failed in AA because _autoBuildArtistList used the basic getImageUrl which returns null for artists without MA metadata images. Now uses getArtistImageUrlWithFallback which tries external sources. Co-Authored-By: Claude Opus 4.6 --- lib/services/audio/massiv_audio_handler.dart | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/lib/services/audio/massiv_audio_handler.dart b/lib/services/audio/massiv_audio_handler.dart index 224f04c..61871b7 100644 --- a/lib/services/audio/massiv_audio_handler.dart +++ b/lib/services/audio/massiv_audio_handler.dart @@ -1175,8 +1175,11 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler if (q?.items == null || q!.items.isEmpty) return; final tracks = q.items.map((qi) => qi.track).toList(); final currentIndex = q.currentIndex ?? 0; - _logger.log('AndroidAuto: refreshed queue: ${tracks.length} tracks'); + _shuffleOn = q.shuffle; + _repeatMode = q.repeatMode ?? 'off'; + _logger.log('AndroidAuto: refreshed queue: ${tracks.length} tracks, shuffle=$_shuffleOn, repeat=$_repeatMode'); _populateQueue(provider, tracks, currentIndex); + _refreshPlaybackState(); } catch (e) { _logger.log('AndroidAuto: failed to refresh queue: $e'); } @@ -1476,12 +1479,15 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler artists = SyncService.instance.cachedArtists; } _logger.log('AndroidAuto: Artists: ${artists.length}'); - return artists.map((a) => MediaItem( - id: 'artist|${a.name}', - title: a.name, - artUri: _autoArtUri(provider, a), - playable: false, - )).toList(); + return Future.wait(artists.map((a) async { + final imageUrl = await provider.getArtistImageUrlWithFallback(a, size: 256); + return MediaItem( + id: 'artist|${a.name}', + title: a.name, + artUri: imageUrl != null ? _contentUriForArtwork(imageUrl) : null, + playable: false, + ); + })); } Future> _autoBuildArtistAlbums( -- 2.39.5