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/kotlin/com/collotsspot/ensemble/MainActivity.kt b/android/app/src/main/kotlin/com/collotsspot/ensemble/MainActivity.kt index beceaba..0298ee8 100644 --- a/android/app/src/main/kotlin/com/collotsspot/ensemble/MainActivity.kt +++ b/android/app/src/main/kotlin/com/collotsspot/ensemble/MainActivity.kt @@ -1,12 +1,19 @@ 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.hardware.display.DisplayManager 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.Display import android.view.KeyEvent import com.ryanheise.audioservice.AudioServiceActivity import io.flutter.embedding.engine.FlutterEngine @@ -17,7 +24,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 @@ -30,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") @@ -74,6 +88,65 @@ 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 -> { + 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) + } + + // 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. @@ -224,6 +297,13 @@ class MainActivity: AudioServiceActivity() { override fun onDestroy() { stopVolumeObserver() + 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/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/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/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 282d3e3..5390adb 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; @@ -99,6 +100,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) @@ -117,6 +126,7 @@ class MusicAssistantProvider with ChangeNotifier { TrackMetadata? _pendingTrackMetadata; TrackMetadata? _currentNotificationMetadata; Completer? _registrationInProgress; + Completer? _reconnectInProgress; // Local player service late final LocalPlayerService _localPlayer; @@ -681,6 +691,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; @@ -885,14 +929,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(); @@ -953,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'); @@ -1946,6 +2009,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 +2022,82 @@ 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, + // 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) { + _selectedPlayer = _availablePlayers.cast().firstWhere( + (p) => p!.playerId == lastSelectedPlayerId, + orElse: () => null, ); - } catch (e) { - _selectedPlayer = _availablePlayers.first; } - } else { - _selectedPlayer = _availablePlayers.first; + // 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 (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 } - _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; } } @@ -2029,16 +2120,43 @@ 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'); selectNextPlayer(); }; + audioHandler.onBrowseActivity = () { + _cancelIdleServiceTimer(); + }; + 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); + } + }; // Player registration is now handled in _initializeAfterConnection() // which runs after authentication completes (when auth is required) @@ -2466,6 +2584,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 @@ -2508,25 +2631,46 @@ 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: artworkUrl != null ? Uri.parse(artworkUrl) : null, - 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 displayArtist = _trackDisplayArtist(track); + final artistWithPlayer = '${displayArtist} • ${_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: _positionTracker.currentPosition, + ); + } 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: _positionTracker.currentPosition, + ); + } // Start notification position timer for Sendspin PCM _manageNotificationPositionTimer(); @@ -2537,8 +2681,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 @@ -2547,27 +2691,46 @@ 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: metadata?.artworkUrl != null ? Uri.parse(metadata!.artworkUrl!) : null, - 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 displayArtist = _trackDisplayArtist(track); + final artistWithPlayer = '${displayArtist} • ${_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() { @@ -2771,6 +2934,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'); } @@ -2783,6 +2947,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'); } @@ -3821,6 +3986,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 @@ -3849,7 +4025,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) { + _cacheService.setCachedArtistAlbums(cacheKey, allAlbums); + } _logger.log('✅ Cached ${allAlbums.length} albums for artist "$artistName"'); return allAlbums; } catch (e) { @@ -4299,19 +4479,31 @@ 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 yet — waiting for Sendspin registration'); + } } } - // Priority 3: First available player - if (playerToSelect == null) { + // Priority 3: First available player (only if no local player configured) + // 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, @@ -4319,7 +4511,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 @@ -4377,16 +4575,15 @@ 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, 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( @@ -4405,8 +4602,29 @@ 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 displayArtist = _trackDisplayArtist(track); + final artistWithPlayer = '${displayArtist} • ${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(); } @@ -4417,16 +4635,15 @@ 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, 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; @@ -4556,7 +4773,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 @@ -4599,25 +4815,37 @@ 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, artist: artistWithPlayer, album: track.album?.name ?? '', duration: track.duration, - artUri: artworkUrl != null ? Uri.tryParse(artworkUrl) : null, + artUri: _contentArtUri(artworkUrl), ); _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 { @@ -4988,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) { @@ -5032,6 +5260,10 @@ class MusicAssistantProvider with ChangeNotifier { } if (queue != null && queue.currentItem != null) { + if (queue.currentIndex != null) { + audioHandler.updateQueueIndex(queue.currentIndex!); + } + final queueTrack = queue.currentItem!.track; final trackChanged = _currentTrack == null || _currentTrack!.uri != queueTrack.uri || @@ -5127,16 +5359,15 @@ 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, 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( @@ -5147,16 +5378,15 @@ 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, 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; @@ -5222,9 +5452,9 @@ 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: _api != null ? Uri.tryParse(_api!.getImageUrl(track, size: 512) ?? '') : null, + artUri: _contentArtUri(_api?.getImageUrl(track, size: 512)), duration: track.duration, ); audioHandler.updateLocalModeNotification( @@ -5259,15 +5489,23 @@ 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. + 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()); } @@ -5375,6 +5613,9 @@ class MusicAssistantProvider with ChangeNotifier { _syncLibraryStatusToService(); notifyListeners(); _prefetchAlbumImages(); + audioHandler.invalidateAutoChildren(const [ + 'cat|artists', 'cat|albums', 'cat|playlists', 'cat|home', + ]); } syncService.removeListener(onSyncComplete); } @@ -5730,7 +5971,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'); } @@ -5795,11 +6035,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; + } } } @@ -5807,11 +6052,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_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). diff --git a/lib/screens/new_library_screen.dart b/lib/screens/new_library_screen.dart index 8b3155f..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, @@ -4914,7 +4912,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/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( diff --git a/lib/services/audio/massiv_audio_handler.dart b/lib/services/audio/massiv_audio_handler.dart index cd24642..61871b7 100644 --- a/lib/services/audio/massiv_audio_handler.dart +++ b/lib/services/audio/massiv_audio_handler.dart @@ -1,6 +1,9 @@ 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:flutter/services.dart'; import 'package:just_audio/just_audio.dart'; import 'package:rxdart/rxdart.dart'; import '../debug_logger.dart'; @@ -33,6 +36,9 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler Function()? onPlay; Function()? onPause; Function()? onSwitchPlayer; + Function()? onBrowseActivity; + Function()? onAAConnected; + Function()? onAADisconnected; // Track whether we're in remote control mode (controlling MA player, not playing locally) bool _isRemoteMode = false; @@ -42,16 +48,39 @@ 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 = {}; - // Custom control for switching players (uses stop action with custom icon) - static const _switchPlayerControl = MediaControl( + // 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; + + // 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', label: 'Switch Player', - action: MediaAction.stop, + name: 'switchPlayer', ); MassivAudioHandler({required this.authManager}) { @@ -63,8 +92,12 @@ 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) + // 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}, builtinActive=$_isBuiltinPlayerActive'); + if (!_isBuiltinPlayerActive) return; if (event.begin) { switch (event.type) { case AudioInterruptionType.duck: @@ -72,7 +105,11 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler break; case AudioInterruptionType.pause: case AudioInterruptionType.unknown: - pause(); + _wasPlayingBeforeInterruption = playbackState.value.playing || _isBuiltinPlayerActive; + if (_wasPlayingBeforeInterruption) { + _player.pause(); + onPause?.call(); + } break; } } else { @@ -81,7 +118,17 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler _player.setVolume(1.0); break; case AudioInterruptionType.pause: - play(); + if (_suppressResume) { + _logger.log('🔊 Audio interruption ended but resume suppressed (audio route changed)'); + _suppressResume = false; + break; + } + if (!_wasPlayingBeforeInterruption) { + _logger.log('🔊 Audio interruption ended but was not playing before'); + break; + } + _player.play(); + onPlay?.call(); break; case AudioInterruptionType.unknown: break; @@ -89,33 +136,62 @@ 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(); }); // 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); } }); + + // Listen for Android Auto connection events from native side + _aaChannel.setMethodCallHandler((call) async { + switch (call.method) { + case 'onAndroidAutoConnected': + _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)'); + _isAndroidAutoConnected = false; + _suppressResume = true; + aaDisconnectedAt = DateTime.now(); + onAADisconnected?.call(); + _refreshPlaybackState(); + } + }); } /// 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: [ 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 { @@ -124,13 +200,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: 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: { ProcessingState.idle: AudioProcessingState.idle, ProcessingState.loading: AudioProcessingState.loading, @@ -149,8 +224,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(); } @@ -158,8 +235,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(); } @@ -196,6 +273,111 @@ 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'); + // 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'; + _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', + }; + case 'switchPlayer': + _logger.log('AndroidAuto: switchPlayer'); + onSwitchPlayer?.call(); + return; + } + // 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 (AA) + void _refreshPlaybackState() { + final current = playbackState.value; + playbackState.add(current.copyWith( + controls: _controls, + systemActions: const { + MediaAction.play, + MediaAction.pause, + MediaAction.skipToNext, + MediaAction.skipToPrevious, + }, + )); + } + // --- Custom methods for Ensemble --- /// Play a URL with the given metadata @@ -238,28 +420,45 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler required bool playing, Duration position = Duration.zero, Duration? duration, - }) { + }) async { + _clearErrorState(); _isRemoteMode = true; - _currentMediaItem = item; - mediaItem.add(item); + // 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); + } + // 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: [ - MediaControl.skipToPrevious, - if (playing) MediaControl.pause else MediaControl.play, - MediaControl.skipToNext, - _switchPlayerControl, - ], + controls: _controls, systemActions: const { MediaAction.play, MediaAction.pause, MediaAction.skipToNext, MediaAction.skipToPrevious, - 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], + androidCompactActionIndices: const [0, 1, 2], processingState: AudioProcessingState.ready, playing: playing, updatePosition: position, @@ -269,7 +468,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 +477,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) @@ -292,17 +495,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; @@ -351,6 +585,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'; @@ -373,10 +612,75 @@ 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 _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 + 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', + ); + } + + /// 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(), + if (!_isAndroidAutoConnected) _switchPlayerControl, + _favoriteControl(), + _radioControl, + _repeatControl(), + ]; + + // 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'); @@ -392,6 +696,35 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler provider.checkAndReconnect(); } + // 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); + // 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(); + 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'); @@ -423,7 +756,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: @@ -492,6 +831,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 { @@ -501,6 +848,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'); @@ -519,8 +873,45 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler } _logger.log('AndroidAuto: playFromMediaId $mediaId'); + _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]; + if (trackList == null || trackList.isEmpty) { + _logger.log('AndroidAuto: Start Radio: 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: 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); + _populateQueue(provider, shuffled, 0); + } + return; + } + if (mediaId.startsWith('track|')) { // Format: track|{tProvider}|{tItemId}|{ctxType}|{ctxProvider}|{ctxId} final parts = mediaId.split('|'); @@ -542,11 +933,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; } @@ -590,7 +983,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(); @@ -598,6 +992,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]}'; @@ -610,33 +1015,192 @@ 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) { _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 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)); + } + + void updateQueueIndex(int index) { + if (playbackState.value.queueIndex == index) return; + 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; + _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'); + } + }); + } + + 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() { @@ -745,6 +1309,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': @@ -753,6 +1318,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': @@ -777,7 +1343,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': @@ -837,17 +1403,53 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler // --- Music builders --- - Future> _autoBuildFavorites( - MusicAssistantProvider provider) async { - final tracks = await provider.getFavoriteTracks(); - _logger.log('AndroidAuto: Favorites returned ${tracks.length} tracks'); - const ctxKey = 'favs||'; - _autoTrackCache[ctxKey] = tracks; - return tracks.map((t) => _autoTrackItem(provider, t, ctxKey)).toList(); + 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), + ]; } - List _autoBuildPlaylistList(MusicAssistantProvider provider) { - final playlists = SyncService.instance.cachedPlaylists; + 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: Fav tracks returned ${tracks.length} tracks'); + const ctxKey = 'favs||'; + _cacheTrackList(ctxKey, tracks); + final items = tracks.map((t) => _autoTrackItem(provider, t, ctxKey)).toList(); + if (items.isNotEmpty) items.insert(0, _autoStartRadioItem(ctxKey)); + return items; + } + + 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}', @@ -863,19 +1465,29 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler final tracks = await provider.getPlaylistTracksWithCache(plProvider, plItemId); final ctxKey = 'plist|$plProvider|$plItemId'; - _autoTrackCache[ctxKey] = tracks; - return tracks.map((t) => _autoTrackItem(provider, t, ctxKey)).toList(); + _cacheTrackList(ctxKey, tracks); + final items = tracks.map((t) => _autoTrackItem(provider, t, ctxKey)).toList(); + if (items.isNotEmpty) items.insert(0, _autoStartRadioItem(ctxKey)); + 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}', - 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( @@ -886,17 +1498,30 @@ 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, + )), + ]; } - 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}', @@ -913,8 +1538,10 @@ 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; - return tracks.map((t) => _autoTrackItem(provider, t, ctxKey)).toList(); + _cacheTrackList(ctxKey, tracks); + final items = tracks.map((t) => _autoTrackItem(provider, t, ctxKey)).toList(); + if (items.isNotEmpty) items.insert(0, _autoStartRadioItem(ctxKey)); + return items; } // --- Audiobook builders --- @@ -950,6 +1577,7 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler artist: b.authorsString, artUri: _autoArtUri(provider, b), playable: true, + extras: _audiobookExtras(b), )).toList(); } @@ -970,6 +1598,7 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler artist: b.authorsString, artUri: _autoArtUri(provider, b), playable: true, + extras: _audiobookExtras(b), )).toList(); } @@ -1010,6 +1639,7 @@ class MassivAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler artist: b.authorsString, artUri: _autoArtUri(provider, b), playable: true, + extras: _audiobookExtras(b), )).toList(); } @@ -1032,9 +1662,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, @@ -1061,8 +1697,26 @@ 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 _autoStartRadioItem(String ctxKey) { + return MediaItem( + id: 'smartshuffle|$ctxKey', + title: 'Start Radio', + artUri: _iconStartRadio, + playable: true, + ); + } + 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, @@ -1071,12 +1725,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'); } // --------------------------------------------------------------------------- @@ -1087,6 +1752,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/music_assistant_api.dart b/lib/services/music_assistant_api.dart index 08c30a5..18c08b3 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, @@ -251,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')) { @@ -1981,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?; @@ -2484,7 +2490,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 +3202,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 +3309,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/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; 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! 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; 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; 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;