Updated Android Auto to use MediaPlayerService separately

Added some missing features found in the docs
This commit is contained in:
Nite 2021-07-12 16:13:34 +02:00
parent db0669098c
commit 83c6b76d0a
No known key found for this signature in database
GPG Key ID: 1D1AD59B1C6386C1
7 changed files with 304 additions and 35 deletions

View File

@ -60,7 +60,7 @@
</service>
<service
android:name=".service.AutoMediaPlayerService"
android:name=".service.AutoMediaBrowserService"
android:label="Ultrasonic Auto Media Player Service"
android:exported="true">

View File

@ -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() }
}

View File

@ -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<MutableList<MediaBrowserCompat.MediaItem>>
) {
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<MutableList<MediaBrowserCompat.MediaItem>>
) {
super.onSearch(query, extras, result)
}
private fun getRootItems(result: Result<MutableList<MediaBrowserCompat.MediaItem>>) {
val mediaItems: MutableList<MediaBrowserCompat.MediaItem> = 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<MutableList<MediaBrowserCompat.MediaItem>>) {
val mediaItems: MutableList<MediaBrowserCompat.MediaItem> = 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)
}
}

View File

@ -171,7 +171,7 @@ class LocalMediaPlayer(
val mainHandler = Handler(context.mainLooper)
val myRunnable = Runnable {
onPlayerStateChanged!!(playerState, currentPlaying)
onPlayerStateChanged?.invoke(playerState, currentPlaying)
}
mainHandler.post(myRunnable)
}

View File

@ -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<LocalMediaPlayer>()
private val nowPlayingEventDistributor by inject<NowPlayingEventDistributor>()
private val mediaPlayerLifecycleSupport by inject<MediaPlayerLifecycleSupport>()
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<MusicDirectory.Entry> = 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())
}
}
)
}

View File

@ -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<MediaSessionEventListener> =
listOf<MediaSessionEventListener>().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) }
}
}

View File

@ -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?)
}