From a5b3138a12e1b26eeee62765d71fac468f6f007e Mon Sep 17 00:00:00 2001 From: Marco D'Alessandro Date: Wed, 15 Jul 2026 09:17:52 +0200 Subject: [PATCH] refactor: integrate audio_service for Android Auto functionality --- android/app/src/main/AndroidManifest.xml | 17 +- .../com/musly/musly/AndroidAutoPlugin.kt | 232 ----- .../com/musly/musly/AndroidSystemPlugin.kt | 35 +- .../com/musly/musly/BluetoothMediaHelper.kt | 4 - .../kotlin/com/musly/musly/LyricsPlugin.kt | 32 +- .../kotlin/com/musly/musly/MainActivity.kt | 10 +- .../kotlin/com/musly/musly/MusicService.kt | 953 ------------------ lib/main.dart | 36 +- lib/providers/auth_provider.dart | 3 - lib/providers/library_provider.dart | 223 ++-- lib/providers/player_provider.dart | 79 +- lib/services/android_auto_service.dart | 364 ------- lib/services/android_system_service.dart | 30 - lib/services/audio_handler.dart | 364 ++++++- lib/services/services.dart | 1 - packages/flutter_chrome_cast/pubspec.lock | 8 +- pubspec.lock | 14 +- pubspec.yaml | 1 + 18 files changed, 551 insertions(+), 1855 deletions(-) delete mode 100644 android/app/src/main/kotlin/com/musly/musly/AndroidAutoPlugin.kt delete mode 100644 android/app/src/main/kotlin/com/musly/musly/MusicService.kt delete mode 100644 lib/services/android_auto_service.dart diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 867faa56..c93f3587 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -81,24 +81,21 @@ - + - - - + + diff --git a/android/app/src/main/kotlin/com/musly/musly/AndroidAutoPlugin.kt b/android/app/src/main/kotlin/com/musly/musly/AndroidAutoPlugin.kt deleted file mode 100644 index eb22766f..00000000 --- a/android/app/src/main/kotlin/com/musly/musly/AndroidAutoPlugin.kt +++ /dev/null @@ -1,232 +0,0 @@ -package com.devid.musly - -import android.content.Context -import android.content.Intent -import android.os.Handler -import android.os.Looper -import android.util.Log -import androidx.core.content.ContextCompat -import io.flutter.embedding.engine.plugins.FlutterPlugin -import io.flutter.plugin.common.EventChannel -import io.flutter.plugin.common.MethodCall -import io.flutter.plugin.common.MethodChannel - -object AndroidAutoPlugin : FlutterPlugin, MethodChannel.MethodCallHandler { - - private const val TAG = "AndroidAutoPlugin" - private const val METHOD_CHANNEL = "com.devid.musly/android_auto" - private const val EVENT_CHANNEL = "com.devid.musly/android_auto_events" - - private var methodChannel: MethodChannel? = null - private var eventChannel: EventChannel? = null - private var eventSink: EventChannel.EventSink? = null - private var context: Context? = null - private val mainHandler = Handler(Looper.getMainLooper()) - - // Buffers for library data sent before MusicService finishes starting. - private var pendingRecentSongs: List>? = null - private var pendingAlbums: List>? = null - private var pendingArtists: List>? = null - private var pendingPlaylists: List>? = null - - override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { - context = binding.applicationContext - - methodChannel = MethodChannel(binding.binaryMessenger, METHOD_CHANNEL) - methodChannel?.setMethodCallHandler(this) - - eventChannel = EventChannel(binding.binaryMessenger, EVENT_CHANNEL) - eventChannel?.setStreamHandler(object : EventChannel.StreamHandler { - override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { - eventSink = events - } - - override fun onCancel(arguments: Any?) { - eventSink = null - } - }) - - Log.d(TAG, "AndroidAutoPlugin attached (service will start on first playback)") - } - - override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { - methodChannel?.setMethodCallHandler(null) - methodChannel = null - eventChannel?.setStreamHandler(null) - eventChannel = null - context = null - } - - override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { - when (call.method) { - "startService" -> { - startMusicService() - result.success(null) - } - "stopService" -> { - stopMusicService() - result.success(null) - } - "updatePlaybackState" -> { - val songId = call.argument("songId") - val title = call.argument("title") ?: "" - val artist = call.argument("artist") ?: "" - val album = call.argument("album") ?: "" - val artworkUrl = call.argument("artworkUrl") - val duration = call.argument("duration")?.toLong() ?: 0L - val position = call.argument("position")?.toLong() ?: 0L - val playing = call.argument("playing") ?: false - - // Ensure the service is running before updating state - val pushState = { - MusicService.getInstance()?.updatePlaybackState( - songId, title, artist, album, artworkUrl, duration, position, playing - ) - } - if (MusicService.getInstance() == null) { - Log.d(TAG, "MusicService not running, starting it now") - startMusicService() - // Service start is async; retry after a short delay - mainHandler.postDelayed({ pushState() }, 200) - } else { - pushState() - } - result.success(null) - } - "updateRecentSongs" -> { - val songs = call.argument>>("songs") ?: emptyList() - val svc = MusicService.getInstance() - if (svc == null) { - Log.d(TAG, "MusicService not ready, buffering ${songs.size} recent songs") - pendingRecentSongs = songs - startMusicService() - mainHandler.postDelayed({ flushPendingLibraryData() }, 800) - } else { - svc.updateRecentSongs(songs) - } - result.success(null) - } - "updateAlbums" -> { - val albums = call.argument>>("albums") ?: emptyList() - val svc = MusicService.getInstance() - if (svc == null) { - Log.d(TAG, "MusicService not ready, buffering ${albums.size} albums") - pendingAlbums = albums - startMusicService() - mainHandler.postDelayed({ flushPendingLibraryData() }, 800) - } else { - svc.updateAlbums(albums) - } - result.success(null) - } - "updateArtists" -> { - val artists = call.argument>>("artists") ?: emptyList() - val svc = MusicService.getInstance() - if (svc == null) { - Log.d(TAG, "MusicService not ready, buffering ${artists.size} artists") - pendingArtists = artists - startMusicService() - mainHandler.postDelayed({ flushPendingLibraryData() }, 800) - } else { - svc.updateArtists(artists) - } - result.success(null) - } - "updatePlaylists" -> { - val playlists = call.argument>>("playlists") ?: emptyList() - val svc = MusicService.getInstance() - if (svc == null) { - Log.d(TAG, "MusicService not ready, buffering ${playlists.size} playlists") - pendingPlaylists = playlists - startMusicService() - mainHandler.postDelayed({ flushPendingLibraryData() }, 800) - } else { - svc.updatePlaylists(playlists) - } - result.success(null) - } - "updateAlbumSongs" -> { - val albumId = call.argument("albumId") ?: "" - val songs = call.argument>>("songs") ?: emptyList() - MusicService.getInstance()?.updateAlbumSongs(albumId, songs) - result.success(null) - } - "updateArtistAlbums" -> { - val artistId = call.argument("artistId") ?: "" - val albums = call.argument>>("albums") ?: emptyList() - MusicService.getInstance()?.updateArtistAlbums(artistId, albums) - result.success(null) - } - "updatePlaylistSongs" -> { - val playlistId = call.argument("playlistId") ?: "" - val songs = call.argument>>("songs") ?: emptyList() - MusicService.getInstance()?.updatePlaylistSongs(playlistId, songs) - result.success(null) - } - "updateSearchResults" -> { - val query = call.argument("query") ?: "" - val songs = call.argument>>("songs") ?: emptyList() - MusicService.getInstance()?.updateSearchResults(query, songs) - result.success(null) - } - else -> result.notImplemented() - } - } - - /** Called by MusicService once it is fully ready, or from postDelayed retries. */ - fun flushPendingLibraryData() { - val svc = MusicService.getInstance() ?: return - pendingRecentSongs?.let { - Log.d(TAG, "Flushing ${it.size} buffered recent songs to MusicService") - svc.updateRecentSongs(it) - pendingRecentSongs = null - } - pendingAlbums?.let { - Log.d(TAG, "Flushing ${it.size} buffered albums to MusicService") - svc.updateAlbums(it) - pendingAlbums = null - } - pendingArtists?.let { - Log.d(TAG, "Flushing ${it.size} buffered artists to MusicService") - svc.updateArtists(it) - pendingArtists = null - } - pendingPlaylists?.let { - Log.d(TAG, "Flushing ${it.size} buffered playlists to MusicService") - svc.updatePlaylists(it) - pendingPlaylists = null - } - } - - fun startMusicService() { - context?.let { ctx -> - try { - val intent = Intent(ctx, MusicService::class.java) - ContextCompat.startForegroundService(ctx, intent) - Log.d(TAG, "startForegroundService called successfully") - } catch (e: Exception) { - Log.e(TAG, "Failed to start MusicService: ${e.message}", e) - } - } ?: Log.w(TAG, "Cannot start MusicService: context is null") - } - - private fun stopMusicService() { - context?.let { ctx -> - val intent = Intent(ctx, MusicService::class.java) - ctx.stopService(intent) - } - } - - fun sendCommand(command: String, arguments: Map?) { - val data = mutableMapOf("command" to command) - arguments?.let { data.putAll(it) } - - eventSink?.success(data) - } - - /** Request library data from Flutter. Called when MusicService is ready but has no data yet. */ - fun requestLibraryData() { - Log.d(TAG, "Requesting library data from Flutter") - sendCommand("requestLibraryData", null) - } -} diff --git a/android/app/src/main/kotlin/com/musly/musly/AndroidSystemPlugin.kt b/android/app/src/main/kotlin/com/musly/musly/AndroidSystemPlugin.kt index e82a9863..6c310421 100644 --- a/android/app/src/main/kotlin/com/musly/musly/AndroidSystemPlugin.kt +++ b/android/app/src/main/kotlin/com/musly/musly/AndroidSystemPlugin.kt @@ -115,28 +115,8 @@ object AndroidSystemPlugin : FlutterPlugin, MethodChannel.MethodCallHandler { result.success(null) } "updatePlaybackState" -> { - val songId = call.argument("songId") - val title = call.argument("title") ?: "" - val artist = call.argument("artist") ?: "" - val album = call.argument("album") ?: "" - val artworkUrl = call.argument("artworkUrl") - val duration = call.argument("duration")?.toLong() ?: 0L - val position = call.argument("position")?.toLong() ?: 0L - val playing = call.argument("playing") ?: false - - // Ensure the service is running before updating state - val pushState = { - MusicService.getInstance()?.updatePlaybackState( - songId, title, artist, album, artworkUrl, duration, position, playing - ) - } - if (MusicService.getInstance() == null) { - Log.d(TAG, "MusicService not running, requesting start via AndroidAutoPlugin") - AndroidAutoPlugin.startMusicService() - handler.postDelayed({ pushState() }, 200) - } else { - pushState() - } + // Media session metadata/state is now handled by audio_service + // (MuslyAudioHandler); nothing to do on this channel anymore. result.success(null) } "setNotificationColor" -> { @@ -167,17 +147,6 @@ object AndroidSystemPlugin : FlutterPlugin, MethodChannel.MethodCallHandler { "getAndroidSdkVersion" -> { result.success(Build.VERSION.SDK_INT) } - "setRemotePlayback" -> { - val isRemote = call.argument("isRemote") ?: false - val volume = call.argument("volume") ?: 50 - MusicService.getInstance()?.setRemoteVolume(isRemote, volume) - result.success(null) - } - "updateRemoteVolume" -> { - val volume = call.argument("volume") ?: 50 - MusicService.getInstance()?.updateRemoteVolume(volume) - result.success(null) - } "dispose" -> { dispose() result.success(null) diff --git a/android/app/src/main/kotlin/com/musly/musly/BluetoothMediaHelper.kt b/android/app/src/main/kotlin/com/musly/musly/BluetoothMediaHelper.kt index 8c7286b4..e1b970dd 100644 --- a/android/app/src/main/kotlin/com/musly/musly/BluetoothMediaHelper.kt +++ b/android/app/src/main/kotlin/com/musly/musly/BluetoothMediaHelper.kt @@ -255,10 +255,6 @@ class BluetoothMediaHelper(private val context: Context) { currentPosition = position isPlaying = playing - MusicService.getInstance()?.apply { - updatePlaybackState(songId, title, artist, album, artworkUrl, duration, position, playing) - } - artworkUrl?.let { url -> loadArtworkAsync(url) } diff --git a/android/app/src/main/kotlin/com/musly/musly/LyricsPlugin.kt b/android/app/src/main/kotlin/com/musly/musly/LyricsPlugin.kt index 3197b684..a4f54eea 100644 --- a/android/app/src/main/kotlin/com/musly/musly/LyricsPlugin.kt +++ b/android/app/src/main/kotlin/com/musly/musly/LyricsPlugin.kt @@ -65,48 +65,24 @@ class LyricsPlugin : MethodCallHandler, EventChannel.StreamHandler { result.success(mapOf("initialized" to true)) } - private var pendingLyricsLine: String? = null - private fun updateLyrics(line: String?) { if (line == null || line == currentLyricsLine) return - + currentLyricsLine = line - pendingLyricsLine = line - - // Update the media notification with the lyrics line as subtitle - val service = MusicService.getInstance() - if (service != null) { - service.updateLyrics(line) - pendingLyricsLine = null - Log.d(TAG, "Updated lyrics via MusicService: $line") - } else { - Log.d(TAG, "MusicService not ready, buffering lyrics: $line") - // Try again in 500ms - android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({ - pendingLyricsLine?.let { pending -> - MusicService.getInstance()?.updateLyrics(pending) - if (MusicService.getInstance() != null) { - Log.d(TAG, "Delayed lyrics update succeeded: $pending") - pendingLyricsLine = null - } - } - }, 500) - } - + // Notify Flutter listeners if any eventSink?.success(mapOf( "event" to "lyricsUpdated", "currentLine" to line, "timestamp" to System.currentTimeMillis() )) - + Log.d(TAG, "Updated lyrics: $line") } - + private fun clearLyrics() { currentLyricsLine = null hasLyrics = false - MusicService.getInstance()?.clearLyrics() Log.d(TAG, "Cleared lyrics") } diff --git a/android/app/src/main/kotlin/com/musly/musly/MainActivity.kt b/android/app/src/main/kotlin/com/musly/musly/MainActivity.kt index b41065a6..3d724ca9 100644 --- a/android/app/src/main/kotlin/com/musly/musly/MainActivity.kt +++ b/android/app/src/main/kotlin/com/musly/musly/MainActivity.kt @@ -1,14 +1,14 @@ package com.devid.musly -import io.flutter.embedding.android.FlutterFragmentActivity +import com.ryanheise.audioservice.AudioServiceFragmentActivity import io.flutter.embedding.engine.FlutterEngine -class MainActivity : FlutterFragmentActivity() { +// Extends AudioServiceFragmentActivity so the activity shares its Flutter +// engine with the audio_service MediaBrowserService (Android Auto). +class MainActivity : AudioServiceFragmentActivity() { override fun configureFlutterEngine(flutterEngine: FlutterEngine) { super.configureFlutterEngine(flutterEngine) - - flutterEngine.plugins.add(AndroidAutoPlugin) - + flutterEngine.plugins.add(AndroidSystemPlugin) flutterEngine.plugins.add(BluetoothAvrcpPlugin) diff --git a/android/app/src/main/kotlin/com/musly/musly/MusicService.kt b/android/app/src/main/kotlin/com/musly/musly/MusicService.kt deleted file mode 100644 index 3ee6283b..00000000 --- a/android/app/src/main/kotlin/com/musly/musly/MusicService.kt +++ /dev/null @@ -1,953 +0,0 @@ -package com.devid.musly - -import android.app.Notification -import android.app.NotificationChannel -import android.app.NotificationManager -import android.app.PendingIntent -import android.content.Context -import android.content.Intent -import android.graphics.Bitmap -import android.graphics.BitmapFactory -import android.graphics.drawable.AnimatedVectorDrawable -import android.graphics.drawable.TransitionDrawable -import android.os.Build -import android.os.Bundle -import android.support.v4.media.MediaBrowserCompat -import android.support.v4.media.MediaDescriptionCompat -import android.support.v4.media.MediaMetadataCompat -import android.support.v4.media.session.MediaSessionCompat -import android.support.v4.media.session.PlaybackStateCompat -import androidx.core.app.NotificationCompat -import androidx.media.MediaBrowserServiceCompat -import androidx.media.session.MediaButtonReceiver -import android.media.AudioManager -import android.util.Log -import androidx.media.VolumeProviderCompat -import kotlinx.coroutines.* -import java.net.URL -import kotlin.math.roundToInt - -class MusicService : MediaBrowserServiceCompat() { - - companion object { - private const val TAG = "MusicService" - private const val CHANNEL_ID = "musly_music_channel" - private const val NOTIFICATION_ID = 1 - private const val MY_MEDIA_ROOT_ID = "media_root_id" - private const val MY_EMPTY_MEDIA_ROOT_ID = "empty_root_id" - - const val MEDIA_ID_ROOT = "ROOT" - const val MEDIA_ID_RECENT = "RECENT" - const val MEDIA_ID_ALBUMS = "ALBUMS" - const val MEDIA_ID_ARTISTS = "ARTISTS" - const val MEDIA_ID_PLAYLISTS = "PLAYLISTS" - const val MEDIA_ID_SEARCH = "SEARCH" - const val MEDIA_ID_SONGS = "SONGS" - - @Volatile - private var instance: MusicService? = null - - fun getInstance(): MusicService? = instance - } - - private lateinit var mediaSession: MediaSessionCompat - private lateinit var stateBuilder: PlaybackStateCompat.Builder - private val serviceScope = CoroutineScope(Dispatchers.Main + SupervisorJob()) - - private var currentSongId: String? = null - private var currentTitle: String = "" - private var currentArtist: String = "" - private var currentAlbum: String = "" - private var currentArtworkUrl: String? = null - private var currentArtworkBitmap: Bitmap? = null - private var currentDuration: Long = 0 - private var currentPosition: Long = 0 - private var isPlaying: Boolean = false - private var volumeProvider: VolumeProviderCompat? = null - private var upnpExpectedVolume = 0 - - private var currentLyricsLine: String? = null - private var hasLyrics: Boolean = false - - private var isLoadingArtwork: Boolean = false - private var lastLoadedArtworkUrl: String? = null - - private val mediaItems = mutableListOf() - private val recentSongs = mutableListOf() - private val albums = mutableListOf() - private val artists = mutableListOf() - private val playlists = mutableListOf() - - private val albumSongsCache = mutableMapOf>() - private val artistAlbumsCache = mutableMapOf>() - private val playlistSongsCache = mutableMapOf>() - - private val pendingAlbumResults = mutableMapOf>>() - private val pendingArtistResults = mutableMapOf>>() - private val pendingPlaylistResults = mutableMapOf>>() - private val pendingSearchResults = mutableMapOf>>() - - override fun onCreate() { - super.onCreate() - instance = this - Log.d(TAG, "MusicService onCreate") - - createNotificationChannel() - initializeMediaSession() - - showIdleNotification() - - AndroidAutoPlugin.flushPendingLibraryData() - - AndroidAutoPlugin.requestLibraryData() - } - - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - Log.d(TAG, "MusicService onStartCommand action=${intent?.action}") - MediaButtonReceiver.handleIntent(mediaSession, intent) - return START_NOT_STICKY - } - - private fun showIdleNotification() { - val builder = NotificationCompat.Builder(this, CHANNEL_ID).apply { - setContentTitle("Musly") - setContentText("Ready to play your music") - setSmallIcon(R.mipmap.ic_launcher) - setVisibility(NotificationCompat.VISIBILITY_PUBLIC) - setColor(0xFF1DB954.toInt()) - setColorized(true) - priority = NotificationCompat.PRIORITY_LOW - - val intent = packageManager.getLaunchIntentForPackage(packageName) - val pendingIntent = PendingIntent.getActivity( - this@MusicService, 0, intent, - PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT - ) - setContentIntent(pendingIntent) - - setStyle( - androidx.media.app.NotificationCompat.MediaStyle() - .setMediaSession(mediaSession.sessionToken) - .setShowActionsInCompactView() - ) - } - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - startForeground(NOTIFICATION_ID, builder.build(), android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK) - } else { - startForeground(NOTIFICATION_ID, builder.build()) - } - } - - private fun createNotificationChannel() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val channel = NotificationChannel( - CHANNEL_ID, - "Musly Music", - NotificationManager.IMPORTANCE_LOW - ).apply { - description = "Music playback controls" - setShowBadge(false) - } - - val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - notificationManager.createNotificationChannel(channel) - } - } - - private fun initializeMediaSession() { - val mediaButtonIntent = Intent(Intent.ACTION_MEDIA_BUTTON) - mediaButtonIntent.setClass(this, MediaButtonReceiver::class.java) - val pendingIntent = PendingIntent.getBroadcast( - this, 0, mediaButtonIntent, - PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT - ) - - mediaSession = MediaSessionCompat(this, "MuslyMusicService").apply { - setFlags( - MediaSessionCompat.FLAG_HANDLES_MEDIA_BUTTONS or - MediaSessionCompat.FLAG_HANDLES_TRANSPORT_CONTROLS - ) - - stateBuilder = PlaybackStateCompat.Builder() - .setActions( - PlaybackStateCompat.ACTION_PLAY or - PlaybackStateCompat.ACTION_PAUSE or - PlaybackStateCompat.ACTION_PLAY_PAUSE or - PlaybackStateCompat.ACTION_STOP or - PlaybackStateCompat.ACTION_SKIP_TO_NEXT or - PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS or - PlaybackStateCompat.ACTION_SEEK_TO or - PlaybackStateCompat.ACTION_PLAY_FROM_MEDIA_ID or - PlaybackStateCompat.ACTION_PLAY_FROM_SEARCH - ) - - setPlaybackState(stateBuilder.build()) - setCallback(MediaSessionCallback()) - setMediaButtonReceiver(pendingIntent) - isActive = true - } - - sessionToken = mediaSession.sessionToken - } - - override fun onGetRoot( - clientPackageName: String, - clientUid: Int, - rootHints: Bundle? - ): BrowserRoot { - val extras = Bundle().apply { - putBoolean("android.media.browse.SEARCH_SUPPORTED", true) - } - return BrowserRoot(MEDIA_ID_ROOT, extras) - } - - override fun onSearch( - query: String, - extras: Bundle?, - result: Result> - ) { - result.detach() - pendingSearchResults[query] = result - AndroidAutoPlugin.sendCommand("search", mapOf("query" to query)) - serviceScope.launch { - delay(10000) - pendingSearchResults.remove(query)?.sendResult(mutableListOf()) - } - } - - fun updateSearchResults(query: String, songs: List>) { - val items = songs.map { song -> - createPlayableMediaItem( - song["id"] as? String ?: "", - song["title"] as? String ?: "", - song["artist"] as? String ?: "", - song["album"] as? String ?: "", - song["artworkUrl"] as? String - ) - }.toMutableList() - pendingSearchResults.remove(query)?.sendResult(items) - } - - override fun onLoadChildren( - parentId: String, - result: Result> - ) { - result.detach() - - serviceScope.launch { - val items = when (parentId) { - MEDIA_ID_ROOT -> getRootItems() - MEDIA_ID_RECENT -> recentSongs - MEDIA_ID_ALBUMS -> albums - MEDIA_ID_ARTISTS -> artists - MEDIA_ID_PLAYLISTS -> playlists - else -> { - if (parentId.startsWith("album_")) { - val albumId = parentId.removePrefix("album_") - getAlbumSongsDynamic(albumId, result) - return@launch - } else if (parentId.startsWith("artist_")) { - val artistId = parentId.removePrefix("artist_") - getArtistAlbumsDynamic(artistId, result) - return@launch - } else if (parentId.startsWith("playlist_")) { - val playlistId = parentId.removePrefix("playlist_") - getPlaylistSongsDynamic(playlistId, result) - return@launch - } else { - mutableListOf() - } - } - } - result.sendResult(items.toMutableList()) - } - } - - private fun getRootItems(): List { - return listOf( - createBrowsableMediaItem( - MEDIA_ID_RECENT, - "Recent", - "Recently played songs", - R.drawable.ic_recent - ), - createBrowsableMediaItem( - MEDIA_ID_ALBUMS, - "Albums", - "Browse your music collection", - R.drawable.ic_albums - ), - createBrowsableMediaItem( - MEDIA_ID_ARTISTS, - "Artists", - "Find music by artist", - R.drawable.ic_artists - ), - createBrowsableMediaItem( - MEDIA_ID_PLAYLISTS, - "Playlists", - "Your curated playlists", - R.drawable.ic_playlists - ) - ) - } - - private fun createBrowsableMediaItem( - mediaId: String, - title: String, - subtitle: String, - iconResId: Int = 0 - ): MediaBrowserCompat.MediaItem { - val descriptionBuilder = MediaDescriptionCompat.Builder() - .setMediaId(mediaId) - .setTitle(title) - .setSubtitle(subtitle) - - if (iconResId != 0) { - descriptionBuilder.setIconUri( - android.net.Uri.parse("android.resource://${packageName}/$iconResId") - ) - } - - return MediaBrowserCompat.MediaItem( - descriptionBuilder.build(), - MediaBrowserCompat.MediaItem.FLAG_BROWSABLE - ) - } - - private fun createBrowsableMediaItemWithArt( - mediaId: String, - title: String, - subtitle: String, - artworkUrl: String? - ): MediaBrowserCompat.MediaItem { - val descriptionBuilder = MediaDescriptionCompat.Builder() - .setMediaId(mediaId) - .setTitle(title) - .setSubtitle(subtitle) - - if (artworkUrl?.isNotEmpty() == true) { - descriptionBuilder.setIconUri(android.net.Uri.parse(artworkUrl)) - } else { - descriptionBuilder.setIconUri( - android.net.Uri.parse("android.resource://${packageName}/${R.drawable.ic_album_placeholder}") - ) - } - - return MediaBrowserCompat.MediaItem( - descriptionBuilder.build(), - MediaBrowserCompat.MediaItem.FLAG_BROWSABLE - ) - } - - private fun createPlayableMediaItem( - mediaId: String, - title: String, - artist: String, - album: String, - artworkUrl: String? - ): MediaBrowserCompat.MediaItem { - val descriptionBuilder = MediaDescriptionCompat.Builder() - .setMediaId(mediaId) - .setTitle(title) - .setSubtitle(artist) - .setDescription(album) - - if (artworkUrl?.isNotEmpty() == true) { - descriptionBuilder.setIconUri(android.net.Uri.parse(artworkUrl)) - } else { - descriptionBuilder.setIconUri( - android.net.Uri.parse("android.resource://${packageName}/${R.drawable.ic_album_placeholder}") - ) - } - - return MediaBrowserCompat.MediaItem( - descriptionBuilder.build(), - MediaBrowserCompat.MediaItem.FLAG_PLAYABLE - ) - } - - private suspend fun getAlbumSongs(albumId: String): List { - return albumSongsCache[albumId] ?: emptyList() - } - - private fun getAlbumSongsDynamic(albumId: String, result: Result>) { - albumSongsCache[albumId]?.let { - result.sendResult(it) - return - } - - pendingAlbumResults[albumId] = result - AndroidAutoPlugin.sendCommand("getAlbumSongs", mapOf("albumId" to albumId)) - - serviceScope.launch { - delay(10000) - pendingAlbumResults.remove(albumId)?.sendResult(mutableListOf()) - } - } - - private suspend fun getArtistAlbums(artistId: String): List { - return artistAlbumsCache[artistId] ?: emptyList() - } - - private fun getArtistAlbumsDynamic(artistId: String, result: Result>) { - artistAlbumsCache[artistId]?.let { - result.sendResult(it) - return - } - - pendingArtistResults[artistId] = result - AndroidAutoPlugin.sendCommand("getArtistAlbums", mapOf("artistId" to artistId)) - - serviceScope.launch { - delay(10000) - pendingArtistResults.remove(artistId)?.sendResult(mutableListOf()) - } - } - - private suspend fun getPlaylistSongs(playlistId: String): List { - return playlistSongsCache[playlistId] ?: emptyList() - } - - private fun getPlaylistSongsDynamic(playlistId: String, result: Result>) { - playlistSongsCache[playlistId]?.let { - result.sendResult(it) - return - } - - pendingPlaylistResults[playlistId] = result - AndroidAutoPlugin.sendCommand("getPlaylistSongs", mapOf("playlistId" to playlistId)) - - serviceScope.launch { - delay(10000) - pendingPlaylistResults.remove(playlistId)?.sendResult(mutableListOf()) - } - } - - fun updateAlbumSongs(albumId: String, songs: List>) { - val items = songs.map { song -> - createPlayableMediaItem( - song["id"] as? String ?: "", - song["title"] as? String ?: "", - song["artist"] as? String ?: "", - song["album"] as? String ?: "", - song["artworkUrl"] as? String - ) - }.toMutableList() - - albumSongsCache[albumId] = items - pendingAlbumResults.remove(albumId)?.sendResult(items) - } - - fun updateArtistAlbums(artistId: String, albumList: List>) { - val items = albumList.map { album -> - createBrowsableMediaItemWithArt( - "album_${album["id"]}", - album["name"] as? String ?: "", - album["artist"] as? String ?: "", - album["artworkUrl"] as? String - ) - }.toMutableList() - - artistAlbumsCache[artistId] = items - pendingArtistResults.remove(artistId)?.sendResult(items) - } - - fun updatePlaylistSongs(playlistId: String, songs: List>) { - val items = songs.map { song -> - createPlayableMediaItem( - song["id"] as? String ?: "", - song["title"] as? String ?: "", - song["artist"] as? String ?: "", - song["album"] as? String ?: "", - song["artworkUrl"] as? String - ) - }.toMutableList() - - playlistSongsCache[playlistId] = items - pendingPlaylistResults.remove(playlistId)?.sendResult(items) - } - - fun updateRecentSongs(songs: List>) { - recentSongs.clear() - songs.forEach { song -> - recentSongs.add(createPlayableMediaItem( - song["id"] as? String ?: "", - song["title"] as? String ?: "", - song["artist"] as? String ?: "", - song["album"] as? String ?: "", - song["artworkUrl"] as? String - )) - } - val extras = Bundle().apply { - putBoolean("android.media.browse.ANIMATED", true) - putString("android.media.browse.TRANSITION", "slide") - } - notifyChildrenChanged(MEDIA_ID_RECENT, extras) - } - - fun updateAlbums(albumList: List>) { - albums.clear() - albumList.forEach { album -> - albums.add(createBrowsableMediaItemWithArt( - "album_${album["id"]}", - album["name"] as? String ?: "", - album["artist"] as? String ?: "", - album["artworkUrl"] as? String - )) - } - val extras = Bundle().apply { - putBoolean("android.media.browse.ANIMATED", true) - putString("android.media.browse.TRANSITION", "fade") - } - notifyChildrenChanged(MEDIA_ID_ALBUMS, extras) - } - - fun updateArtists(artistList: List>) { - artists.clear() - artistList.forEach { artist -> - artists.add(createBrowsableMediaItem( - "artist_${artist["id"]}", - artist["name"] as? String ?: "", - "${artist["albumCount"] ?: 0} albums" - )) - } - val extras = Bundle().apply { - putBoolean("android.media.browse.ANIMATED", true) - putString("android.media.browse.TRANSITION", "slide") - } - notifyChildrenChanged(MEDIA_ID_ARTISTS, extras) - } - - fun updatePlaylists(playlistList: List>) { - playlists.clear() - playlistList.forEach { playlist -> - playlists.add(createBrowsableMediaItemWithArt( - "playlist_${playlist["id"]}", - playlist["name"] as? String ?: "", - "${playlist["songCount"] ?: 0} songs", - playlist["artworkUrl"] as? String - )) - } - val extras = Bundle().apply { - putBoolean("android.media.browse.ANIMATED", true) - putString("android.media.browse.TRANSITION", "fade") - } - notifyChildrenChanged(MEDIA_ID_PLAYLISTS, extras) - } - - fun updatePlaybackState( - songId: String?, - title: String, - artist: String, - album: String, - artworkUrl: String?, - duration: Long, - position: Long, - playing: Boolean - ) { - val songChanged = songId != currentSongId - val metadataChanged = songChanged - || title != currentTitle - || artist != currentArtist - || album != currentAlbum - || artworkUrl != currentArtworkUrl - || duration != currentDuration - - if (songChanged) { - lastLoadedArtworkUrl = null - } - - currentSongId = songId - currentTitle = title - currentArtist = artist - currentAlbum = album - currentArtworkUrl = artworkUrl - currentDuration = duration - currentPosition = position - isPlaying = playing - - if (metadataChanged) { - updateMediaSessionMetadata() - } - updateMediaSessionPlaybackState() - showNotification() - } - - private fun updateMediaSessionMetadata() { - Log.d(TAG, "updateMediaSessionMetadata: song=$currentTitle, artworkUrl=$currentArtworkUrl") - - val metadataBuilder = MediaMetadataCompat.Builder() - .putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, currentSongId) - .putString(MediaMetadataCompat.METADATA_KEY_TITLE, currentTitle) - .putString(MediaMetadataCompat.METADATA_KEY_ARTIST, currentArtist) - .putString(MediaMetadataCompat.METADATA_KEY_ALBUM, currentAlbum) - .putLong(MediaMetadataCompat.METADATA_KEY_DURATION, currentDuration) - .putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, currentTitle) - .putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI, currentArtworkUrl) - - if (hasLyrics && !currentLyricsLine.isNullOrEmpty()) { - metadataBuilder.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, currentLyricsLine) - } - - val url = currentArtworkUrl - - if (!url.isNullOrEmpty() && url == lastLoadedArtworkUrl && currentArtworkBitmap != null) { - Log.d(TAG, "updateMediaSessionMetadata: Artwork URL unchanged, reusing cached bitmap") - val cachedMetadata = MediaMetadataCompat.Builder() - .putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, currentSongId) - .putString(MediaMetadataCompat.METADATA_KEY_TITLE, currentTitle) - .putString(MediaMetadataCompat.METADATA_KEY_ARTIST, currentArtist) - .putString(MediaMetadataCompat.METADATA_KEY_ALBUM, currentAlbum) - .putLong(MediaMetadataCompat.METADATA_KEY_DURATION, currentDuration) - .putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, currentArtworkBitmap) - .putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, currentTitle) - .putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI, url) - .apply { - if (hasLyrics && !currentLyricsLine.isNullOrEmpty()) { - putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, currentLyricsLine) - } - } - .build() - mediaSession.setMetadata(cachedMetadata) - return - } - - if (url.isNullOrEmpty()) { - Log.w(TAG, "updateMediaSessionMetadata: No artwork URL provided") - if (currentArtworkBitmap != null) { - Log.d(TAG, "updateMediaSessionMetadata: Using cached bitmap") - val updatedMetadata = MediaMetadataCompat.Builder() - .putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, currentSongId) - .putString(MediaMetadataCompat.METADATA_KEY_TITLE, currentTitle) - .putString(MediaMetadataCompat.METADATA_KEY_ARTIST, currentArtist) - .putString(MediaMetadataCompat.METADATA_KEY_ALBUM, currentAlbum) - .putLong(MediaMetadataCompat.METADATA_KEY_DURATION, currentDuration) - .putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, currentArtworkBitmap) - .putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, currentTitle) - .apply { - if (hasLyrics && !currentLyricsLine.isNullOrEmpty()) { - putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, currentLyricsLine) - } - } - .build() - mediaSession.setMetadata(updatedMetadata) - } else { - Log.d(TAG, "updateMediaSessionMetadata: No cached bitmap, setting metadata without artwork") - mediaSession.setMetadata(metadataBuilder.build()) - } - return - } - - isLoadingArtwork = true - setBufferingState(true) - - serviceScope.launch(Dispatchers.IO) { - try { - Log.d(TAG, "updateMediaSessionMetadata: Loading artwork from URL: $url") - val bitmap = BitmapFactory.decodeStream(URL(url).openStream()) - - if (bitmap == null) { - Log.e(TAG, "updateMediaSessionMetadata: Failed to decode bitmap from URL: $url") - isLoadingArtwork = false - withContext(Dispatchers.Main) { - setBufferingState(false) - } - return@launch - } - - currentArtworkBitmap = bitmap - lastLoadedArtworkUrl = url - isLoadingArtwork = false - - Log.d(TAG, "updateMediaSessionMetadata: Artwork loaded successfully: ${bitmap.width}x${bitmap.height}") - - withContext(Dispatchers.Main) { - val updatedMetadata = MediaMetadataCompat.Builder() - .putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, currentSongId) - .putString(MediaMetadataCompat.METADATA_KEY_TITLE, currentTitle) - .putString(MediaMetadataCompat.METADATA_KEY_ARTIST, currentArtist) - .putString(MediaMetadataCompat.METADATA_KEY_ALBUM, currentAlbum) - .putLong(MediaMetadataCompat.METADATA_KEY_DURATION, currentDuration) - .putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, bitmap) - .putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, currentTitle) - .putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI, url) - .apply { - if (hasLyrics && !currentLyricsLine.isNullOrEmpty()) { - putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, currentLyricsLine) - } - } - .build() - - mediaSession.setMetadata(updatedMetadata) - setBufferingState(false) - showNotification() - - val artworkExtras = Bundle().apply { - putBoolean("android.media.metadata.ANIMATED", true) - putString("android.media.metadata.TRANSITION", "fade") - } - mediaSession.setExtras(artworkExtras) - } - } catch (e: Exception) { - Log.e(TAG, "updateMediaSessionMetadata: Error loading artwork: ${e.message}", e) - isLoadingArtwork = false - withContext(Dispatchers.Main) { - setBufferingState(false) - } - } - } - } - - private fun updateMediaSessionPlaybackState(isBuffering: Boolean = false) { - val state = when { - isBuffering -> PlaybackStateCompat.STATE_BUFFERING - isPlaying -> PlaybackStateCompat.STATE_PLAYING - else -> PlaybackStateCompat.STATE_PAUSED - } - - val position = if (isPlaying) { - currentPosition + 100 - } else { - currentPosition - } - - stateBuilder - .setState(state, position, if (isPlaying) 1.0f else 0.0f) - .setBufferedPosition(currentDuration) - .setActions( - PlaybackStateCompat.ACTION_PLAY or - PlaybackStateCompat.ACTION_PAUSE or - PlaybackStateCompat.ACTION_PLAY_PAUSE or - PlaybackStateCompat.ACTION_STOP or - PlaybackStateCompat.ACTION_SKIP_TO_NEXT or - PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS or - PlaybackStateCompat.ACTION_SEEK_TO or - PlaybackStateCompat.ACTION_PLAY_FROM_MEDIA_ID or - PlaybackStateCompat.ACTION_PLAY_FROM_SEARCH - ) - - val extras = Bundle().apply { - putBoolean("android.media.session.ANIMATED", true) - putLong("android.media.session.POSITION_UPDATE_TIME", System.currentTimeMillis()) - } - stateBuilder.setExtras(extras) - - mediaSession.setPlaybackState(stateBuilder.build()) - } - - fun setBufferingState(buffering: Boolean) { - updateMediaSessionPlaybackState(isBuffering = buffering) - } - - private fun showNotification() { - val controller = mediaSession.controller - val mediaMetadata = controller.metadata - val description = mediaMetadata?.description - - val subtitleText = if (hasLyrics && !currentLyricsLine.isNullOrEmpty()) { - currentLyricsLine - } else { - description?.subtitle ?: currentArtist - } - - val builder = NotificationCompat.Builder(this, CHANNEL_ID).apply { - setContentTitle(description?.title ?: currentTitle) - setContentText(subtitleText) - setSubText(description?.description ?: currentAlbum) - setSmallIcon(R.mipmap.ic_launcher) - setVisibility(NotificationCompat.VISIBILITY_PUBLIC) - setColor(0xFF1DB954.toInt()) - setColorized(true) - - description?.iconBitmap?.let { bitmap -> - setLargeIcon(bitmap) - } ?: currentArtworkBitmap?.let { bitmap -> - setLargeIcon(bitmap) - } - - val intent = packageManager.getLaunchIntentForPackage(packageName) - val pendingIntent = PendingIntent.getActivity( - this@MusicService, 0, intent, - PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT - ) - setContentIntent(pendingIntent) - - addAction( - NotificationCompat.Action( - R.drawable.ic_previous, - "Previous", - MediaButtonReceiver.buildMediaButtonPendingIntent( - this@MusicService, - PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS - ) - ) - ) - - if (isPlaying) { - addAction( - NotificationCompat.Action( - R.drawable.ic_pause, - "Pause", - MediaButtonReceiver.buildMediaButtonPendingIntent( - this@MusicService, - PlaybackStateCompat.ACTION_PAUSE - ) - ) - ) - } else { - addAction( - NotificationCompat.Action( - R.drawable.ic_play, - "Play", - MediaButtonReceiver.buildMediaButtonPendingIntent( - this@MusicService, - PlaybackStateCompat.ACTION_PLAY - ) - ) - ) - } - - // Next button with custom icon - addAction( - NotificationCompat.Action( - R.drawable.ic_next, - "Next", - MediaButtonReceiver.buildMediaButtonPendingIntent( - this@MusicService, - PlaybackStateCompat.ACTION_SKIP_TO_NEXT - ) - ) - ) - - setStyle( - androidx.media.app.NotificationCompat.MediaStyle() - .setMediaSession(mediaSession.sessionToken) - .setShowActionsInCompactView(0, 1, 2) - .setShowCancelButton(true) - .setCancelButtonIntent( - MediaButtonReceiver.buildMediaButtonPendingIntent( - this@MusicService, - PlaybackStateCompat.ACTION_STOP - ) - ) - ) - } - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - startForeground(NOTIFICATION_ID, builder.build(), android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK) - } else { - startForeground(NOTIFICATION_ID, builder.build()) - } - } - - private inner class MediaSessionCallback : MediaSessionCompat.Callback() { - override fun onPlay() { - AndroidAutoPlugin.sendCommand("play", null) - } - - override fun onPause() { - AndroidAutoPlugin.sendCommand("pause", null) - } - - override fun onStop() { - AndroidAutoPlugin.sendCommand("stop", null) - } - - override fun onSkipToNext() { - AndroidAutoPlugin.sendCommand("skipNext", null) - } - - override fun onSkipToPrevious() { - AndroidAutoPlugin.sendCommand("skipPrevious", null) - } - - override fun onSeekTo(pos: Long) { - AndroidAutoPlugin.sendCommand("seekTo", mapOf("position" to pos)) - } - - override fun onPlayFromMediaId(mediaId: String?, extras: Bundle?) { - mediaId?.let { - AndroidAutoPlugin.sendCommand("playFromMediaId", mapOf("mediaId" to it)) - } - } - - override fun onPlayFromSearch(query: String?, extras: Bundle?) { - val q = query?.trim() ?: "" - AndroidAutoPlugin.sendCommand("playFromSearch", mapOf("query" to q)) - } - } - - fun setRemoteVolume(isRemote: Boolean, currentVolume: Int) { - if (isRemote) { - val initialProviderVolume = (currentVolume / 5.0).roundToInt().coerceIn(0, 20) - upnpExpectedVolume = initialProviderVolume - volumeProvider = object : VolumeProviderCompat( - VOLUME_CONTROL_ABSOLUTE, 20, initialProviderVolume - ) { - override fun onSetVolumeTo(volume: Int) { - upnpExpectedVolume = volume - setCurrentVolume(volume) - AndroidAutoPlugin.sendCommand("setVolume", mapOf("volume" to volume * 5)) - } - - override fun onAdjustVolume(direction: Int) { - if (direction == 0) return - upnpExpectedVolume = (upnpExpectedVolume + direction).coerceIn(0, 20) - setCurrentVolume(upnpExpectedVolume) - AndroidAutoPlugin.sendCommand("setVolume", mapOf("volume" to upnpExpectedVolume * 5)) - } - } - mediaSession.setPlaybackToRemote(volumeProvider!!) - Log.d(TAG, "MediaSession set to remote volume (current=$currentVolume)") - } else { - volumeProvider = null - mediaSession.setPlaybackToLocal(AudioManager.STREAM_MUSIC) - Log.d(TAG, "MediaSession set to local volume") - } - } - - fun updateRemoteVolume(volume: Int) { - val providerVolume = (volume / 5.0).roundToInt().coerceIn(0, 20) - upnpExpectedVolume = providerVolume - volumeProvider?.currentVolume = providerVolume - } - - fun updateLyrics(lyricsLine: String?) { - if (lyricsLine == null || lyricsLine == currentLyricsLine) return - - currentLyricsLine = lyricsLine - hasLyrics = true - - updateMediaSessionMetadata() - showNotification() - - Log.d(TAG, "Updated lyrics: $lyricsLine") - } - - fun clearLyrics() { - currentLyricsLine = null - hasLyrics = false - updateMediaSessionMetadata() - showNotification() - Log.d(TAG, "Cleared lyrics") - } - - override fun onDestroy() { - Log.d(TAG, "MusicService onDestroy") - instance = null - super.onDestroy() - serviceScope.cancel() - mediaSession.isActive = false - mediaSession.release() - } - - override fun onTaskRemoved(rootIntent: Intent?) { - Log.d(TAG, "MusicService onTaskRemoved") - super.onTaskRemoved(rootIntent) - mediaSession.isActive = false - stopForeground(true) - stopSelf() - } -} \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart index 17229a4c..c8539b10 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -218,6 +218,24 @@ void main() async { // Create TranscodingService instance to share across providers final transcodingService = TranscodingService(); + // Create these providers eagerly (not lazily via `create:`) so their + // Android Auto callbacks are registered on the audio handler as soon as the + // engine starts. This matters for the headless cold start: when Android + // Auto launches the app with no UI, no widget ever reads the providers, so + // lazy construction would leave the browse tree and search unwired. + final authProvider = AuthProvider(subsonicService, storageService); + final playerProvider = PlayerProvider( + subsonicService, + storageService, + castService, + upnpService, + audioHandler, + jukeboxService, + transcodingService, + ); + final libraryProvider = LibraryProvider(subsonicService, audioHandler); + playerProvider.setLibraryProvider(libraryProvider); + final Widget appWithProviders = MultiProvider( providers: [ Provider.value(value: storageService), @@ -229,9 +247,7 @@ void main() async { value: transcodingService, ), ChangeNotifierProvider.value(value: localMusicService), - ChangeNotifierProvider( - create: (_) => AuthProvider(subsonicService, storageService), - ), + ChangeNotifierProvider.value(value: authProvider), ChangeNotifierProvider.value(value: castService), ChangeNotifierProvider.value(value: localeService), ChangeNotifierProvider.value(value: themeService), @@ -240,18 +256,8 @@ void main() async { ), ChangeNotifierProvider.value(value: upnpService), ChangeNotifierProvider.value(value: jukeboxService), - ChangeNotifierProvider( - create: (_) => PlayerProvider( - subsonicService, - storageService, - castService, - upnpService, - audioHandler, - jukeboxService, - transcodingService, - ), - ), - ChangeNotifierProvider(create: (_) => LibraryProvider(subsonicService)), + ChangeNotifierProvider.value(value: playerProvider), + ChangeNotifierProvider.value(value: libraryProvider), ], child: const MuslyApp(), ); diff --git a/lib/providers/auth_provider.dart b/lib/providers/auth_provider.dart index 2e630a3a..580cbbb4 100644 --- a/lib/providers/auth_provider.dart +++ b/lib/providers/auth_provider.dart @@ -334,9 +334,6 @@ class AuthProvider extends ChangeNotifier { try { await offlineService.deleteAllDownloads(); } catch (_) {} - try { - await AndroidAutoService().dispose(); - } catch (_) {} try { await AndroidSystemService().dispose(); } catch (_) {} diff --git a/lib/providers/library_provider.dart b/lib/providers/library_provider.dart index 35d5eed8..c393be00 100644 --- a/lib/providers/library_provider.dart +++ b/lib/providers/library_provider.dart @@ -4,11 +4,12 @@ import 'package:flutter/foundation.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../models/models.dart'; import '../services/services.dart'; +import '../services/audio_handler.dart'; import '../services/local_music_service.dart'; class LibraryProvider extends ChangeNotifier { final SubsonicService _subsonicService; - final AndroidAutoService _androidAutoService = AndroidAutoService(); + final MuslyAudioHandler _audioHandler; bool _localOnlyMode = false; bool _serverOfflineMode = false; @@ -40,34 +41,17 @@ class LibraryProvider extends ChangeNotifier { static const String _artistsCacheKey = 'cached_artists'; static const String _lastUpdateKey = 'last_cache_update'; - LibraryProvider(this._subsonicService) { - // Register callback to push library data when Android Auto service requests it - _androidAutoService.onRequestLibraryData = _onRequestLibraryData; + LibraryProvider(this._subsonicService, this._audioHandler) { + // Serve the Android Auto browse tree: audio_service pulls these lists on + // demand, including from the headless engine when the car connects while + // the app UI has never been opened. + _audioHandler.onGetRecentSongs = _recentSongsForAuto; + _audioHandler.onGetLibraryAlbums = _albumsForAuto; + _audioHandler.onGetLibraryArtists = _artistsForAuto; + _audioHandler.onGetLibraryPlaylists = _playlistsForAuto; } SubsonicService get subsonicService => _subsonicService; - void _onRequestLibraryData() { - debugPrint('LibraryProvider: Android Auto requested library data'); - if (_isInitialized) { - if (_serverOfflineMode) { - _pushOfflineLibraryToAndroidAuto(); - } else { - _pushLibraryToAndroidAuto(); - } - } else { - // If not initialized yet, try to initialize and then push - if (!_isLoading) { - initialize().then((_) { - if (_serverOfflineMode) { - _pushOfflineLibraryToAndroidAuto(); - } else { - _pushLibraryToAndroidAuto(); - } - }); - } - } - } - void setLocalMusicService(LocalMusicService service, {bool mergeWithServer = false}) { _localMusicService?.removeListener(_onLocalMusicServiceChanged); @@ -236,19 +220,7 @@ class LibraryProvider extends ChangeNotifier { _playlists = _cachedPlaylists; } - if (_serverOfflineMode) { - await _pushOfflineLibraryToAndroidAuto(); - } else { - _pushLibraryToAndroidAuto(); - } - - Future.delayed(const Duration(milliseconds: 800), () { - if (_serverOfflineMode) { - _pushOfflineLibraryToAndroidAuto(); - } else { - _pushLibraryToAndroidAuto(); - } - }); + _audioHandler.notifyAutoChildrenChanged(); if (!_serverOfflineMode) { try { @@ -472,63 +444,122 @@ class LibraryProvider extends ChangeNotifier { } } - void _pushLibraryToAndroidAuto() { - if (_artists.isNotEmpty) { - _androidAutoService.updateArtists(_artists); - } - if (_recentAlbums.isNotEmpty) { - _androidAutoService.updateAlbums(_recentAlbums, getCoverArtUrl); - } - if (_playlists.isNotEmpty) { - _androidAutoService.updatePlaylists(_playlists, getCoverArtUrl); + /// Make sure the library is loaded before serving Android Auto browse + /// requests (the headless engine starts with an empty provider). + Future _ensureInitializedForAuto() async { + if (_isInitialized) return; + if (!_isLoading) { + try { + await initialize(); + } catch (e) { + debugPrint('LibraryProvider: initialize for Android Auto failed: $e'); + } + return; } - if (_randomSongs.isNotEmpty) { - _androidAutoService.updateRecentSongs(_randomSongs, getCoverArtUrl); + // Another initialize() is already in flight; wait for it (bounded). + for (var i = 0; i < 100 && _isLoading && !_isInitialized; i++) { + await Future.delayed(const Duration(milliseconds: 200)); } } - Future _pushOfflineLibraryToAndroidAuto() async { + Future> _downloadedSongIdsForAuto() async { final offlineService = OfflineService(); await offlineService.initialize(); - final downloadedIds = offlineService.getDownloadedSongIds().toSet(); - - if (downloadedIds.isEmpty) { - _pushLibraryToAndroidAuto(); - return; - } + return offlineService.getDownloadedSongIds().toSet(); + } - final offlineSongs = - _cachedAllSongs.where((s) => downloadedIds.contains(s.id)).toList(); - if (offlineSongs.isNotEmpty) { - _androidAutoService.updateRecentSongs(offlineSongs, getCoverArtUrl); - } else if (_randomSongs.isNotEmpty) { - _androidAutoService.updateRecentSongs(_randomSongs, getCoverArtUrl); + Future>> _recentSongsForAuto() async { + await _ensureInitializedForAuto(); + var songs = _randomSongs; + if (_serverOfflineMode) { + final downloadedIds = await _downloadedSongIdsForAuto(); + final offlineSongs = + _cachedAllSongs.where((s) => downloadedIds.contains(s.id)).toList(); + if (offlineSongs.isNotEmpty) songs = offlineSongs; } + return songs + .take(50) + .map( + (song) => { + 'id': song.id, + 'title': song.title, + 'artist': song.artist ?? '', + 'album': song.album ?? '', + 'artworkUrl': getCoverArtUrl(song.coverArt), + 'duration': song.duration ?? 0, + }, + ) + .toList(); + } - final albumIdsWithDownloads = - offlineSongs.map((s) => s.albumId).whereType().toSet(); - final offlineAlbums = _cachedAllAlbums - .where((a) => albumIdsWithDownloads.contains(a.id)) + Future>> _albumsForAuto() async { + await _ensureInitializedForAuto(); + var albums = _recentAlbums; + if (_serverOfflineMode) { + final downloadedIds = await _downloadedSongIdsForAuto(); + final albumIdsWithDownloads = _cachedAllSongs + .where((s) => downloadedIds.contains(s.id)) + .map((s) => s.albumId) + .whereType() + .toSet(); + final offlineAlbums = _cachedAllAlbums + .where((a) => albumIdsWithDownloads.contains(a.id)) + .toList(); + if (offlineAlbums.isNotEmpty) albums = offlineAlbums; + } + return albums + .take(100) + .map( + (album) => { + 'id': album.id, + 'name': album.name, + 'artist': album.artist ?? '', + 'artworkUrl': getCoverArtUrl(album.coverArt), + }, + ) .toList(); - if (offlineAlbums.isNotEmpty) { - _androidAutoService.updateAlbums(offlineAlbums, getCoverArtUrl); - } else if (_recentAlbums.isNotEmpty) { - _androidAutoService.updateAlbums(_recentAlbums, getCoverArtUrl); - } + } - final artistIdsWithDownloads = - offlineSongs.map((s) => s.artistId).whereType().toSet(); - final offlineArtists = - _artists.where((a) => artistIdsWithDownloads.contains(a.id)).toList(); - if (offlineArtists.isNotEmpty) { - _androidAutoService.updateArtists(offlineArtists); - } else if (_artists.isNotEmpty) { - _androidAutoService.updateArtists(_artists); - } + Future>> _artistsForAuto() async { + await _ensureInitializedForAuto(); + var artists = _artists; + if (_serverOfflineMode) { + final downloadedIds = await _downloadedSongIdsForAuto(); + final artistIdsWithDownloads = _cachedAllSongs + .where((s) => downloadedIds.contains(s.id)) + .map((s) => s.artistId) + .whereType() + .toSet(); + final offlineArtists = _artists + .where((a) => artistIdsWithDownloads.contains(a.id)) + .toList(); + if (offlineArtists.isNotEmpty) artists = offlineArtists; + } + return artists + .take(100) + .map( + (artist) => { + 'id': artist.id, + 'name': artist.name, + 'albumCount': artist.albumCount, + }, + ) + .toList(); + } - if (_playlists.isNotEmpty) { - _androidAutoService.updatePlaylists(_playlists, getCoverArtUrl); - } + Future>> _playlistsForAuto() async { + await _ensureInitializedForAuto(); + return _playlists + .take(50) + .map( + (playlist) => { + 'id': playlist.id, + 'name': playlist.name, + 'songCount': playlist.songCount, + 'artworkUrl': getCoverArtUrl(playlist.coverArt), + }, + ) + .toList(); } void _preloadCoverArt() { @@ -566,14 +597,11 @@ class LibraryProvider extends ChangeNotifier { try { _artists = await _subsonicService.getArtists(); notifyListeners(); - _androidAutoService.updateArtists(_artists); + _audioHandler + .notifyAutoChildrenChanged([MuslyAudioHandler.mediaIdArtists]); _saveCachedData(); } catch (e) { debugPrint('Error loading artists: $e'); - - if (_artists.isNotEmpty) { - _androidAutoService.updateArtists(_artists); - } } } @@ -591,7 +619,8 @@ class LibraryProvider extends ChangeNotifier { _recentAlbums = fetched; } notifyListeners(); - _androidAutoService.updateAlbums(_recentAlbums, getCoverArtUrl); + _audioHandler + .notifyAutoChildrenChanged([MuslyAudioHandler.mediaIdAlbums]); } catch (e) { debugPrint('Error loading recent albums: $e'); } @@ -662,17 +691,14 @@ class LibraryProvider extends ChangeNotifier { _cachedPlaylists = _playlists; _saveCachedData(); notifyListeners(); - _androidAutoService.updatePlaylists(_playlists, getCoverArtUrl); + _audioHandler + .notifyAutoChildrenChanged([MuslyAudioHandler.mediaIdPlaylists]); } catch (e) { debugPrint('Error loading playlists: $e'); if (_playlists.isEmpty && _cachedPlaylists.isNotEmpty) { _playlists = _cachedPlaylists; notifyListeners(); } - - if (_playlists.isNotEmpty) { - _androidAutoService.updatePlaylists(_playlists, getCoverArtUrl); - } } } @@ -681,13 +707,10 @@ class LibraryProvider extends ChangeNotifier { try { _randomSongs = await _subsonicService.getRandomSongs(size: 50); notifyListeners(); - _androidAutoService.updateRecentSongs(_randomSongs, getCoverArtUrl); + _audioHandler + .notifyAutoChildrenChanged([MuslyAudioHandler.mediaIdRecent]); } catch (e) { debugPrint('Error loading random songs: $e'); - - if (_randomSongs.isNotEmpty) { - _androidAutoService.updateRecentSongs(_randomSongs, getCoverArtUrl); - } } } diff --git a/lib/providers/player_provider.dart b/lib/providers/player_provider.dart index ddb69523..8eac0cca 100644 --- a/lib/providers/player_provider.dart +++ b/lib/providers/player_provider.dart @@ -13,7 +13,6 @@ import 'package:shared_preferences/shared_preferences.dart'; import '../models/models.dart'; import '../services/subsonic_service.dart'; import '../services/offline_service.dart'; -import '../services/android_auto_service.dart'; import '../services/android_system_service.dart'; import '../services/windows_system_service.dart'; import '../services/bluetooth_avrcp_service.dart'; @@ -41,7 +40,6 @@ class PlayerProvider extends ChangeNotifier with WidgetsBindingObserver { // Convenience getter — use this everywhere just_audio is accessed directly. AudioPlayer get _audioPlayer => _audioHandler.player; final OfflineService _offlineService = OfflineService(); - final AndroidAutoService _androidAutoService = AndroidAutoService(); final AndroidSystemService _androidSystemService = AndroidSystemService(); final WindowsSystemService _windowsService = WindowsSystemService(); final BluetoothAvrcpService _bluetoothService = BluetoothAvrcpService(); @@ -461,34 +459,15 @@ class PlayerProvider extends ChangeNotifier with WidgetsBindingObserver { } void _initializeAndroidAuto() { - _androidAutoService.initialize(); - - _androidAutoService.onPlay = play; - _androidAutoService.onPause = pause; - _androidAutoService.onStop = stop; - _androidAutoService.onSkipNext = skipNext; - _androidAutoService.onSkipPrevious = skipPrevious; - _androidAutoService.onSeekTo = seek; - _androidAutoService.onPlayFromMediaId = _playFromMediaId; - _androidAutoService.onSetVolume = _onRemoteVolumeChange; - - _androidAutoService.onGetAlbumSongs = _getAlbumSongsForAndroidAuto; - _androidAutoService.onGetArtistAlbums = _getArtistAlbumsForAndroidAuto; - _androidAutoService.onGetPlaylistSongs = _getPlaylistSongsForAndroidAuto; - _androidAutoService.onSearch = _searchForAndroidAuto; - _androidAutoService.onPlayFromSearch = _playFromSearchForAndroidAuto; - _androidAutoService.onRequestLibraryData = _onRequestLibraryData; - } - - void _onRequestLibraryData() { - debugPrint( - 'PlayerProvider: Android Auto requested library data, delegating to LibraryProvider'); - // The LibraryProvider handles this in its constructor, but we add this - // as a fallback to ensure the request is handled - if (_libraryProvider != null) { - // Trigger a re-push of library data via the LibraryProvider - // This is handled by the callback registered in LibraryProvider's constructor - } + // Song-level browse data, search and playback for Android Auto are + // served through the audio_service handler (see MuslyAudioHandler). + _audioHandler.onGetAlbumSongs = _getAlbumSongsForAndroidAuto; + _audioHandler.onGetArtistAlbums = _getArtistAlbumsForAndroidAuto; + _audioHandler.onGetPlaylistSongs = _getPlaylistSongsForAndroidAuto; + _audioHandler.onSearch = _searchForAndroidAuto; + _audioHandler.onPlayFromMediaId = _playFromMediaId; + _audioHandler.onPlayFromSearch = _playFromSearchForAndroidAuto; + _audioHandler.onSetRemoteVolume = _onRemoteVolumeChange; } Future>> _getAlbumSongsForAndroidAuto( @@ -864,19 +843,9 @@ class PlayerProvider extends ChangeNotifier with WidgetsBindingObserver { ? _duration : Duration(seconds: _currentSong!.duration ?? 0); - _androidAutoService.updatePlaybackState( - songId: _currentSong!.id, - title: _currentSong!.title, - artist: _currentSong!.artist ?? '', - album: _currentSong!.album ?? '', - artworkUrl: artworkUrl, - duration: effectiveDuration, - position: _position, - isPlaying: _isPlaying, - ); - - // Update the audio_service handler so lock screen / Control Center / iOS - // Now Playing info stays accurate regardless of the UI lifecycle. + // Update the audio_service handler so lock screen / Control Center / + // Android Auto Now Playing info stays accurate regardless of the UI + // lifecycle. _audioHandler.updateNowPlaying( id: _currentSong!.id, title: _currentSong!.title, @@ -886,6 +855,15 @@ class PlayerProvider extends ChangeNotifier with WidgetsBindingObserver { duration: effectiveDuration, ); + // While rendering on a remote target the local just_audio player is + // paused, so push the real playback state to the media session manually. + if (_isRenderingRemotely || _jukeboxService.enabled) { + _audioHandler.updateRemotePlaybackState( + playing: _isPlaying, + position: _position, + ); + } + _updateDiscordRpc(); _updateAllServices(); } @@ -2379,7 +2357,7 @@ class PlayerProvider extends ChangeNotifier with WidgetsBindingObserver { final actual = await _upnpService.getVolume(); if (actual >= 0) { _volume = actual / 100.0; - _androidSystemService.updateRemoteVolume(actual); + _audioHandler.updateRemoteVolume(actual); notifyListeners(); } } catch (e) { @@ -2673,9 +2651,6 @@ class PlayerProvider extends ChangeNotifier with WidgetsBindingObserver { debugPrint('Error disposing audio handler: $e'); }); - try { - _androidAutoService.dispose(); - } catch (_) {} try { _androidSystemService.dispose(); } catch (_) {} @@ -2777,7 +2752,7 @@ class PlayerProvider extends ChangeNotifier with WidgetsBindingObserver { notifyListeners(); if (_castService.isConnected) { _audioPlayer.pause(); - _androidSystemService.setRemotePlayback(isRemote: true, volume: 50); + _audioHandler.setRemotePlayback(isRemote: true, volume: 50); if (_currentSong != null) { final song = _currentSong!; _currentSong = null; @@ -2785,7 +2760,7 @@ class PlayerProvider extends ChangeNotifier with WidgetsBindingObserver { } } else { _isRenderingRemotely = false; - _androidSystemService.setRemotePlayback(isRemote: false); + _audioHandler.setRemotePlayback(isRemote: false); _isPlaying = false; notifyListeners(); _updateAndroidAuto(); @@ -2808,7 +2783,7 @@ class PlayerProvider extends ChangeNotifier with WidgetsBindingObserver { final vol = _upnpService.volume; if (vol >= 0) _volume = vol / 100.0; - _androidSystemService.setRemotePlayback( + _audioHandler.setRemotePlayback( isRemote: true, volume: vol >= 0 ? vol : 50, ); @@ -2826,7 +2801,7 @@ class PlayerProvider extends ChangeNotifier with WidgetsBindingObserver { _isRenderingRemotely = false; _isPlaying = false; // Preserve _position and _duration so the UI shows where we were. - _androidSystemService.setRemotePlayback(isRemote: false); + _audioHandler.setRemotePlayback(isRemote: false); notifyListeners(); _updateAndroidAuto(); return; @@ -2876,7 +2851,7 @@ class PlayerProvider extends ChangeNotifier with WidgetsBindingObserver { if ((_volume - normalized).abs() > 0.005) { _volume = normalized; changed = true; - _androidSystemService.updateRemoteVolume(vol); + _audioHandler.updateRemoteVolume(vol); } } diff --git a/lib/services/android_auto_service.dart b/lib/services/android_auto_service.dart deleted file mode 100644 index 74cdef9d..00000000 --- a/lib/services/android_auto_service.dart +++ /dev/null @@ -1,364 +0,0 @@ -import 'dart:async'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/services.dart'; -import '../models/song.dart'; -import '../models/album.dart'; -import '../models/artist.dart'; -import '../models/playlist.dart'; - -class AndroidAutoService { - static final AndroidAutoService _instance = AndroidAutoService._internal(); - factory AndroidAutoService() => _instance; - AndroidAutoService._internal(); - - static const MethodChannel _methodChannel = MethodChannel( - 'com.devid.musly/android_auto', - ); - static const EventChannel _eventChannel = EventChannel( - 'com.devid.musly/android_auto_events', - ); - - StreamSubscription? _eventSubscription; - - // Track if a library data request was received before callback was registered - bool _pendingLibraryDataRequest = false; - - VoidCallback? onPlay; - VoidCallback? onPause; - VoidCallback? onStop; - VoidCallback? onSkipNext; - VoidCallback? onSkipPrevious; - Function(Duration position)? onSeekTo; - Function(String mediaId)? onPlayFromMediaId; - Function(int volume)? onSetVolume; - - Future>> Function(String albumId)? onGetAlbumSongs; - Future>> Function(String artistId)? - onGetArtistAlbums; - Future>> Function(String playlistId)? - onGetPlaylistSongs; - Future>> Function(String query)? onSearch; - Function(String query)? onPlayFromSearch; - - VoidCallback? _onRequestLibraryData; - VoidCallback? get onRequestLibraryData => _onRequestLibraryData; - set onRequestLibraryData(VoidCallback? callback) { - _onRequestLibraryData = callback; - // If there was a pending request, process it now - if (callback != null && _pendingLibraryDataRequest) { - debugPrint('AndroidAuto: Processing pending library data request'); - _pendingLibraryDataRequest = false; - callback(); - } - } - - Future initialize() async { - if (defaultTargetPlatform != TargetPlatform.android) return; - - try { - _eventSubscription = _eventChannel.receiveBroadcastStream().listen( - _handleEvent, - onError: _handleError, - ); - - await _methodChannel.invokeMethod('startService'); - debugPrint('AndroidAutoService initialized'); - } catch (e) { - debugPrint('Error initializing AndroidAutoService: $e'); - } - } - - void _handleEvent(dynamic event) { - if (event is! Map) return; - - final command = event['command'] as String?; - debugPrint('AndroidAuto command received: $command'); - - switch (command) { - case 'play': - onPlay?.call(); - break; - case 'pause': - onPause?.call(); - break; - case 'stop': - onStop?.call(); - break; - case 'skipNext': - onSkipNext?.call(); - break; - case 'skipPrevious': - onSkipPrevious?.call(); - break; - case 'seekTo': - final position = event['position'] as int?; - if (position != null) { - onSeekTo?.call(Duration(milliseconds: position)); - } - break; - case 'playFromMediaId': - final mediaId = event['mediaId'] as String?; - if (mediaId != null) { - onPlayFromMediaId?.call(mediaId); - } - break; - case 'setVolume': - final volume = event['volume'] as int?; - if (volume != null) { - onSetVolume?.call(volume); - } - break; - case 'getAlbumSongs': - final albumId = event['albumId'] as String?; - if (albumId != null) { - _handleGetAlbumSongs(albumId); - } - break; - case 'getArtistAlbums': - final artistId = event['artistId'] as String?; - if (artistId != null) { - _handleGetArtistAlbums(artistId); - } - break; - case 'getPlaylistSongs': - final playlistId = event['playlistId'] as String?; - if (playlistId != null) { - _handleGetPlaylistSongs(playlistId); - } - break; - case 'search': - final query = event['query'] as String?; - debugPrint('AndroidAuto: Search command received, query="$query", onSearch=${onSearch != null}'); - if (query != null) { - _handleSearch(query); - } - break; - case 'playFromSearch': - final searchQuery = event['query'] as String?; - if (searchQuery != null) { - onPlayFromSearch?.call(searchQuery); - } - break; - case 'requestLibraryData': - debugPrint('AndroidAuto: Library data requested by service'); - if (onRequestLibraryData != null) { - onRequestLibraryData!(); - } else { - debugPrint('AndroidAuto: Warning - onRequestLibraryData callback is not set, buffering request'); - _pendingLibraryDataRequest = true; - } - break; - } - } - - Future _handleGetAlbumSongs(String albumId) async { - if (onGetAlbumSongs == null) return; - try { - final songs = await onGetAlbumSongs!(albumId); - await _methodChannel.invokeMethod('updateAlbumSongs', { - 'albumId': albumId, - 'songs': songs, - }); - } catch (e) { - debugPrint('Error getting album songs: $e'); - } - } - - Future _handleGetArtistAlbums(String artistId) async { - if (onGetArtistAlbums == null) return; - try { - final albums = await onGetArtistAlbums!(artistId); - await _methodChannel.invokeMethod('updateArtistAlbums', { - 'artistId': artistId, - 'albums': albums, - }); - } catch (e) { - debugPrint('Error getting artist albums: $e'); - } - } - - Future _handleGetPlaylistSongs(String playlistId) async { - if (onGetPlaylistSongs == null) return; - try { - final songs = await onGetPlaylistSongs!(playlistId); - await _methodChannel.invokeMethod('updatePlaylistSongs', { - 'playlistId': playlistId, - 'songs': songs, - }); - } catch (e) { - debugPrint('Error getting playlist songs: $e'); - } - } - - Future _handleSearch(String query) async { - debugPrint('AndroidAuto: _handleSearch called with query="$query"'); - if (onSearch == null) { - debugPrint('AndroidAuto: onSearch callback is null, cannot search'); - return; - } - try { - debugPrint('AndroidAuto: Executing search...'); - final songs = await onSearch!(query); - debugPrint('AndroidAuto: Search returned ${songs.length} songs'); - await _methodChannel.invokeMethod('updateSearchResults', { - 'query': query, - 'songs': songs, - }); - debugPrint('AndroidAuto: Search results sent to native'); - } catch (e, stackTrace) { - debugPrint('AndroidAuto: Error handling search: $e'); - debugPrint('AndroidAuto: Stack trace: $stackTrace'); - await _methodChannel.invokeMethod('updateSearchResults', { - 'query': query, - 'songs': >[], - }); - } - } - - void _handleError(dynamic error) { - debugPrint('AndroidAuto event error: $error'); - } - - Future updatePlaybackState({ - required String? songId, - required String title, - required String artist, - required String album, - String? artworkUrl, - required Duration duration, - required Duration position, - required bool isPlaying, - }) async { - if (defaultTargetPlatform != TargetPlatform.android) return; - - try { - await _methodChannel.invokeMethod('updatePlaybackState', { - 'songId': songId, - 'title': title, - 'artist': artist, - 'album': album, - 'artworkUrl': artworkUrl, - 'duration': duration.inMilliseconds, - 'position': position.inMilliseconds, - 'playing': isPlaying, - }); - } catch (e) { - debugPrint('Error updating playback state: $e'); - } - } - - Future updateRecentSongs( - List songs, - String Function(String?) getCoverArtUrl, - ) async { - if (defaultTargetPlatform != TargetPlatform.android) return; - - try { - final songList = songs - .take(50) - .map( - (song) => { - 'id': song.id, - 'title': song.title, - 'artist': song.artist ?? '', - 'album': song.album ?? '', - 'artworkUrl': getCoverArtUrl(song.coverArt), - 'duration': song.duration ?? 0, - }, - ) - .toList(); - - await _methodChannel.invokeMethod('updateRecentSongs', { - 'songs': songList, - }); - } catch (e) { - debugPrint('Error updating recent songs: $e'); - } - } - - Future updateAlbums( - List albums, - String Function(String?) getCoverArtUrl, - ) async { - if (defaultTargetPlatform != TargetPlatform.android) return; - - try { - final albumList = albums - .take(100) - .map( - (album) => { - 'id': album.id, - 'name': album.name, - 'artist': album.artist ?? '', - 'artworkUrl': getCoverArtUrl(album.coverArt), - }, - ) - .toList(); - - await _methodChannel.invokeMethod('updateAlbums', {'albums': albumList}); - } catch (e) { - debugPrint('Error updating albums: $e'); - } - } - - Future updateArtists(List artists) async { - if (defaultTargetPlatform != TargetPlatform.android) return; - - try { - final artistList = artists - .take(100) - .map( - (artist) => { - 'id': artist.id, - 'name': artist.name, - 'albumCount': artist.albumCount, - }, - ) - .toList(); - - await _methodChannel.invokeMethod('updateArtists', { - 'artists': artistList, - }); - } catch (e) { - debugPrint('Error updating artists: $e'); - } - } - - Future updatePlaylists( - List playlists, - String Function(String?) getCoverArtUrl, - ) async { - if (defaultTargetPlatform != TargetPlatform.android) return; - - try { - final playlistList = playlists - .take(50) - .map( - (playlist) => { - 'id': playlist.id, - 'name': playlist.name, - 'songCount': playlist.songCount, - 'artworkUrl': getCoverArtUrl(playlist.coverArt), - }, - ) - .toList(); - - await _methodChannel.invokeMethod('updatePlaylists', { - 'playlists': playlistList, - }); - } catch (e) { - debugPrint('Error updating playlists: $e'); - } - } - - Future dispose() async { - if (defaultTargetPlatform != TargetPlatform.android) return; - - _eventSubscription?.cancel(); - try { - await _methodChannel.invokeMethod('stopService'); - } catch (e) { - debugPrint('Error stopping AndroidAutoService: $e'); - } - } -} diff --git a/lib/services/android_system_service.dart b/lib/services/android_system_service.dart index 802049bd..64af00f4 100644 --- a/lib/services/android_system_service.dart +++ b/lib/services/android_system_service.dart @@ -334,36 +334,6 @@ class AndroidSystemService { } } - Future setRemotePlayback({ - required bool isRemote, - int volume = 50, - }) async { - if (defaultTargetPlatform != TargetPlatform.android) { - return; - } - try { - await _methodChannel.invokeMethod('setRemotePlayback', { - 'isRemote': isRemote, - 'volume': volume, - }); - } catch (e) { - debugPrint('Error setting remote playback: $e'); - } - } - - Future updateRemoteVolume(int volume) async { - if (defaultTargetPlatform != TargetPlatform.android) { - return; - } - try { - await _methodChannel.invokeMethod('updateRemoteVolume', { - 'volume': volume, - }); - } catch (e) { - debugPrint('Error updating remote volume: $e'); - } - } - Future dispose() async { if (defaultTargetPlatform != TargetPlatform.android) { return; diff --git a/lib/services/audio_handler.dart b/lib/services/audio_handler.dart index acd914d7..66d92728 100644 --- a/lib/services/audio_handler.dart +++ b/lib/services/audio_handler.dart @@ -5,8 +5,9 @@ import 'package:audio_service/audio_service.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:just_audio/just_audio.dart'; +import 'package:rxdart/rxdart.dart'; -/// iOS/background-service audio handler. +/// Background-service audio handler (iOS + Android). /// /// Wraps [AudioPlayer] (just_audio) and bridges the [audio_service] protocol /// so that: @@ -15,16 +16,33 @@ import 'package:just_audio/just_audio.dart'; /// MPRemoteCommandCenter registration. /// • [MPNowPlayingInfoCenter] is updated automatically by the audio_service /// iOS plugin whenever [mediaItem] changes. -/// • On Android the existing notification/media-session stack is kept; the -/// handler provides a unified Dart abstraction over the raw [AudioPlayer]. +/// • On Android, audio_service hosts the MediaBrowserService used by +/// Android Auto: the browse tree, voice/keyboard search and playback +/// commands are served by this handler, even when the app UI has never +/// been opened (audio_service spawns a headless Flutter engine and runs +/// `main()`). /// /// [PlayerProvider] receives this handler and uses [player] directly for all /// just_audio operations. It calls [updateNowPlaying] whenever the current -/// song changes to push metadata up to the lock screen / Control Center. +/// song changes to push metadata up to the lock screen / Control Center / +/// Android Auto. class MuslyAudioHandler extends BaseAudioHandler with SeekHandler { final AudioPlayer _player = AudioPlayer(); static const _pitchChannel = MethodChannel('com.devid.musly/pitch'); + // --------------------------------------------------------------------------- + // Android Auto browse tree media IDs. + // Kept identical to the legacy MusicService scheme so play-from-media-id + // handling in PlayerProvider keeps working unchanged. + // --------------------------------------------------------------------------- + static const mediaIdRecent = 'RECENT'; + static const mediaIdAlbums = 'ALBUMS'; + static const mediaIdArtists = 'ARTISTS'; + static const mediaIdPlaylists = 'PLAYLISTS'; + static const _albumPrefix = 'album_'; + static const _artistPrefix = 'artist_'; + static const _playlistPrefix = 'playlist_'; + /// Exposed so [PlayerProvider] can still call setUrl, play, pause, seek, etc. AudioPlayer get player => _player; @@ -40,13 +58,48 @@ class MuslyAudioHandler extends BaseAudioHandler with SeekHandler { Future Function(Duration)? onSeekTo; Future Function()? onTogglePlayPause; + // --------------------------------------------------------------------------- + // Android Auto callbacks. + // Browse data getters are wired by LibraryProvider; song-level getters, + // search and playback are wired by PlayerProvider. All of them exchange the + // same map shapes used by the legacy AndroidAutoService bridge: + // songs: {id, title, artist, album, artworkUrl} + // albums: {id, name, artist, artworkUrl} + // artists: {id, name, albumCount} + // playlists: {id, name, songCount, artworkUrl} + // --------------------------------------------------------------------------- + Future>> Function()? onGetRecentSongs; + Future>> Function()? onGetLibraryAlbums; + Future>> Function()? onGetLibraryArtists; + Future>> Function()? onGetLibraryPlaylists; + Future>> Function(String albumId)? onGetAlbumSongs; + Future>> Function(String artistId)? + onGetArtistAlbums; + Future>> Function(String playlistId)? + onGetPlaylistSongs; + Future>> Function(String query)? onSearch; + Future Function(String mediaId)? onPlayFromMediaId; + Future Function(String query)? onPlayFromSearch; + + /// Called when a remote-volume change is requested from the media session + /// (Android Auto / Bluetooth volume keys while casting or on UPnP). + /// The argument is a volume percentage (0–100). + void Function(int volumePercent)? onSetRemoteVolume; + + final Map>> _childrenSubjects = + {}; + + // Remote (UPnP/Cast) volume state mirrored into the media session. + bool _remotePlayback = false; + int _remoteVolume = 50; + static const _remoteMaxVolume = 100; + static const _remoteVolumeStep = 5; + MuslyAudioHandler() { // Forward just_audio playback events → audio_service playback state. // This drives the iOS Control Center / lock screen widget and the // Android media notification automatically. - _player.playbackEventStream - .map(_buildPlaybackState) - .pipe(playbackState); + _player.playbackEventStream.map(_buildPlaybackState).pipe(playbackState); } // --------------------------------------------------------------------------- @@ -88,6 +141,251 @@ class MuslyAudioHandler extends BaseAudioHandler with SeekHandler { } } + // --------------------------------------------------------------------------- + // Android Auto: browse tree + // --------------------------------------------------------------------------- + + @override + Future> getChildren( + String parentMediaId, [ + Map? options, + ]) async { + try { + switch (parentMediaId) { + case AudioService.browsableRootId: + return _rootItems(); + case AudioService.recentRootId: + case mediaIdRecent: + return _songItems(await onGetRecentSongs?.call() ?? const []); + case mediaIdAlbums: + return _albumItems(await onGetLibraryAlbums?.call() ?? const []); + case mediaIdArtists: + return _artistItems(await onGetLibraryArtists?.call() ?? const []); + case mediaIdPlaylists: + return _playlistItems( + await onGetLibraryPlaylists?.call() ?? const [], + ); + } + if (parentMediaId.startsWith(_albumPrefix)) { + final albumId = parentMediaId.substring(_albumPrefix.length); + return _songItems(await onGetAlbumSongs?.call(albumId) ?? const []); + } + if (parentMediaId.startsWith(_artistPrefix)) { + final artistId = parentMediaId.substring(_artistPrefix.length); + return _albumItems( + await onGetArtistAlbums?.call(artistId) ?? const [], + ); + } + if (parentMediaId.startsWith(_playlistPrefix)) { + final playlistId = parentMediaId.substring(_playlistPrefix.length); + return _songItems( + await onGetPlaylistSongs?.call(playlistId) ?? const [], + ); + } + } catch (e, st) { + debugPrint('AudioHandler: getChildren($parentMediaId) failed: $e\n$st'); + } + return const []; + } + + @override + ValueStream> subscribeToChildren(String parentMediaId) { + return _childrenSubjects.putIfAbsent( + parentMediaId, + () => BehaviorSubject.seeded({}), + ); + } + + /// Tell subscribed media browsers (Android Auto) that the children of the + /// given parents changed, so they re-query [getChildren]. With no argument, + /// all four top-level categories are refreshed. + void notifyAutoChildrenChanged([List? parents]) { + final targets = parents ?? + const [mediaIdRecent, mediaIdAlbums, mediaIdArtists, mediaIdPlaylists]; + for (final parent in targets) { + _childrenSubjects[parent]?.add({}); + } + } + + @override + Future> search( + String query, [ + Map? extras, + ]) async { + try { + final results = await onSearch?.call(query) ?? const []; + return _songItems(results); + } catch (e) { + debugPrint('AudioHandler: search("$query") failed: $e'); + return const []; + } + } + + @override + Future playFromMediaId( + String mediaId, [ + Map? extras, + ]) async { + await onPlayFromMediaId?.call(mediaId); + } + + @override + Future playFromSearch( + String query, [ + Map? extras, + ]) async { + await onPlayFromSearch?.call(query.trim()); + } + + List _rootItems() { + return const [ + MediaItem( + id: mediaIdRecent, + title: 'Recent', + playable: false, + ), + MediaItem( + id: mediaIdAlbums, + title: 'Albums', + playable: false, + ), + MediaItem( + id: mediaIdArtists, + title: 'Artists', + playable: false, + ), + MediaItem( + id: mediaIdPlaylists, + title: 'Playlists', + playable: false, + ), + ]; + } + + List _songItems(List> songs) { + return songs + .map( + (song) => MediaItem( + id: (song['id'] as String?) ?? '', + title: (song['title'] as String?) ?? '', + artist: song['artist'] as String?, + album: song['album'] as String?, + artUri: _tryParseUri(song['artworkUrl'] as String?), + duration: _parseDurationSeconds(song['duration']), + ), + ) + .where((item) => item.id.isNotEmpty) + .toList(); + } + + List _albumItems(List> albums) { + return albums + .map( + (album) => MediaItem( + id: '$_albumPrefix${album['id']}', + title: (album['name'] as String?) ?? '', + artist: album['artist'] as String?, + artUri: _tryParseUri(album['artworkUrl'] as String?), + playable: false, + ), + ) + .toList(); + } + + List _artistItems(List> artists) { + return artists + .map( + (artist) => MediaItem( + id: '$_artistPrefix${artist['id']}', + title: (artist['name'] as String?) ?? '', + displaySubtitle: '${artist['albumCount'] ?? 0} albums', + playable: false, + ), + ) + .toList(); + } + + List _playlistItems(List> playlists) { + return playlists + .map( + (playlist) => MediaItem( + id: '$_playlistPrefix${playlist['id']}', + title: (playlist['name'] as String?) ?? '', + displaySubtitle: '${playlist['songCount'] ?? 0} songs', + artUri: _tryParseUri(playlist['artworkUrl'] as String?), + playable: false, + ), + ) + .toList(); + } + + static Uri? _tryParseUri(String? url) { + if (url == null || url.isEmpty) return null; + return Uri.tryParse(url); + } + + static Duration? _parseDurationSeconds(dynamic value) { + final seconds = switch (value) { + int v => v, + String v => int.tryParse(v), + _ => null, + }; + if (seconds == null || seconds <= 0) return null; + return Duration(seconds: seconds); + } + + // --------------------------------------------------------------------------- + // Remote playback volume (UPnP / Cast) mirrored into the media session so + // hardware volume keys and Android Auto control the remote renderer. + // --------------------------------------------------------------------------- + + void setRemotePlayback({required bool isRemote, int volume = 50}) { + if (kIsWeb || !Platform.isAndroid) return; + _remotePlayback = isRemote; + if (isRemote) { + _remoteVolume = volume.clamp(0, _remoteMaxVolume); + androidPlaybackInfo.add( + RemoteAndroidPlaybackInfo( + volumeControlType: AndroidVolumeControlType.absolute, + maxVolume: _remoteMaxVolume, + volume: _remoteVolume, + ), + ); + } else { + androidPlaybackInfo.add(LocalAndroidPlaybackInfo()); + } + } + + void updateRemoteVolume(int volume) { + if (kIsWeb || !Platform.isAndroid || !_remotePlayback) return; + _remoteVolume = volume.clamp(0, _remoteMaxVolume); + androidPlaybackInfo.add( + RemoteAndroidPlaybackInfo( + volumeControlType: AndroidVolumeControlType.absolute, + maxVolume: _remoteMaxVolume, + volume: _remoteVolume, + ), + ); + } + + @override + Future androidSetRemoteVolume(int volumeIndex) async { + if (!_remotePlayback) return; + _remoteVolume = volumeIndex.clamp(0, _remoteMaxVolume); + onSetRemoteVolume?.call(_remoteVolume); + } + + @override + Future androidAdjustRemoteVolume( + AndroidVolumeDirection direction, + ) async { + if (!_remotePlayback || direction.index == 0) return; + _remoteVolume = (_remoteVolume + direction.index * _remoteVolumeStep) + .clamp(0, _remoteMaxVolume); + updateRemoteVolume(_remoteVolume); + onSetRemoteVolume?.call(_remoteVolume); + } + // --------------------------------------------------------------------------- // Called by PlayerProvider to push metadata to the lock screen. // --------------------------------------------------------------------------- @@ -120,6 +418,35 @@ class MuslyAudioHandler extends BaseAudioHandler with SeekHandler { mediaItem.add(const MediaItem(id: '', title: '')); } + /// Pushes an explicit playback state while rendering on a remote target + /// (UPnP / Cast / jukebox), where the local just_audio player is paused and + /// would otherwise report "paused" to the media session and Android Auto. + void updateRemotePlaybackState({ + required bool playing, + required Duration position, + }) { + playbackState.add( + playbackState.value.copyWith( + controls: [ + MediaControl.skipToPrevious, + if (playing) MediaControl.pause else MediaControl.play, + MediaControl.skipToNext, + ], + systemActions: const { + MediaAction.seek, + MediaAction.seekForward, + MediaAction.seekBackward, + MediaAction.playFromMediaId, + MediaAction.playFromSearch, + }, + androidCompactActionIndices: const [0, 1, 2], + processingState: AudioProcessingState.ready, + playing: playing, + updatePosition: position, + ), + ); + } + // --------------------------------------------------------------------------- // Internal: map just_audio state → audio_service PlaybackState // --------------------------------------------------------------------------- @@ -143,6 +470,8 @@ class MuslyAudioHandler extends BaseAudioHandler with SeekHandler { MediaAction.seek, MediaAction.seekForward, MediaAction.seekBackward, + MediaAction.playFromMediaId, + MediaAction.playFromSearch, }, androidCompactActionIndices: const [0, 1, 2], processingState: @@ -185,13 +514,15 @@ class MuslyAudioHandler extends BaseAudioHandler with SeekHandler { /// Initialises [audio_service] and returns the singleton [MuslyAudioHandler]. /// Call this once from [main()] before [runApp()]. /// -/// On iOS, AudioService.init() is called so the audio engine runs as a proper -/// background service, driving the Control Center and lock screen. -/// On all other platforms (Android, desktop, web) the handler is created -/// directly — Android manages its own MediaBrowserServiceCompat (MusicService) -/// for Android Auto and we must not start a second service on top of it. +/// On iOS and Android, AudioService.init() is called so the audio engine runs +/// as a proper background service. On Android this also registers the +/// MediaBrowserService that Android Auto connects to: when Auto starts with +/// the app closed, audio_service spawns a headless Flutter engine, runs +/// main(), and this handler serves the browse tree, search and playback. +/// On desktop/web the handler is created directly (audio_service has no +/// backend there). Future initAudioService() async { - if (!kIsWeb && Platform.isIOS) { + if (!kIsWeb && (Platform.isIOS || Platform.isAndroid)) { return AudioService.init( builder: () => MuslyAudioHandler(), config: const AudioServiceConfig( @@ -199,10 +530,15 @@ Future initAudioService() async { androidNotificationChannelName: 'Musly', androidNotificationOngoing: true, androidStopForegroundOnPause: true, + androidNotificationIcon: 'mipmap/ic_launcher', + notificationColor: Color(0xFF1DB954), preloadArtwork: false, + androidBrowsableRootExtras: { + 'android.media.browse.SEARCH_SUPPORTED': true, + }, ), ); } - // Android / desktop / web: no AudioService wrapper needed. + // Desktop / web: no AudioService wrapper needed. return MuslyAudioHandler(); } diff --git a/lib/services/services.dart b/lib/services/services.dart index ae3bc3ba..c40a2040 100644 --- a/lib/services/services.dart +++ b/lib/services/services.dart @@ -2,7 +2,6 @@ export 'subsonic_service.dart'; export 'jellyfin_service.dart'; export 'youtube_service.dart'; export 'storage_service.dart'; -export 'android_auto_service.dart'; export 'android_system_service.dart'; export 'windows_system_service.dart'; export 'bluetooth_avrcp_service.dart'; diff --git a/packages/flutter_chrome_cast/pubspec.lock b/packages/flutter_chrome_cast/pubspec.lock index 77ff44e1..37e52e9b 100644 --- a/packages/flutter_chrome_cast/pubspec.lock +++ b/packages/flutter_chrome_cast/pubspec.lock @@ -172,10 +172,10 @@ packages: dependency: transitive description: name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" url: "https://pub.dev" source: hosted - version: "0.12.19" + version: "0.12.18" material_color_utilities: dependency: transitive description: @@ -337,10 +337,10 @@ packages: dependency: transitive description: name: test_api - sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + sha256: "19a78f63e83d3a61f00826d09bc2f60e191bf3504183c001262be6ac75589fb8" url: "https://pub.dev" source: hosted - version: "0.7.10" + version: "0.7.8" typed_data: dependency: transitive description: diff --git a/pubspec.lock b/pubspec.lock index 046f8d3d..66e7021e 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -720,10 +720,10 @@ packages: dependency: transitive description: name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" url: "https://pub.dev" source: hosted - version: "0.12.19" + version: "0.12.18" material_color_utilities: dependency: transitive description: @@ -760,10 +760,10 @@ packages: dependency: transitive description: name: meta - sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" url: "https://pub.dev" source: hosted - version: "1.18.0" + version: "1.17.0" mime: dependency: transitive description: @@ -1021,7 +1021,7 @@ packages: source: hosted version: "0.2.7" rxdart: - dependency: transitive + dependency: "direct main" description: name: rxdart sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" @@ -1245,10 +1245,10 @@ packages: dependency: transitive description: name: test_api - sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + sha256: "19a78f63e83d3a61f00826d09bc2f60e191bf3504183c001262be6ac75589fb8" url: "https://pub.dev" source: hosted - version: "0.7.11" + version: "0.7.8" typed_data: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 5adb7c8b..e49fd525 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -37,6 +37,7 @@ dependencies: media_kit_libs_linux: ^1.2.1 # Native libmpv binaries for Linux audio_session: ^0.1.25 audio_service: ^0.18.18 + rxdart: ^0.28.0 cached_network_image: ^3.3.1 flutter_cache_manager: ^3.3.1