From 83c6b76d0a40f7a61f5e9bc6da04e0980d6fe3ea Mon Sep 17 00:00:00 2001 From: Nite Date: Mon, 12 Jul 2021 16:13:34 +0200 Subject: [PATCH] Updated Android Auto to use MediaPlayerService separately Added some missing features found in the docs --- ultrasonic/src/main/AndroidManifest.xml | 2 +- .../moire/ultrasonic/di/ApplicationModule.kt | 2 + .../service/AutoMediaBrowserService.kt | 186 ++++++++++++++++++ .../ultrasonic/service/LocalMediaPlayer.kt | 2 +- .../ultrasonic/service/MediaPlayerService.kt | 85 ++++---- ...ediaSessionTokenCreatedEventDistributor.kt | 49 +++++ .../MediaSessionTokenCreatedEventListener.kt | 13 ++ 7 files changed, 304 insertions(+), 35 deletions(-) create mode 100644 ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/AutoMediaBrowserService.kt create mode 100644 ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/MediaSessionTokenCreatedEventDistributor.kt create mode 100644 ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/MediaSessionTokenCreatedEventListener.kt diff --git a/ultrasonic/src/main/AndroidManifest.xml b/ultrasonic/src/main/AndroidManifest.xml index f3a0d39c..f9abed6b 100644 --- a/ultrasonic/src/main/AndroidManifest.xml +++ b/ultrasonic/src/main/AndroidManifest.xml @@ -60,7 +60,7 @@ diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/ApplicationModule.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/ApplicationModule.kt index e78404bf..47b08cde 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/ApplicationModule.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/ApplicationModule.kt @@ -4,6 +4,7 @@ import org.koin.android.ext.koin.androidContext import org.koin.dsl.module import org.moire.ultrasonic.data.ActiveServerProvider import org.moire.ultrasonic.subsonic.ImageLoaderProvider +import org.moire.ultrasonic.util.MediaSessionEventDistributor import org.moire.ultrasonic.util.NowPlayingEventDistributor import org.moire.ultrasonic.util.PermissionUtil import org.moire.ultrasonic.util.ThemeChangedEventDistributor @@ -17,4 +18,5 @@ val applicationModule = module { single { PermissionUtil(androidContext()) } single { NowPlayingEventDistributor() } single { ThemeChangedEventDistributor() } + single { MediaSessionEventDistributor() } } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/AutoMediaBrowserService.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/AutoMediaBrowserService.kt new file mode 100644 index 00000000..6afcb29a --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/AutoMediaBrowserService.kt @@ -0,0 +1,186 @@ +package org.moire.ultrasonic.service + +import android.os.Bundle +import android.os.Handler +import android.support.v4.media.MediaBrowserCompat +import android.support.v4.media.MediaDescriptionCompat +import android.support.v4.media.session.MediaSessionCompat +import androidx.media.MediaBrowserServiceCompat +import androidx.media.utils.MediaConstants +import org.koin.android.ext.android.inject +import org.moire.ultrasonic.util.MediaSessionEventDistributor +import org.moire.ultrasonic.util.MediaSessionEventListener +import timber.log.Timber + + +const val MY_MEDIA_ROOT_ID = "MY_MEDIA_ROOT_ID" +const val MY_MEDIA_ALBUM_ID = "MY_MEDIA_ALBUM_ID" +const val MY_MEDIA_ARTIST_ID = "MY_MEDIA_ARTIST_ID" +const val MY_MEDIA_ALBUM_ITEM = "MY_MEDIA_ALBUM_ITEM" +const val MY_MEDIA_LIBRARY_ID = "MY_MEDIA_LIBRARY_ID" +const val MY_MEDIA_PLAYLIST_ID = "MY_MEDIA_PLAYLIST_ID" + +class AutoMediaBrowserService : MediaBrowserServiceCompat() { + + private lateinit var mediaSessionEventListener: MediaSessionEventListener + private val mediaSessionEventDistributor: MediaSessionEventDistributor by inject() + private val lifecycleSupport: MediaPlayerLifecycleSupport by inject() + + override fun onCreate() { + super.onCreate() + + mediaSessionEventListener = object : MediaSessionEventListener { + override fun onMediaSessionTokenCreated(token: MediaSessionCompat.Token) { + Timber.i("AutoMediaBrowserService onMediaSessionTokenCreated called") + if (sessionToken == null) { + Timber.i("AutoMediaBrowserService onMediaSessionTokenCreated session token was null, set it to %s", token.toString()) + sessionToken = token + } + } + + override fun onPlayFromMediaIdRequested(mediaId: String?, extras: Bundle?) { + // TODO implement + Timber.i("AutoMediaBrowserService onPlayFromMediaIdRequested called") + } + + override fun onPlayFromSearchRequested(query: String?, extras: Bundle?) { + // TODO implement + Timber.i("AutoMediaBrowserService onPlayFromSearchRequested called") + } + } + + mediaSessionEventDistributor.subscribe(mediaSessionEventListener) + + val handler = Handler() + handler.postDelayed({ + Timber.i("AutoMediaBrowserService starting lifecycleSupport and MediaPlayerService...") + // TODO it seems Android Auto handles autostart, but we must check that + lifecycleSupport.onCreate() + MediaPlayerService.getInstance() + }, 100) + + Timber.i("AutoMediaBrowserService onCreate called") + } + + override fun onDestroy() { + super.onDestroy() + mediaSessionEventDistributor.unsubscribe(mediaSessionEventListener) + Timber.i("AutoMediaBrowserService onDestroy called") + } + + override fun onGetRoot( + clientPackageName: String, + clientUid: Int, + rootHints: Bundle? + ): BrowserRoot? { + Timber.i("AutoMediaBrowserService onGetRoot called") + + // TODO: The number of horizontal items available on the Andoid Auto screen. Check and handle. + val maximumRootChildLimit = rootHints!!.getInt( + MediaConstants.BROWSER_ROOT_HINTS_KEY_ROOT_CHILDREN_LIMIT, + 4 + ) + + // TODO: The type of the horizontal items children on the Android Auto screen. Check and handle. + val supportedRootChildFlags = rootHints!!.getInt( + MediaConstants.BROWSER_ROOT_HINTS_KEY_ROOT_CHILDREN_SUPPORTED_FLAGS, + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ) + + val extras = Bundle() + extras.putInt( + MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_BROWSABLE, + MediaConstants.DESCRIPTION_EXTRAS_VALUE_CONTENT_STYLE_LIST_ITEM) + extras.putInt( + MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_PLAYABLE, + MediaConstants.DESCRIPTION_EXTRAS_VALUE_CONTENT_STYLE_LIST_ITEM) + + return BrowserRoot(MY_MEDIA_ROOT_ID, extras) + } + + override fun onLoadChildren( + parentId: String, + result: Result> + ) { + Timber.i("AutoMediaBrowserService onLoadChildren called") + + if (parentId == MY_MEDIA_ROOT_ID) { + return getRootItems(result) + } else { + return getAlbumLists(result) + } + } + + override fun onSearch( + query: String, + extras: Bundle?, + result: Result> + ) { + super.onSearch(query, extras, result) + } + + private fun getRootItems(result: Result>) { + val mediaItems: MutableList = ArrayList() + + // TODO implement this with proper texts, icons, etc + mediaItems.add( + MediaBrowserCompat.MediaItem( + MediaDescriptionCompat.Builder() + .setTitle("Library") + .setMediaId(MY_MEDIA_LIBRARY_ID) + .build(), + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ) + ) + + mediaItems.add( + MediaBrowserCompat.MediaItem( + MediaDescriptionCompat.Builder() + .setTitle("Artists") + .setMediaId(MY_MEDIA_ARTIST_ID) + .build(), + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ) + ) + + mediaItems.add( + MediaBrowserCompat.MediaItem( + MediaDescriptionCompat.Builder() + .setTitle("Albums") + .setMediaId(MY_MEDIA_ALBUM_ID) + .build(), + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ) + ) + + mediaItems.add( + MediaBrowserCompat.MediaItem( + MediaDescriptionCompat.Builder() + .setTitle("Playlists") + .setMediaId(MY_MEDIA_PLAYLIST_ID) + .build(), + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ) + ) + + result.sendResult(mediaItems) + } + + private fun getAlbumLists(result: Result>) { + val mediaItems: MutableList = ArrayList() + + val description = MediaDescriptionCompat.Builder() + .setTitle("Test") + .setMediaId(MY_MEDIA_ALBUM_ITEM + 1) + .build() + + mediaItems.add( + MediaBrowserCompat.MediaItem( + description, + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ) + ) + + result.sendResult(mediaItems) + } +} \ No newline at end of file diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/LocalMediaPlayer.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/LocalMediaPlayer.kt index 1de7bac3..005967b9 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/LocalMediaPlayer.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/LocalMediaPlayer.kt @@ -171,7 +171,7 @@ class LocalMediaPlayer( val mainHandler = Handler(context.mainLooper) val myRunnable = Runnable { - onPlayerStateChanged!!(playerState, currentPlaying) + onPlayerStateChanged?.invoke(playerState, currentPlaying) } mainHandler.post(myRunnable) } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerService.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerService.kt index 101a8f5d..493591fc 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerService.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerService.kt @@ -14,14 +14,13 @@ import android.content.Intent import android.os.Build import android.os.Bundle import android.os.IBinder -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 android.view.KeyEvent import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat -import androidx.media.MediaBrowserServiceCompat import kotlin.collections.ArrayList import org.koin.android.ext.android.inject import org.moire.ultrasonic.R @@ -37,7 +36,12 @@ import org.moire.ultrasonic.provider.UltrasonicAppWidgetProvider4X3 import org.moire.ultrasonic.provider.UltrasonicAppWidgetProvider4X4 import org.moire.ultrasonic.receiver.MediaButtonIntentReceiver import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService -import org.moire.ultrasonic.util.* +import org.moire.ultrasonic.util.Constants +import org.moire.ultrasonic.util.MediaSessionEventDistributor +import org.moire.ultrasonic.util.NowPlayingEventDistributor +import org.moire.ultrasonic.util.ShufflePlayBuffer +import org.moire.ultrasonic.util.SimpleServiceBinder +import org.moire.ultrasonic.util.Util import timber.log.Timber /** @@ -56,9 +60,10 @@ class MediaPlayerService : Service() { private val localMediaPlayer by inject() private val nowPlayingEventDistributor by inject() private val mediaPlayerLifecycleSupport by inject() + private val mediaSessionEventDistributor: MediaSessionEventDistributor by inject() private var mediaSession: MediaSessionCompat? = null - private var mediaSessionToken: MediaSessionCompat.Token? = null + var mediaSessionToken: MediaSessionCompat.Token? = null private var isInForeground = false private var notificationBuilder: NotificationCompat.Builder? = null @@ -91,6 +96,12 @@ class MediaPlayerService : Service() { localMediaPlayer.onNextSongRequested = Runnable { setNextPlaying() } + // TODO maybe MediaSession must be in an independent class after all... + // It seems this must be initialized in the stopped state too, e.g. for Android Auto. + // So it is best to init this early. + initMediaSessions() + updateMediaSession(null, PlayerState.IDLE) + // Create Notification Channel createNotificationChannel() @@ -113,6 +124,8 @@ class MediaPlayerService : Service() { localMediaPlayer.release() downloader.stop() shufflePlayBuffer.onDestroy() + + mediaSessionEventDistributor.ReleaseCachedMediaSessionToken() mediaSession?.release() mediaSession = null } catch (ignored: Throwable) { @@ -467,7 +480,7 @@ class MediaPlayerService : Service() { fun updateMediaSession(currentPlaying: DownloadFile?, playerState: PlayerState) { Timber.d("Updating the MediaSession") - if (mediaSession == null) initMediaSessions() + val playbackState = PlaybackStateCompat.Builder() // Set Metadata val metadata = MediaMetadataCompat.Builder() @@ -483,6 +496,9 @@ class MediaPlayerService : Service() { metadata.putString(MediaMetadataCompat.METADATA_KEY_ALBUM, song.album) metadata.putString(MediaMetadataCompat.METADATA_KEY_TITLE, song.title) metadata.putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, cover) + + playbackState.setActiveQueueItemId(downloader.currentPlayingIndex.toLong()) + } catch (e: Exception) { Timber.e(e, "Error setting the metadata") } @@ -492,13 +508,15 @@ class MediaPlayerService : Service() { mediaSession!!.setMetadata(metadata.build()) // Create playback State - val playbackState = PlaybackStateCompat.Builder() val state: Int val isActive: Boolean var actions: Long = PlaybackStateCompat.ACTION_PLAY_PAUSE or PlaybackStateCompat.ACTION_SKIP_TO_NEXT or - PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS + PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS or + PlaybackStateCompat.ACTION_PLAY_FROM_MEDIA_ID or + PlaybackStateCompat.ACTION_PLAY_FROM_SEARCH or + PlaybackStateCompat.ACTION_SKIP_TO_QUEUE_ITEM // Map our playerState to native PlaybackState // TODO: Synchronize these APIs @@ -534,7 +552,8 @@ class MediaPlayerService : Service() { } } - playbackState.setState(state, PlaybackStateCompat.PLAYBACK_POSITION_UNKNOWN, 1.0f) + // TODO playerPosition should be updated more frequently (currently this function is called only when the playing track changes) + playbackState.setState(state, playerPosition.toLong(), 1.0f) // Set actions playbackState.setActions(actions) @@ -545,6 +564,14 @@ class MediaPlayerService : Service() { // Set Active state mediaSession!!.isActive = isActive + // TODO Implement Now Playing queue handling properly + mediaSession!!.setQueueTitle("Now Playing") + mediaSession!!.setQueue(downloader.downloadList.mapIndexed { id, file -> + MediaSessionCompat.QueueItem(MediaDescriptionCompat.Builder() + .setTitle(file.song.title) + .build(), id.toLong()) + }) + Timber.d("Setting the MediaSession to active = %s", isActive) } @@ -795,6 +822,7 @@ class MediaPlayerService : Service() { mediaSession = MediaSessionCompat(applicationContext, "UltrasonicService") mediaSessionToken = mediaSession!!.sessionToken + mediaSessionEventDistributor.RaiseMediaSessionTokenCreatedEvent(mediaSessionToken!!) updateMediaButtonReceiver() @@ -810,35 +838,21 @@ class MediaPlayerService : Service() { Timber.v("Media Session Callback: onPlay") } -/* + override fun onPlayFromMediaId(mediaId: String?, extras: Bundle?) { super.onPlayFromMediaId(mediaId, extras) - val result = autoMediaBrowser!!.getBundleData(extras) - if (result != null) { - val mediaId = result.first - val directoryList = result.second - - resetPlayback() - val songs: MutableList = mutableListOf() - var found = false - for (item in directoryList) { - if (found || item.id == mediaId) { - found = true - songs.add(item) - } - } - downloader.download(songs, false, false, false, true) - - getPendingIntentForMediaAction( - applicationContext, - KeyEvent.KEYCODE_MEDIA_PLAY, - keycode - ).send() - } - Timber.v("Media Session Callback: onPlayFromMediaId") + Timber.d("Media Session Callback: onPlayFromMediaId") + mediaSessionEventDistributor.RaisePlayFromMediaIdRequestedEvent(mediaId, extras) } -*/ + + override fun onPlayFromSearch(query: String?, extras: Bundle?) { + super.onPlayFromSearch(query, extras) + + Timber.d("Media Session Callback: onPlayFromSearch") + mediaSessionEventDistributor.RaisePlayFromSearchRequestedEvent(query, extras) + } + override fun onPause() { super.onPause() getPendingIntentForMediaAction( @@ -886,6 +900,11 @@ class MediaPlayerService : Service() { mediaPlayerLifecycleSupport.handleKeyEvent(event) return true } + + override fun onSkipToQueueItem(id: Long) { + super.onSkipToQueueItem(id) + play(id.toInt()) + } } ) } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/MediaSessionTokenCreatedEventDistributor.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/MediaSessionTokenCreatedEventDistributor.kt new file mode 100644 index 00000000..f90470a4 --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/MediaSessionTokenCreatedEventDistributor.kt @@ -0,0 +1,49 @@ +package org.moire.ultrasonic.util + +import android.os.Bundle +import android.support.v4.media.session.MediaSessionCompat + +/** + * This class distributes MediaSession related events to its subscribers. + * It is a primitive implementation of a pub-sub event bus + */ +class MediaSessionEventDistributor { + var eventListenerList: MutableList = + listOf().toMutableList() + + var cachedToken: MediaSessionCompat.Token? = null + + fun subscribe(listener: MediaSessionEventListener) { + eventListenerList.add(listener) + + synchronized(this) { + if (cachedToken != null) + listener.onMediaSessionTokenCreated(cachedToken!!) + } + } + + fun unsubscribe(listener: MediaSessionEventListener) { + eventListenerList.remove(listener) + } + + fun ReleaseCachedMediaSessionToken() { + synchronized(this) { + cachedToken = null + } + } + + fun RaiseMediaSessionTokenCreatedEvent(token: MediaSessionCompat.Token) { + synchronized(this) { + cachedToken = token + eventListenerList.forEach { listener -> listener.onMediaSessionTokenCreated(token) } + } + } + + fun RaisePlayFromMediaIdRequestedEvent(mediaId: String?, extras: Bundle?) { + eventListenerList.forEach { listener -> listener.onPlayFromMediaIdRequested(mediaId, extras) } + } + + fun RaisePlayFromSearchRequestedEvent(query: String?, extras: Bundle?) { + eventListenerList.forEach { listener -> listener.onPlayFromSearchRequested(query, extras) } + } +} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/MediaSessionTokenCreatedEventListener.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/MediaSessionTokenCreatedEventListener.kt new file mode 100644 index 00000000..1b1c922d --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/MediaSessionTokenCreatedEventListener.kt @@ -0,0 +1,13 @@ +package org.moire.ultrasonic.util + +import android.os.Bundle +import android.support.v4.media.session.MediaSessionCompat + +/** + * Callback interface for MediaSession related event subscribers + */ +interface MediaSessionEventListener { + fun onMediaSessionTokenCreated(token: MediaSessionCompat.Token) + fun onPlayFromMediaIdRequested(mediaId: String?, extras: Bundle?) + fun onPlayFromSearchRequested(query: String?, extras: Bundle?) +}