我想定义一个 MediaLibraryService,以便 Android Auto、Exoplayer、AIMP For Android 和其他应用程序可以访问和播放我的服务管理的媒体。
我认为不需要 UI/Activity。我只是想定义一个前台服务,然后让各个前端找到它。
我想弄清楚这是否是一个合理的方法。我可以这样做吗,还是我需要制作一个完整的应用程序,其中也包含带有 MediaController/MediaBrowser 的 Activity?
不,Media3 为您提供了使该服务成为自己的独立音乐播放器所需的所有工具和 API,以便像 Android Auto 这样的控制器可以访问您的音乐并播放它,而不需要实际的活动和其他东西。这意味着您将播放列表存储在服务端,您在服务端执行所有操作,如果您愿意制作音乐播放器应用程序,则该 Activity 可以使用 Media3 提供的 API 从服务中获取信息比如
getChildren
...等等
该库的强大之处在于,您可以
sendCustomCommand
其中包含几乎所有您需要的内容,当您想要在活动和服务之间进行通信时,这将很有帮助,但在导航应用程序时完全没有必要通过 Android Auto 等控制器,而不是实际的移动应用程序。一首歌曲是由它的 MediaId 定义的,所以我相信这就是让一首歌曲可播放所需的全部,而且,服务为您提供了 applicationContext
,这样您就可以查询 MediaStore 所需的一切,并使用 Datastore 或 SharedPreferences 所需的一切.
这里有一个 MusicLibraryService 示例,您可以参考(这是一种高级情况,我使用两个单独的播放列表(一个曲目列表和一个实际播放列表,这两个列表都可以单独排队,具体取决于用户选择的播放列表)另外,它是一个 MedialibraryService,因此层次结构非常重要。最顶层的父根项是不可见的,但它对于服务的功能非常重要,因为整个层次结构从它传播,您可以在其正下方包含媒体项如果你有一个巨大的播放列表,但层次结构的级别越多,它就会变得越复杂。我建议你一步一步地使用一个根项来熟悉 MediaLibraryService,并首先直接在下面设置子项:
class MusicPlayerService : MediaLibraryService() {
lateinit var player: Player
private var session: MediaLibrarySession? = null
private val serviceIOScope = CoroutineScope(Dispatchers.IO)
private val serviceMainScope = CoroutineScope(Dispatchers.Main)
/** This is the root item that is parent to our playlist.
* It is necessary to have a parent item otherwise there is no "library" */
val rootItem = MediaItem.Builder()
.setMediaId(nodeROOT)
.setMediaMetadata(
MediaMetadata.Builder()
.setIsBrowsable(false)
.setIsPlayable(false)
.setMediaType(MediaMetadata.MEDIA_TYPE_FOLDER_MIXED)
.setTitle("MyMusicAppRootWhichIsNotVisibleToControllers")
.build()
)
.build()
val subroot_TracklistItem = MediaItem.Builder()
.setMediaId(nodeTRACKLIST)
.setMediaMetadata(
MediaMetadata.Builder()
.setIsBrowsable(true)
.setIsPlayable(false)
.setMediaType(MediaMetadata.MEDIA_TYPE_FOLDER_MIXED)
.setTitle("Tracklist")
.setArtworkUri(
Uri.parse("android.resource://mpappc/drawable/ic_tracklist")
)
.build()
)
.build()
val subroot_PlaylistItem = MediaItem.Builder()
.setMediaId(nodePLAYLIST)
.setMediaMetadata(
MediaMetadata.Builder()
.setIsBrowsable(true)
.setIsPlayable(false)
.setMediaType(MediaMetadata.MEDIA_TYPE_FOLDER_MIXED)
.setTitle("Playlist")
.setArtworkUri(
Uri.parse("android.resource://mpappc/drawable/ic_tracklist")
)
.build()
)
.build()
val rootHierarchy = listOf(subroot_TracklistItem, subroot_PlaylistItem)
var tracklist = mutableListOf<MediaItem>()
var playlist = mutableListOf<MediaItem>()
var latestSearchResults = mutableListOf<MediaItem>()
/** This will fetch music from the source folder (or the entire device if not specified) */
private fun queryMusic(initial: Boolean = false) {
val sp = PreferenceManager.getDefaultSharedPreferences(applicationContext)
val src = sp.getString("music_src_folder", "") ?: ""
serviceIOScope.launch {
scanMusic(applicationContext, uri = if (src == "") null else src.toUri()) {
tracklist.clear()
tracklist.addAll(it)
if (initial) {
serviceMainScope.launch {
player.setMediaItems(tracklist)
}
}
session?.notifyChildrenChanged(nodeTRACKLIST, tracklist.size, null)
}
}
}
override fun onCreate() {
super.onCreate()
restorePlaylist(applicationContext) {
playlist.clear()
playlist.addAll(it)
session?.notifyChildrenChanged(nodePLAYLIST, playlist.size, null)
}
/** Building ExoPlayer to use FFmpeg Audio Renderer and also enable fast-seeking */
player = ExoPlayer.Builder(applicationContext)
.setSeekParameters(SeekParameters.CLOSEST_SYNC) /* Enabling fast seeking */
.setRenderersFactory(
DefaultRenderersFactory(this).setExtensionRendererMode(
DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER /* We prefer extensions, such as FFmpeg */
)
)
.setWakeMode(C.WAKE_MODE_LOCAL) /* Prevent the service from being killed during playback */
.setHandleAudioBecomingNoisy(true) /* Prevent annoying noise when changing devices */
.setAudioAttributes(AudioAttributes.DEFAULT, true)
.build()
player.repeatMode = Player.REPEAT_MODE_ALL
//Fetching music when the service starts
queryMusic(true)
/** Listening to some player events */
player.addListener(object : Player.Listener {
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
super.onMediaItemTransition(mediaItem, reason)
//Controlling Android Auto queue here intelligently
if (mediaItem != null && player.mediaItemCount == 1) {
val playlistfootprint = mediaItem.mediaMetadata.extras?.getBoolean("isplaylist", false) == true
if (playlistfootprint && playlist.size > 1) {
val index = playlist.indexOfFirst { it.mediaId == mediaItem.mediaId }
player.setMediaItems(playlist, index, 0)
setPlaybackMode(PlayBackMode.PBM_PLAYLIST)
}
if (!playlistfootprint && tracklist.size > 1) {
val index = tracklist.indexOfFirst { it.mediaId == mediaItem.mediaId }
player.setMediaItems(tracklist, index, 0)
setPlaybackMode(PlayBackMode.PBM_TRACKLIST)
}
}
}
override fun onPlayerError(error: PlaybackException) {
error.printStackTrace()
Log.e("PLAYER", error.stackTraceToString())
}
})
/** Creating our MediaLibrarySession which is an advanced extension of a MediaSession */
session = MediaLibrarySession
.Builder(this, player, object : MediaLibrarySession.Callback {
override fun onGetItem(session: MediaLibrarySession, browser: MediaSession.ControllerInfo, mediaId: String): ListenableFuture<LibraryResult<MediaItem>> {
return super.onGetItem(session, browser, mediaId)
}
override fun onSetMediaItems(
mediaSession: MediaSession,
controller: MediaSession.ControllerInfo,
mediaItems: MutableList<MediaItem>,
startIndex: Int,
startPositionMs: Long
): ListenableFuture<MediaItemsWithStartPosition> {
val newItems = mediaItems.map {
it.buildUpon().setUri(it.mediaId).build()
}.toMutableList()
return super.onSetMediaItems(mediaSession, controller, newItems, startIndex, startPositionMs)
}
override fun onGetLibraryRoot(session: MediaLibrarySession, browser: MediaSession.ControllerInfo, params: LibraryParams?): ListenableFuture<LibraryResult<MediaItem>> {
return Futures.immediateFuture(LibraryResult.ofItem(rootItem, params))
}
override fun onGetChildren(
session: MediaLibrarySession, browser: MediaSession.ControllerInfo,
parentId: String, page: Int, pageSize: Int, params: LibraryParams?
): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> {
return Futures.immediateFuture(
LibraryResult.ofItemList(
when (parentId) {
nodeROOT -> rootHierarchy
nodeTRACKLIST -> tracklist
nodePLAYLIST -> playlist
else -> rootHierarchy
},
params
)
)
}
override fun onSubscribe(session: MediaLibrarySession, browser: MediaSession.ControllerInfo, parentId: String, params: LibraryParams?): ListenableFuture<LibraryResult<Void>> {
session.notifyChildrenChanged(
parentId,
when (parentId) {
nodeROOT -> 2
nodeTRACKLIST -> tracklist.size
nodePLAYLIST -> playlist.size
else -> 0
},
params
)
return Futures.immediateFuture(LibraryResult.ofVoid()) //super.onSubscribe(session, browser, parentId, params)
}
/** In order to end the service from our media browser side (UI side), we receive
* our own custom command (which is [CUSTOM_COM_END_SERVICE]). However, the session
* is not designed to accept foreign weird commands. So we edit the onConnect callback method
* to make sure it accepts it.
*/
override fun onConnect(session: MediaSession, controller: MediaSession.ControllerInfo): MediaSession.ConnectionResult {
val sessionComs = super.onConnect(session, controller).availableSessionCommands
.buildUpon()
.add(CUSTOM_COM_PLAY_ITEM) //Command executed when an item is requested to play
.add(CUSTOM_COM_END_SERVICE) //This one is called to end the service manually from the UI
.add(CUSTOM_COM_PLAYLIST_ADD) //Command used when adding items to playlist
.add(CUSTOM_COM_PLAYLIST_REMOVE) //Command used when removing items from playlist
.add(CUSTOM_COM_PLAYLIST_CLEAR) //Command used when clearing all items from playlist
.add(CUSTOM_COM_SCAN_MUSIC) //Command use to execute a music scan
.add(CUSTOM_COM_TRACKLIST_FORGET) //Used when an item is to be forgotten (swipe left)
.build()
val playerComs = super.onConnect(session, controller).availablePlayerCommands
return MediaSession.ConnectionResult.accept(sessionComs, playerComs)
}
/** Receiving some custom commands such as the command that ends the service.
* In order to make the player accept newly customized foreign weird commands, we have
* to edit the onConnect callback method like we did above */
override fun onCustomCommand(
session: MediaSession,
controller: MediaSession.ControllerInfo,
customCommand: SessionCommand,
args: Bundle
): ListenableFuture<SessionResult> {
/** When the controller tries to add an item to the playlist */
if (customCommand == CUSTOM_COM_PLAY_ITEM) {
args.getString("id")?.let { mediaid ->
if (args.getBoolean("playlist", false)) {
val i = playlist.indexOfFirst { it.mediaId == mediaid }
setPlaybackMode(PlayBackMode.PBM_PLAYLIST)
player.setMediaItems(playlist, i, 0)
} else {
val i = tracklist.indexOfFirst { it.mediaId == mediaid }
setPlaybackMode(PlayBackMode.PBM_TRACKLIST)
player.setMediaItems(tracklist, i, 0)
}
player.prepare()
player.play()
return Futures.immediateFuture(SessionResult(RESULT_SUCCESS))
}
}
/** When the controller (like the app) closes fully, we need to disconnect */
if (customCommand == CUSTOM_COM_END_SERVICE) {
session.release()
player.release()
[email protected]()
return Futures.immediateFuture(SessionResult(RESULT_SUCCESS))
}
/** When the user changes the source folder */
if (customCommand == CUSTOM_COM_SCAN_MUSIC) {
queryMusic()
return Futures.immediateFuture(SessionResult(RESULT_SUCCESS))
}
/** When the controller tries to add an item to the playlist */
if (customCommand == CUSTOM_COM_PLAYLIST_ADD) {
args.getString("id")?.let { mediaid ->
tracklist.firstOrNull { it.mediaId == mediaid }?.let { itemToAdd ->
itemToAdd.playlistFootprint(true)
playlist.add(itemToAdd)
serviceIOScope.launch {
/** notifying UI-end that the playlist has been modified */
[email protected]?.apply {
notifyChildrenChanged(
controller,
nodePLAYLIST,
playlist.size,
null
)
}
/** Saving the playlist to memory as it is now */
snapshotPlaylist(playlist)
}
return Futures.immediateFuture(SessionResult(RESULT_SUCCESS))
}
}
}
/** When the controller tries to remove an item from the playlist */
if (customCommand == CUSTOM_COM_PLAYLIST_REMOVE) {
args.getString("id")?.let { mediaid ->
playlist.firstOrNull { it.mediaId == mediaid }?.let { itemToRemove ->
playlist.remove(itemToRemove)
serviceIOScope.launch {
/** notifying UI-end that the playlist has been modified */
[email protected]?.apply {
notifyChildrenChanged(
controller,
nodePLAYLIST,
playlist.size,
null
)
}
/** Saving the playlist to memory as it is now */
snapshotPlaylist(playlist)
}
return Futures.immediateFuture(SessionResult(RESULT_SUCCESS))
}
}
}
/** When the controller tries to clear the playlist */
if (customCommand == CUSTOM_COM_PLAYLIST_CLEAR) {
playlist.clear()
[email protected]?.apply {
notifyChildrenChanged(
controller,
nodePLAYLIST,
0,
null
)
}
/** Saving the playlist to memory as it is now */
snapshotPlaylist(playlist)
return Futures.immediateFuture(SessionResult(RESULT_SUCCESS))
}
}
}
return super.onCustomCommand(session, controller, customCommand, args)
}
})
.build()
}
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaLibrarySession? {
return session
}
override fun onDestroy() {
snapshotPlaylist(playlist)
session?.run {
player.release()
release()
session = null
}
super.onDestroy()
}