playback: port basic media descriptions
This commit is contained in:
parent
e43f55bc78
commit
69070e7b13
9 changed files with 656 additions and 1041 deletions
|
@ -24,6 +24,7 @@ import android.content.Intent
|
|||
import android.os.Bundle
|
||||
import android.os.IBinder
|
||||
import android.support.v4.media.MediaBrowserCompat
|
||||
import android.support.v4.media.MediaBrowserCompat.MediaItem
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.core.app.NotificationChannelCompat
|
||||
import androidx.core.app.NotificationCompat
|
||||
|
@ -81,31 +82,29 @@ class AuxioService : MediaBrowserServiceCompat(), ForegroundListener {
|
|||
clientPackageName: String,
|
||||
clientUid: Int,
|
||||
rootHints: Bundle?
|
||||
): BrowserRoot? {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
): BrowserRoot? = null
|
||||
|
||||
override fun onLoadChildren(
|
||||
parentId: String,
|
||||
result: Result<MutableList<MediaBrowserCompat.MediaItem>>
|
||||
result: Result<MutableList<MediaItem>>
|
||||
) = throw NotImplementedError()
|
||||
|
||||
override fun onLoadChildren(
|
||||
parentId: String,
|
||||
result: Result<MutableList<MediaBrowserCompat.MediaItem>>,
|
||||
result: Result<MutableList<MediaItem>>,
|
||||
options: Bundle
|
||||
) {
|
||||
super.onLoadChildren(parentId, result, options)
|
||||
}
|
||||
|
||||
override fun onLoadItem(itemId: String, result: Result<MediaBrowserCompat.MediaItem>) {
|
||||
override fun onLoadItem(itemId: String, result: Result<MediaItem>) {
|
||||
super.onLoadItem(itemId, result)
|
||||
}
|
||||
|
||||
override fun onSearch(
|
||||
query: String,
|
||||
extras: Bundle?,
|
||||
result: Result<MutableList<MediaBrowserCompat.MediaItem>>
|
||||
result: Result<MutableList<MediaItem>>
|
||||
) {
|
||||
super.onSearch(query, extras, result)
|
||||
}
|
||||
|
|
|
@ -1,374 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2024 Auxio Project
|
||||
* MediaItemBrowser.kt is part of Auxio.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.music.service
|
||||
//
|
||||
// import android.content.Context
|
||||
// import android.os.Bundle
|
||||
// import androidx.annotation.StringRes
|
||||
// import androidx.media.utils.MediaConstants
|
||||
// import androidx.media3.common.MediaItem
|
||||
// import androidx.media3.session.MediaSession.ControllerInfo
|
||||
// import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
// import javax.inject.Inject
|
||||
// import kotlin.math.min
|
||||
// import kotlinx.coroutines.CoroutineScope
|
||||
// import kotlinx.coroutines.Deferred
|
||||
// import kotlinx.coroutines.Dispatchers
|
||||
// import kotlinx.coroutines.Job
|
||||
// import kotlinx.coroutines.async
|
||||
// import org.oxycblt.auxio.R
|
||||
// import org.oxycblt.auxio.list.ListSettings
|
||||
// import org.oxycblt.auxio.list.sort.Sort
|
||||
// import org.oxycblt.auxio.music.Album
|
||||
// import org.oxycblt.auxio.music.Artist
|
||||
// import org.oxycblt.auxio.music.Genre
|
||||
// import org.oxycblt.auxio.music.Music
|
||||
// import org.oxycblt.auxio.music.MusicRepository
|
||||
// import org.oxycblt.auxio.music.Playlist
|
||||
// import org.oxycblt.auxio.music.Song
|
||||
// import org.oxycblt.auxio.music.device.DeviceLibrary
|
||||
// import org.oxycblt.auxio.music.user.UserLibrary
|
||||
// import org.oxycblt.auxio.search.SearchEngine
|
||||
//
|
||||
// class MediaItemBrowser
|
||||
// @Inject
|
||||
// constructor(
|
||||
// @ApplicationContext private val context: Context,
|
||||
// private val musicRepository: MusicRepository,
|
||||
// private val listSettings: ListSettings,
|
||||
// private val searchEngine: SearchEngine
|
||||
// ) : MusicRepository.UpdateListener {
|
||||
// private val browserJob = Job()
|
||||
// private val searchScope = CoroutineScope(browserJob + Dispatchers.Default)
|
||||
// private val searchSubscribers = mutableMapOf<ControllerInfo, String>()
|
||||
// private val searchResults = mutableMapOf<String, Deferred<SearchEngine.Items>>()
|
||||
// private var invalidator: Invalidator? = null
|
||||
//
|
||||
// interface Invalidator {
|
||||
// fun invalidate(ids: Map<String, Int>)
|
||||
//
|
||||
// fun invalidate(controller: ControllerInfo, query: String, itemCount: Int)
|
||||
// }
|
||||
//
|
||||
// fun attach(invalidator: Invalidator) {
|
||||
// this.invalidator = invalidator
|
||||
// musicRepository.addUpdateListener(this)
|
||||
// }
|
||||
//
|
||||
// fun release() {
|
||||
// browserJob.cancel()
|
||||
// invalidator = null
|
||||
// musicRepository.removeUpdateListener(this)
|
||||
// }
|
||||
//
|
||||
// override fun onMusicChanges(changes: MusicRepository.Changes) {
|
||||
// val deviceLibrary = musicRepository.deviceLibrary
|
||||
// var invalidateSearch = false
|
||||
// val invalidate = mutableMapOf<String, Int>()
|
||||
// if (changes.deviceLibrary && deviceLibrary != null) {
|
||||
// MediaSessionUID.Category.DEVICE_MUSIC.forEach {
|
||||
// invalidate[it.toString()] = getCategorySize(it, musicRepository)
|
||||
// }
|
||||
//
|
||||
// deviceLibrary.albums.forEach {
|
||||
// val id = MediaSessionUID.Single(it.uid).toString()
|
||||
// invalidate[id] = it.songs.size
|
||||
// }
|
||||
//
|
||||
// deviceLibrary.artists.forEach {
|
||||
// val id = MediaSessionUID.Single(it.uid).toString()
|
||||
// invalidate[id] = it.songs.size + it.explicitAlbums.size + it.implicitAlbums.size
|
||||
// }
|
||||
//
|
||||
// deviceLibrary.genres.forEach {
|
||||
// val id = MediaSessionUID.Single(it.uid).toString()
|
||||
// invalidate[id] = it.songs.size + it.artists.size
|
||||
// }
|
||||
//
|
||||
// invalidateSearch = true
|
||||
// }
|
||||
// val userLibrary = musicRepository.userLibrary
|
||||
// if (changes.userLibrary && userLibrary != null) {
|
||||
// MediaSessionUID.Category.USER_MUSIC.forEach {
|
||||
// invalidate[it.toString()] = getCategorySize(it, musicRepository)
|
||||
// }
|
||||
// userLibrary.playlists.forEach {
|
||||
// val id = MediaSessionUID.Single(it.uid).toString()
|
||||
// invalidate[id] = it.songs.size
|
||||
// }
|
||||
// invalidateSearch = true
|
||||
// }
|
||||
//
|
||||
// if (invalidate.isNotEmpty()) {
|
||||
// invalidator?.invalidate(invalidate)
|
||||
// }
|
||||
//
|
||||
// if (invalidateSearch) {
|
||||
// for (entry in searchResults.entries) {
|
||||
// searchResults[entry.key]?.cancel()
|
||||
// }
|
||||
// searchResults.clear()
|
||||
//
|
||||
// for (entry in searchSubscribers.entries) {
|
||||
// if (searchResults[entry.value] != null) {
|
||||
// continue
|
||||
// }
|
||||
// searchResults[entry.value] = searchTo(entry.value)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// val root: MediaItem
|
||||
// get() = MediaSessionUID.Category.ROOT.toMediaItem(context)
|
||||
//
|
||||
// fun getItem(mediaId: String): MediaItem? {
|
||||
// val music =
|
||||
// when (val uid = MediaSessionUID.fromString(mediaId)) {
|
||||
// is MediaSessionUID.Category -> return uid.toMediaItem(context)
|
||||
// is MediaSessionUID.Single ->
|
||||
// musicRepository.find(uid.uid)?.let { musicRepository.find(it.uid) }
|
||||
// is MediaSessionUID.Joined ->
|
||||
// musicRepository.find(uid.childUid)?.let { musicRepository.find(it.uid) }
|
||||
// null -> null
|
||||
// }
|
||||
// ?: return null
|
||||
//
|
||||
// return when (music) {
|
||||
// is Album -> music.toMediaItem(context)
|
||||
// is Artist -> music.toMediaItem(context)
|
||||
// is Genre -> music.toMediaItem(context)
|
||||
// is Playlist -> music.toMediaItem(context)
|
||||
// is Song -> music.toMediaItem(context, null)
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// fun getChildren(parentId: String, page: Int, pageSize: Int): List<MediaItem>? {
|
||||
// val deviceLibrary = musicRepository.deviceLibrary
|
||||
// val userLibrary = musicRepository.userLibrary
|
||||
// if (deviceLibrary == null || userLibrary == null) {
|
||||
// return listOf()
|
||||
// }
|
||||
//
|
||||
// val items = getMediaItemList(parentId, deviceLibrary, userLibrary) ?: return null
|
||||
// return items.paginate(page, pageSize)
|
||||
// }
|
||||
//
|
||||
// private fun getMediaItemList(
|
||||
// id: String,
|
||||
// deviceLibrary: DeviceLibrary,
|
||||
// userLibrary: UserLibrary
|
||||
// ): List<MediaItem>? {
|
||||
// return when (val mediaSessionUID = MediaSessionUID.fromString(id)) {
|
||||
// is MediaSessionUID.Category -> {
|
||||
// when (mediaSessionUID) {
|
||||
// MediaSessionUID.Category.ROOT ->
|
||||
// MediaSessionUID.Category.IMPORTANT.map { it.toMediaItem(context) }
|
||||
// MediaSessionUID.Category.SONGS ->
|
||||
// listSettings.songSort.songs(deviceLibrary.songs).map {
|
||||
// it.toMediaItem(context, null)
|
||||
// }
|
||||
// MediaSessionUID.Category.ALBUMS ->
|
||||
// listSettings.albumSort.albums(deviceLibrary.albums).map {
|
||||
// it.toMediaItem(context)
|
||||
// }
|
||||
// MediaSessionUID.Category.ARTISTS ->
|
||||
// listSettings.artistSort.artists(deviceLibrary.artists).map {
|
||||
// it.toMediaItem(context)
|
||||
// }
|
||||
// MediaSessionUID.Category.GENRES ->
|
||||
// listSettings.genreSort.genres(deviceLibrary.genres).map {
|
||||
// it.toMediaItem(context)
|
||||
// }
|
||||
// MediaSessionUID.Category.PLAYLISTS ->
|
||||
// userLibrary.playlists.map { it.toMediaItem(context) }
|
||||
// }
|
||||
// }
|
||||
// is MediaSessionUID.Single -> {
|
||||
// getChildMediaItems(mediaSessionUID.uid)
|
||||
// }
|
||||
// is MediaSessionUID.Joined -> {
|
||||
// getChildMediaItems(mediaSessionUID.childUid)
|
||||
// }
|
||||
// null -> {
|
||||
// return null
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// private fun getChildMediaItems(uid: Music.UID): List<MediaItem>? {
|
||||
// return when (val item = musicRepository.find(uid)) {
|
||||
// is Album -> {
|
||||
// val songs = listSettings.albumSongSort.songs(item.songs)
|
||||
// songs.map { it.toMediaItem(context, item).withHeader(R.string.lbl_songs) }
|
||||
// }
|
||||
// is Artist -> {
|
||||
// val albums = ARTIST_ALBUMS_SORT.albums(item.explicitAlbums + item.implicitAlbums)
|
||||
// val songs = listSettings.artistSongSort.songs(item.songs)
|
||||
// albums.map { it.toMediaItem(context).withHeader(R.string.lbl_albums) } +
|
||||
// songs.map { it.toMediaItem(context, item).withHeader(R.string.lbl_songs) }
|
||||
// }
|
||||
// is Genre -> {
|
||||
// val artists = GENRE_ARTISTS_SORT.artists(item.artists)
|
||||
// val songs = listSettings.genreSongSort.songs(item.songs)
|
||||
// artists.map { it.toMediaItem(context).withHeader(R.string.lbl_artists) } +
|
||||
// songs.map { it.toMediaItem(context, null).withHeader(R.string.lbl_songs) }
|
||||
// }
|
||||
// is Playlist -> {
|
||||
// item.songs.map { it.toMediaItem(context, item).withHeader(R.string.lbl_songs) }
|
||||
// }
|
||||
// is Song,
|
||||
// null -> return null
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// private fun MediaItem.withHeader(@StringRes res: Int): MediaItem {
|
||||
// val oldExtras = mediaMetadata.extras ?: Bundle()
|
||||
// val newExtras =
|
||||
// Bundle(oldExtras).apply {
|
||||
// putString(
|
||||
// MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE,
|
||||
// context.getString(res))
|
||||
// }
|
||||
// return buildUpon()
|
||||
// .setMediaMetadata(mediaMetadata.buildUpon().setExtras(newExtras).build())
|
||||
// .build()
|
||||
// }
|
||||
//
|
||||
// private fun getCategorySize(
|
||||
// category: MediaSessionUID.Category,
|
||||
// musicRepository: MusicRepository
|
||||
// ): Int {
|
||||
// val deviceLibrary = musicRepository.deviceLibrary ?: return 0
|
||||
// val userLibrary = musicRepository.userLibrary ?: return 0
|
||||
// return when (category) {
|
||||
// MediaSessionUID.Category.ROOT -> MediaSessionUID.Category.IMPORTANT.size
|
||||
// MediaSessionUID.Category.SONGS -> deviceLibrary.songs.size
|
||||
// MediaSessionUID.Category.ALBUMS -> deviceLibrary.albums.size
|
||||
// MediaSessionUID.Category.ARTISTS -> deviceLibrary.artists.size
|
||||
// MediaSessionUID.Category.GENRES -> deviceLibrary.genres.size
|
||||
// MediaSessionUID.Category.PLAYLISTS -> userLibrary.playlists.size
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// suspend fun prepareSearch(query: String, controller: ControllerInfo) {
|
||||
// searchSubscribers[controller] = query
|
||||
// val existing = searchResults[query]
|
||||
// if (existing == null) {
|
||||
// val new = searchTo(query)
|
||||
// searchResults[query] = new
|
||||
// new.await()
|
||||
// } else {
|
||||
// val items = existing.await()
|
||||
// invalidator?.invalidate(controller, query, items.count())
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// suspend fun getSearchResult(
|
||||
// query: String,
|
||||
// page: Int,
|
||||
// pageSize: Int,
|
||||
// ): List<MediaItem>? {
|
||||
// val deferred = searchResults[query] ?: searchTo(query).also { searchResults[query] = it }
|
||||
// return deferred.await().concat().paginate(page, pageSize)
|
||||
// }
|
||||
//
|
||||
// private fun SearchEngine.Items.concat(): MutableList<MediaItem> {
|
||||
// val music = mutableListOf<MediaItem>()
|
||||
// if (songs != null) {
|
||||
// music.addAll(songs.map { it.toMediaItem(context, null) })
|
||||
// }
|
||||
// if (albums != null) {
|
||||
// music.addAll(albums.map { it.toMediaItem(context) })
|
||||
// }
|
||||
// if (artists != null) {
|
||||
// music.addAll(artists.map { it.toMediaItem(context) })
|
||||
// }
|
||||
// if (genres != null) {
|
||||
// music.addAll(genres.map { it.toMediaItem(context) })
|
||||
// }
|
||||
// if (playlists != null) {
|
||||
// music.addAll(playlists.map { it.toMediaItem(context) })
|
||||
// }
|
||||
// return music
|
||||
// }
|
||||
//
|
||||
// private fun SearchEngine.Items.count(): Int {
|
||||
// var count = 0
|
||||
// if (songs != null) {
|
||||
// count += songs.size
|
||||
// }
|
||||
// if (albums != null) {
|
||||
// count += albums.size
|
||||
// }
|
||||
// if (artists != null) {
|
||||
// count += artists.size
|
||||
// }
|
||||
// if (genres != null) {
|
||||
// count += genres.size
|
||||
// }
|
||||
// if (playlists != null) {
|
||||
// count += playlists.size
|
||||
// }
|
||||
// return count
|
||||
// }
|
||||
//
|
||||
// private fun searchTo(query: String) =
|
||||
// searchScope.async {
|
||||
// if (query.isEmpty()) {
|
||||
// return@async SearchEngine.Items()
|
||||
// }
|
||||
// val deviceLibrary = musicRepository.deviceLibrary ?: return@async SearchEngine.Items()
|
||||
// val userLibrary = musicRepository.userLibrary ?: return@async SearchEngine.Items()
|
||||
// val items =
|
||||
// SearchEngine.Items(
|
||||
// deviceLibrary.songs,
|
||||
// deviceLibrary.albums,
|
||||
// deviceLibrary.artists,
|
||||
// deviceLibrary.genres,
|
||||
// userLibrary.playlists)
|
||||
// val results = searchEngine.search(items, query)
|
||||
// for (entry in searchSubscribers.entries) {
|
||||
// if (entry.value == query) {
|
||||
// invalidator?.invalidate(entry.key, query, results.count())
|
||||
// }
|
||||
// }
|
||||
// results
|
||||
// }
|
||||
//
|
||||
// private fun List<MediaItem>.paginate(page: Int, pageSize: Int): List<MediaItem>? {
|
||||
// if (page == Int.MAX_VALUE) {
|
||||
// // I think if someone requests this page it more or less implies that I should
|
||||
// // return all of the pages.
|
||||
// return this
|
||||
// }
|
||||
// val start = page * pageSize
|
||||
// val end = min((page + 1) * pageSize, size) // Tolerate partial page queries
|
||||
// if (pageSize == 0 || start !in indices) {
|
||||
// // These pages are probably invalid. Hopefully this won't backfire.
|
||||
// return null
|
||||
// }
|
||||
// return subList(start, end).toMutableList()
|
||||
// }
|
||||
//
|
||||
// private companion object {
|
||||
// // TODO: Rely on detail item gen logic?
|
||||
// val ARTIST_ALBUMS_SORT = Sort(Sort.Mode.ByDate, Sort.Direction.DESCENDING)
|
||||
// val GENRE_ARTISTS_SORT = Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING)
|
||||
// }
|
||||
// }
|
|
@ -22,12 +22,13 @@ import android.content.Context
|
|||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.os.Bundle
|
||||
import android.support.v4.media.MediaBrowserCompat.MediaItem
|
||||
import android.support.v4.media.MediaDescriptionCompat
|
||||
import android.support.v4.media.MediaMetadataCompat
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.media.utils.MediaConstants
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.MediaMetadata
|
||||
import java.io.ByteArrayOutputStream
|
||||
import org.oxycblt.auxio.BuildConfig
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.music.Album
|
||||
|
@ -37,239 +38,37 @@ import org.oxycblt.auxio.music.Music
|
|||
import org.oxycblt.auxio.music.MusicParent
|
||||
import org.oxycblt.auxio.music.Playlist
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.music.device.DeviceLibrary
|
||||
import org.oxycblt.auxio.music.resolveNames
|
||||
import org.oxycblt.auxio.util.getPlural
|
||||
import java.io.ByteArrayOutputStream
|
||||
import kotlin.math.ceil
|
||||
|
||||
fun MediaSessionUID.Category.toMediaItem(context: Context): MediaItem {
|
||||
// TODO: Make custom overflow menu for compat
|
||||
val style =
|
||||
Bundle().apply {
|
||||
putInt(
|
||||
MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_SINGLE_ITEM,
|
||||
MediaConstants.DESCRIPTION_EXTRAS_VALUE_CONTENT_STYLE_CATEGORY_LIST_ITEM)
|
||||
}
|
||||
val metadata =
|
||||
MediaMetadata.Builder()
|
||||
.setTitle(context.getString(nameRes))
|
||||
.setIsPlayable(false)
|
||||
.setIsBrowsable(true)
|
||||
.setMediaType(mediaType)
|
||||
.setExtras(style)
|
||||
if (bitmapRes != null) {
|
||||
val data = ByteArrayOutputStream()
|
||||
BitmapFactory.decodeResource(context.resources, bitmapRes)
|
||||
.compress(Bitmap.CompressFormat.PNG, 100, data)
|
||||
metadata.setArtworkData(data.toByteArray(), MediaMetadata.PICTURE_TYPE_FILE_ICON)
|
||||
}
|
||||
return MediaItem.Builder().setMediaId(toString()).setMediaMetadata(metadata.build()).build()
|
||||
}
|
||||
enum class Category(val id: String, @StringRes val nameRes: Int, @DrawableRes val bitmapRes: Int?) {
|
||||
ROOT("root", R.string.info_app_name, null),
|
||||
MORE("more", R.string.lbl_more, R.drawable.ic_more_24),
|
||||
SONGS("songs", R.string.lbl_songs, R.drawable.ic_song_bitmap_24),
|
||||
ALBUMS("albums", R.string.lbl_albums, R.drawable.ic_album_bitmap_24),
|
||||
ARTISTS("artists", R.string.lbl_artists, R.drawable.ic_artist_bitmap_24),
|
||||
GENRES("genres", R.string.lbl_genres, R.drawable.ic_genre_bitmap_24),
|
||||
PLAYLISTS("playlists", R.string.lbl_playlists, R.drawable.ic_playlist_bitmap_24);
|
||||
|
||||
fun Song.toMediaItem(context: Context, parent: MusicParent?): MediaItem {
|
||||
val mediaSessionUID =
|
||||
if (parent == null) {
|
||||
MediaSessionUID.Single(uid)
|
||||
} else {
|
||||
MediaSessionUID.Joined(parent.uid, uid)
|
||||
}
|
||||
val metadata =
|
||||
MediaMetadata.Builder()
|
||||
.setTitle(name.resolve(context))
|
||||
.setArtist(artists.resolveNames(context))
|
||||
.setAlbumTitle(album.name.resolve(context))
|
||||
.setAlbumArtist(album.artists.resolveNames(context))
|
||||
.setTrackNumber(track)
|
||||
.setDiscNumber(disc?.number)
|
||||
.setGenre(genres.resolveNames(context))
|
||||
.setDisplayTitle(name.resolve(context))
|
||||
.setSubtitle(artists.resolveNames(context))
|
||||
.setRecordingYear(album.dates?.min?.year)
|
||||
.setRecordingMonth(album.dates?.min?.month)
|
||||
.setRecordingDay(album.dates?.min?.day)
|
||||
.setReleaseYear(album.dates?.min?.year)
|
||||
.setReleaseMonth(album.dates?.min?.month)
|
||||
.setReleaseDay(album.dates?.min?.day)
|
||||
.setMediaType(MediaMetadata.MEDIA_TYPE_MUSIC)
|
||||
.setIsPlayable(true)
|
||||
.setIsBrowsable(false)
|
||||
.setArtworkUri(cover.mediaStoreCoverUri)
|
||||
.setExtras(
|
||||
Bundle().apply {
|
||||
putString("uid", mediaSessionUID.toString())
|
||||
putLong("durationMs", durationMs)
|
||||
})
|
||||
.build()
|
||||
return MediaItem.Builder()
|
||||
.setUri(uri)
|
||||
.setMediaId(mediaSessionUID.toString())
|
||||
.setMediaMetadata(metadata)
|
||||
.build()
|
||||
}
|
||||
|
||||
fun Album.toMediaItem(context: Context): MediaItem {
|
||||
val mediaSessionUID = MediaSessionUID.Single(uid)
|
||||
val metadata =
|
||||
MediaMetadata.Builder()
|
||||
.setTitle(name.resolve(context))
|
||||
.setArtist(artists.resolveNames(context))
|
||||
.setAlbumTitle(name.resolve(context))
|
||||
.setAlbumArtist(artists.resolveNames(context))
|
||||
.setRecordingYear(dates?.min?.year)
|
||||
.setRecordingMonth(dates?.min?.month)
|
||||
.setRecordingDay(dates?.min?.day)
|
||||
.setReleaseYear(dates?.min?.year)
|
||||
.setReleaseMonth(dates?.min?.month)
|
||||
.setReleaseDay(dates?.min?.day)
|
||||
.setMediaType(MediaMetadata.MEDIA_TYPE_ALBUM)
|
||||
.setIsPlayable(false)
|
||||
.setIsBrowsable(true)
|
||||
.setArtworkUri(cover.single.mediaStoreCoverUri)
|
||||
.setExtras(Bundle().apply { putString("uid", mediaSessionUID.toString()) })
|
||||
.build()
|
||||
return MediaItem.Builder()
|
||||
.setMediaId(mediaSessionUID.toString())
|
||||
.setMediaMetadata(metadata)
|
||||
.build()
|
||||
}
|
||||
|
||||
fun Artist.toMediaItem(context: Context): MediaItem {
|
||||
val mediaSessionUID = MediaSessionUID.Single(uid)
|
||||
val metadata =
|
||||
MediaMetadata.Builder()
|
||||
.setTitle(name.resolve(context))
|
||||
.setSubtitle(
|
||||
context.getString(
|
||||
R.string.fmt_two,
|
||||
if (explicitAlbums.isNotEmpty()) {
|
||||
context.getPlural(R.plurals.fmt_album_count, explicitAlbums.size)
|
||||
} else {
|
||||
context.getString(R.string.def_album_count)
|
||||
},
|
||||
if (songs.isNotEmpty()) {
|
||||
context.getPlural(R.plurals.fmt_song_count, songs.size)
|
||||
} else {
|
||||
context.getString(R.string.def_song_count)
|
||||
}))
|
||||
.setMediaType(MediaMetadata.MEDIA_TYPE_ARTIST)
|
||||
.setIsPlayable(false)
|
||||
.setIsBrowsable(true)
|
||||
.setGenre(genres.resolveNames(context))
|
||||
.setArtworkUri(cover.single.mediaStoreCoverUri)
|
||||
.setExtras(Bundle().apply { putString("uid", mediaSessionUID.toString()) })
|
||||
.build()
|
||||
return MediaItem.Builder()
|
||||
.setMediaId(mediaSessionUID.toString())
|
||||
.setMediaMetadata(metadata)
|
||||
.build()
|
||||
}
|
||||
|
||||
fun Genre.toMediaItem(context: Context): MediaItem {
|
||||
val mediaSessionUID = MediaSessionUID.Single(uid)
|
||||
val metadata =
|
||||
MediaMetadata.Builder()
|
||||
.setTitle(name.resolve(context))
|
||||
.setSubtitle(
|
||||
if (songs.isNotEmpty()) {
|
||||
context.getPlural(R.plurals.fmt_song_count, songs.size)
|
||||
} else {
|
||||
context.getString(R.string.def_song_count)
|
||||
})
|
||||
.setMediaType(MediaMetadata.MEDIA_TYPE_GENRE)
|
||||
.setIsPlayable(false)
|
||||
.setIsBrowsable(true)
|
||||
.setArtworkUri(cover.single.mediaStoreCoverUri)
|
||||
.setExtras(Bundle().apply { putString("uid", mediaSessionUID.toString()) })
|
||||
.build()
|
||||
return MediaItem.Builder()
|
||||
.setMediaId(mediaSessionUID.toString())
|
||||
.setMediaMetadata(metadata)
|
||||
.build()
|
||||
}
|
||||
|
||||
fun Playlist.toMediaItem(context: Context): MediaItem {
|
||||
val mediaSessionUID = MediaSessionUID.Single(uid)
|
||||
val metadata =
|
||||
MediaMetadata.Builder()
|
||||
.setTitle(name.resolve(context))
|
||||
.setSubtitle(
|
||||
if (songs.isNotEmpty()) {
|
||||
context.getPlural(R.plurals.fmt_song_count, songs.size)
|
||||
} else {
|
||||
context.getString(R.string.def_song_count)
|
||||
})
|
||||
.setMediaType(MediaMetadata.MEDIA_TYPE_PLAYLIST)
|
||||
.setIsPlayable(false)
|
||||
.setIsBrowsable(true)
|
||||
.setArtworkUri(cover?.single?.mediaStoreCoverUri)
|
||||
.setExtras(Bundle().apply { putString("uid", mediaSessionUID.toString()) })
|
||||
.build()
|
||||
return MediaItem.Builder()
|
||||
.setMediaId(mediaSessionUID.toString())
|
||||
.setMediaMetadata(metadata)
|
||||
.build()
|
||||
}
|
||||
|
||||
fun MediaItem.toSong(deviceLibrary: DeviceLibrary): Song? {
|
||||
val uid = MediaSessionUID.fromString(mediaId) ?: return null
|
||||
return when (uid) {
|
||||
is MediaSessionUID.Single -> {
|
||||
deviceLibrary.findSong(uid.uid)
|
||||
}
|
||||
is MediaSessionUID.Joined -> {
|
||||
deviceLibrary.findSong(uid.childUid)
|
||||
}
|
||||
is MediaSessionUID.Category -> null
|
||||
companion object {
|
||||
val DEVICE_MUSIC = listOf(ROOT, SONGS, ALBUMS, ARTISTS, GENRES)
|
||||
val USER_MUSIC = listOf(ROOT, PLAYLISTS)
|
||||
val IMPORTANT = listOf(SONGS, ALBUMS, ARTISTS, GENRES, PLAYLISTS)
|
||||
}
|
||||
}
|
||||
|
||||
sealed interface MediaSessionUID {
|
||||
enum class Category(
|
||||
val id: String,
|
||||
@StringRes val nameRes: Int,
|
||||
@DrawableRes val bitmapRes: Int?,
|
||||
val mediaType: Int?
|
||||
) : MediaSessionUID {
|
||||
ROOT("root", R.string.info_app_name, null, null),
|
||||
SONGS(
|
||||
"songs",
|
||||
R.string.lbl_songs,
|
||||
R.drawable.ic_song_bitmap_24,
|
||||
MediaMetadata.MEDIA_TYPE_MUSIC),
|
||||
ALBUMS(
|
||||
"albums",
|
||||
R.string.lbl_albums,
|
||||
R.drawable.ic_album_bitmap_24,
|
||||
MediaMetadata.MEDIA_TYPE_FOLDER_ALBUMS),
|
||||
ARTISTS(
|
||||
"artists",
|
||||
R.string.lbl_artists,
|
||||
R.drawable.ic_artist_bitmap_24,
|
||||
MediaMetadata.MEDIA_TYPE_FOLDER_ARTISTS),
|
||||
GENRES(
|
||||
"genres",
|
||||
R.string.lbl_genres,
|
||||
R.drawable.ic_genre_bitmap_24,
|
||||
MediaMetadata.MEDIA_TYPE_FOLDER_GENRES),
|
||||
PLAYLISTS(
|
||||
"playlists",
|
||||
R.string.lbl_playlists,
|
||||
R.drawable.ic_playlist_bitmap_24,
|
||||
MediaMetadata.MEDIA_TYPE_FOLDER_PLAYLISTS);
|
||||
|
||||
override fun toString() = "$ID_CATEGORY:$id"
|
||||
|
||||
companion object {
|
||||
val DEVICE_MUSIC = listOf(ROOT, SONGS, ALBUMS, ARTISTS, GENRES)
|
||||
val USER_MUSIC = listOf(ROOT, PLAYLISTS)
|
||||
val IMPORTANT = listOf(SONGS, ALBUMS, ARTISTS, GENRES, PLAYLISTS)
|
||||
}
|
||||
data class CategoryItem(val category: Category) : MediaSessionUID {
|
||||
override fun toString() = "$ID_CATEGORY:$category"
|
||||
}
|
||||
|
||||
data class Single(val uid: Music.UID) : MediaSessionUID {
|
||||
data class SingleItem(val uid: Music.UID) : MediaSessionUID {
|
||||
override fun toString() = "$ID_ITEM:$uid"
|
||||
}
|
||||
|
||||
data class Joined(val parentUid: Music.UID, val childUid: Music.UID) : MediaSessionUID {
|
||||
data class ChildItem(val parentUid: Music.UID, val childUid: Music.UID) : MediaSessionUID {
|
||||
override fun toString() = "$ID_ITEM:$parentUid>$childUid"
|
||||
}
|
||||
|
||||
|
@ -284,22 +83,23 @@ sealed interface MediaSessionUID {
|
|||
}
|
||||
return when (parts[0]) {
|
||||
ID_CATEGORY ->
|
||||
when (parts[1]) {
|
||||
CategoryItem(when (parts[1]) {
|
||||
Category.ROOT.id -> Category.ROOT
|
||||
Category.MORE.id -> Category.MORE
|
||||
Category.SONGS.id -> Category.SONGS
|
||||
Category.ALBUMS.id -> Category.ALBUMS
|
||||
Category.ARTISTS.id -> Category.ARTISTS
|
||||
Category.GENRES.id -> Category.GENRES
|
||||
Category.PLAYLISTS.id -> Category.PLAYLISTS
|
||||
else -> null
|
||||
}
|
||||
else -> return null
|
||||
})
|
||||
ID_ITEM -> {
|
||||
val uids = parts[1].split(">", limit = 2)
|
||||
if (uids.size == 1) {
|
||||
Music.UID.fromString(uids[0])?.let { Single(it) }
|
||||
Music.UID.fromString(uids[0])?.let { SingleItem(it) }
|
||||
} else {
|
||||
Music.UID.fromString(uids[0])?.let { parent ->
|
||||
Music.UID.fromString(uids[1])?.let { child -> Joined(parent, child) }
|
||||
Music.UID.fromString(uids[1])?.let { child -> ChildItem(parent, child) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -308,3 +108,108 @@ sealed interface MediaSessionUID {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun Category.toMediaItem(context: Context): MediaItem {
|
||||
// TODO: Make custom overflow menu for compat
|
||||
val style =
|
||||
Bundle().apply {
|
||||
putInt(
|
||||
MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_SINGLE_ITEM,
|
||||
MediaConstants.DESCRIPTION_EXTRAS_VALUE_CONTENT_STYLE_CATEGORY_LIST_ITEM)
|
||||
}
|
||||
val mediaSessionUID = MediaSessionUID.CategoryItem(this)
|
||||
val description = MediaDescriptionCompat.Builder()
|
||||
.setMediaId(mediaSessionUID.toString())
|
||||
.setTitle(context.getString(nameRes))
|
||||
if (bitmapRes != null) {
|
||||
val bitmap = BitmapFactory.decodeResource(context.resources, bitmapRes)
|
||||
description.setIconBitmap(bitmap)
|
||||
}
|
||||
return MediaItem(description.build(), MediaItem.FLAG_BROWSABLE)
|
||||
}
|
||||
fun Song.toMediaItem(context: Context, parent: MusicParent?): MediaItem {
|
||||
val mediaSessionUID =
|
||||
if (parent == null) {
|
||||
MediaSessionUID.SingleItem(uid)
|
||||
} else {
|
||||
MediaSessionUID.ChildItem(parent.uid, uid)
|
||||
}
|
||||
val description = MediaDescriptionCompat.Builder()
|
||||
.setMediaId(mediaSessionUID.toString())
|
||||
.setTitle(name.resolve(context))
|
||||
.setSubtitle(artists.resolveNames(context))
|
||||
.setDescription(album.name.resolve(context))
|
||||
.setIconUri(album.cover.single.mediaStoreCoverUri)
|
||||
.setMediaUri(uri)
|
||||
.build()
|
||||
return MediaItem(description, MediaItem.FLAG_PLAYABLE)
|
||||
}
|
||||
|
||||
fun Album.toMediaItem(context: Context): MediaItem {
|
||||
val mediaSessionUID = MediaSessionUID.SingleItem(uid)
|
||||
val description = MediaDescriptionCompat.Builder()
|
||||
.setMediaId(mediaSessionUID.toString())
|
||||
.setTitle(name.resolve(context))
|
||||
.setSubtitle(artists.resolveNames(context))
|
||||
.setIconUri(cover.single.mediaStoreCoverUri)
|
||||
.build()
|
||||
return MediaItem(description, MediaItem.FLAG_BROWSABLE)
|
||||
}
|
||||
|
||||
fun Artist.toMediaItem(context: Context): MediaItem {
|
||||
val mediaSessionUID = MediaSessionUID.SingleItem(uid)
|
||||
val counts =
|
||||
context.getString(
|
||||
R.string.fmt_two,
|
||||
if (explicitAlbums.isNotEmpty()) {
|
||||
context.getPlural(R.plurals.fmt_album_count, explicitAlbums.size)
|
||||
} else {
|
||||
context.getString(R.string.def_album_count)
|
||||
},
|
||||
if (songs.isNotEmpty()) {
|
||||
context.getPlural(R.plurals.fmt_song_count, songs.size)
|
||||
} else {
|
||||
context.getString(R.string.def_song_count)
|
||||
})
|
||||
val description = MediaDescriptionCompat.Builder()
|
||||
.setMediaId(mediaSessionUID.toString())
|
||||
.setTitle(name.resolve(context))
|
||||
.setSubtitle(counts)
|
||||
.setIconUri(cover.single.mediaStoreCoverUri)
|
||||
.build()
|
||||
return MediaItem(description, MediaItem.FLAG_BROWSABLE)
|
||||
}
|
||||
|
||||
fun Genre.toMediaItem(context: Context): MediaItem {
|
||||
val mediaSessionUID = MediaSessionUID.SingleItem(uid)
|
||||
val counts =
|
||||
if (songs.isNotEmpty()) {
|
||||
context.getPlural(R.plurals.fmt_song_count, songs.size)
|
||||
} else {
|
||||
context.getString(R.string.def_song_count)
|
||||
}
|
||||
val description = MediaDescriptionCompat.Builder()
|
||||
.setMediaId(mediaSessionUID.toString())
|
||||
.setTitle(name.resolve(context))
|
||||
.setSubtitle(counts)
|
||||
.setIconUri(cover.single.mediaStoreCoverUri)
|
||||
.build()
|
||||
return MediaItem(description, MediaItem.FLAG_BROWSABLE)
|
||||
}
|
||||
|
||||
fun Playlist.toMediaItem(context: Context): MediaItem {
|
||||
val mediaSessionUID = MediaSessionUID.SingleItem(uid)
|
||||
val counts =
|
||||
if (songs.isNotEmpty()) {
|
||||
context.getPlural(R.plurals.fmt_song_count, songs.size)
|
||||
} else {
|
||||
context.getString(R.string.def_song_count)
|
||||
}
|
||||
val description = MediaDescriptionCompat.Builder()
|
||||
.setMediaId(mediaSessionUID.toString())
|
||||
.setTitle(name.resolve(context))
|
||||
.setSubtitle(counts)
|
||||
.setIconUri(cover?.single?.mediaStoreCoverUri)
|
||||
.build()
|
||||
return MediaItem(description, MediaItem.FLAG_BROWSABLE)
|
||||
}
|
||||
|
|
|
@ -0,0 +1,388 @@
|
|||
/*
|
||||
* Copyright (c) 2024 Auxio Project
|
||||
* MusicBrowser.kt is part of Auxio.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.music.service
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.support.v4.media.MediaBrowserCompat.MediaItem
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.media.utils.MediaConstants
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Deferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.async
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.list.ListSettings
|
||||
import org.oxycblt.auxio.list.sort.Sort
|
||||
import org.oxycblt.auxio.music.Album
|
||||
import org.oxycblt.auxio.music.Artist
|
||||
import org.oxycblt.auxio.music.Genre
|
||||
import org.oxycblt.auxio.music.Music
|
||||
import org.oxycblt.auxio.music.MusicRepository
|
||||
import org.oxycblt.auxio.music.Playlist
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.music.device.DeviceLibrary
|
||||
import org.oxycblt.auxio.music.user.UserLibrary
|
||||
import org.oxycblt.auxio.search.SearchEngine
|
||||
import javax.inject.Inject
|
||||
import kotlin.math.min
|
||||
|
||||
class MediaItemBrowser
|
||||
@Inject
|
||||
constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val musicRepository: MusicRepository,
|
||||
private val listSettings: ListSettings
|
||||
) : MusicRepository.UpdateListener {
|
||||
private val browserJob = Job()
|
||||
private val searchScope = CoroutineScope(browserJob + Dispatchers.Default)
|
||||
private val searchSubscribers = mutableMapOf<String, String>()
|
||||
private val searchResults = mutableMapOf<String, Deferred<SearchEngine.Items>>()
|
||||
private var invalidator: Invalidator? = null
|
||||
|
||||
interface Invalidator {
|
||||
fun invalidate(ids: Map<String, Int>)
|
||||
|
||||
fun invalidate(controller: String, query: String, itemCount: Int)
|
||||
}
|
||||
|
||||
fun attach(invalidator: Invalidator) {
|
||||
this.invalidator = invalidator
|
||||
musicRepository.addUpdateListener(this)
|
||||
}
|
||||
|
||||
fun release() {
|
||||
browserJob.cancel()
|
||||
invalidator = null
|
||||
musicRepository.removeUpdateListener(this)
|
||||
}
|
||||
|
||||
override fun onMusicChanges(changes: MusicRepository.Changes) {
|
||||
val deviceLibrary = musicRepository.deviceLibrary
|
||||
var invalidateSearch = false
|
||||
val invalidate = mutableMapOf<String, Int>()
|
||||
if (changes.deviceLibrary && deviceLibrary != null) {
|
||||
MediaSessionUID.Category.DEVICE_MUSIC.forEach {
|
||||
invalidate[it.toString()] = getCategorySize(it, musicRepository)
|
||||
}
|
||||
|
||||
deviceLibrary.albums.forEach {
|
||||
val id = MediaSessionUID.SingleItem(it.uid).toString()
|
||||
invalidate[id] = it.songs.size
|
||||
}
|
||||
|
||||
deviceLibrary.artists.forEach {
|
||||
val id = MediaSessionUID.SingleItem(it.uid).toString()
|
||||
invalidate[id] = it.songs.size + it.explicitAlbums.size + it.implicitAlbums.size
|
||||
}
|
||||
|
||||
deviceLibrary.genres.forEach {
|
||||
val id = MediaSessionUID.SingleItem(it.uid).toString()
|
||||
invalidate[id] = it.songs.size + it.artists.size
|
||||
}
|
||||
|
||||
invalidateSearch = true
|
||||
}
|
||||
val userLibrary = musicRepository.userLibrary
|
||||
if (changes.userLibrary && userLibrary != null) {
|
||||
MediaSessionUID.Category.USER_MUSIC.forEach {
|
||||
invalidate[it.toString()] = getCategorySize(it, musicRepository)
|
||||
}
|
||||
userLibrary.playlists.forEach {
|
||||
val id = MediaSessionUID.SingleItem(it.uid).toString()
|
||||
invalidate[id] = it.songs.size
|
||||
}
|
||||
invalidateSearch = true
|
||||
}
|
||||
|
||||
if (invalidate.isNotEmpty()) {
|
||||
invalidator?.invalidate(invalidate)
|
||||
}
|
||||
|
||||
if (invalidateSearch) {
|
||||
for (entry in searchResults.entries) {
|
||||
searchResults[entry.key]?.cancel()
|
||||
}
|
||||
searchResults.clear()
|
||||
|
||||
for (entry in searchSubscribers.entries) {
|
||||
if (searchResults[entry.value] != null) {
|
||||
continue
|
||||
}
|
||||
searchResults[entry.value] = searchTo(entry.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val root: MediaItem
|
||||
get() = MediaSessionUID.Category.ROOT.toMediaItem(context)
|
||||
|
||||
fun getItem(mediaId: String): MediaItem? {
|
||||
val music =
|
||||
when (val uid = MediaSessionUID.fromString(mediaId)) {
|
||||
is MediaSessionUID.Category -> return uid.toMediaItem(context)
|
||||
is MediaSessionUID.SingleItem ->
|
||||
musicRepository.find(uid.uid)?.let { musicRepository.find(it.uid) }
|
||||
|
||||
is MediaSessionUID.ChildItem ->
|
||||
musicRepository.find(uid.childUid)?.let { musicRepository.find(it.uid) }
|
||||
|
||||
null -> null
|
||||
}
|
||||
?: return null
|
||||
|
||||
return when (music) {
|
||||
is Album -> music.toMediaItem(context)
|
||||
is Artist -> music.toMediaItem(context)
|
||||
is Genre -> music.toMediaItem(context)
|
||||
is Playlist -> music.toMediaItem(context)
|
||||
is Song -> music.toMediaItem(context, null)
|
||||
}
|
||||
}
|
||||
|
||||
fun getChildren(parentId: String, page: Int, pageSize: Int): List<MediaItem>? {
|
||||
val deviceLibrary = musicRepository.deviceLibrary
|
||||
val userLibrary = musicRepository.userLibrary
|
||||
if (deviceLibrary == null || userLibrary == null) {
|
||||
return listOf()
|
||||
}
|
||||
|
||||
val items = getMediaItemList(parentId, deviceLibrary, userLibrary) ?: return null
|
||||
return items.paginate(page, pageSize)
|
||||
}
|
||||
|
||||
private fun getMediaItemList(
|
||||
id: String,
|
||||
deviceLibrary: DeviceLibrary,
|
||||
userLibrary: UserLibrary
|
||||
): List<MediaItem>? {
|
||||
return when (val mediaSessionUID = MediaSessionUID.fromString(id)) {
|
||||
is MediaSessionUID.Category -> {
|
||||
when (mediaSessionUID) {
|
||||
MediaSessionUID.Category.ROOT ->
|
||||
MediaSessionUID.Category.IMPORTANT.map { it.toMediaItem(context) }
|
||||
|
||||
MediaSessionUID.Category.SONGS ->
|
||||
listSettings.songSort.songs(deviceLibrary.songs).map {
|
||||
it.toMediaItem(context, null)
|
||||
}
|
||||
|
||||
MediaSessionUID.Category.ALBUMS ->
|
||||
listSettings.albumSort.albums(deviceLibrary.albums).map {
|
||||
it.toMediaItem(context)
|
||||
}
|
||||
|
||||
MediaSessionUID.Category.ARTISTS ->
|
||||
listSettings.artistSort.artists(deviceLibrary.artists).map {
|
||||
it.toMediaItem(context)
|
||||
}
|
||||
|
||||
MediaSessionUID.Category.GENRES ->
|
||||
listSettings.genreSort.genres(deviceLibrary.genres).map {
|
||||
it.toMediaItem(context)
|
||||
}
|
||||
|
||||
MediaSessionUID.Category.PLAYLISTS ->
|
||||
userLibrary.playlists.map { it.toMediaItem(context) }
|
||||
}
|
||||
}
|
||||
|
||||
is MediaSessionUID.SingleItem -> {
|
||||
getChildMediaItems(mediaSessionUID.uid)
|
||||
}
|
||||
|
||||
is MediaSessionUID.ChildItem -> {
|
||||
getChildMediaItems(mediaSessionUID.childUid)
|
||||
}
|
||||
|
||||
null -> {
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getChildMediaItems(uid: Music.UID): List<MediaItem>? {
|
||||
return when (val item = musicRepository.find(uid)) {
|
||||
is Album -> {
|
||||
val songs = listSettings.albumSongSort.songs(item.songs)
|
||||
songs.map { it.toMediaItem(context, item).withHeader(R.string.lbl_songs) }
|
||||
}
|
||||
|
||||
is Artist -> {
|
||||
val albums = ARTIST_ALBUMS_SORT.albums(item.explicitAlbums + item.implicitAlbums)
|
||||
val songs = listSettings.artistSongSort.songs(item.songs)
|
||||
albums.map { it.toMediaItem(context).withHeader(R.string.lbl_albums) } +
|
||||
songs.map { it.toMediaItem(context, item).withHeader(R.string.lbl_songs) }
|
||||
}
|
||||
|
||||
is Genre -> {
|
||||
val artists = GENRE_ARTISTS_SORT.artists(item.artists)
|
||||
val songs = listSettings.genreSongSort.songs(item.songs)
|
||||
artists.map { it.toMediaItem(context).withHeader(R.string.lbl_artists) } +
|
||||
songs.map { it.toMediaItem(context, null).withHeader(R.string.lbl_songs) }
|
||||
}
|
||||
|
||||
is Playlist -> {
|
||||
item.songs.map { it.toMediaItem(context, item).withHeader(R.string.lbl_songs) }
|
||||
}
|
||||
|
||||
is Song,
|
||||
null -> return null
|
||||
}
|
||||
}
|
||||
|
||||
private fun MediaItem.withHeader(@StringRes res: Int): MediaItem {
|
||||
val oldExtras = mediaMetadata.extras ?: Bundle()
|
||||
val newExtras =
|
||||
Bundle(oldExtras).apply {
|
||||
putString(
|
||||
MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE,
|
||||
context.getString(res)
|
||||
)
|
||||
}
|
||||
return buildUpon()
|
||||
.setMediaMetadata(mediaMetadata.buildUpon().setExtras(newExtras).build())
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun getCategorySize(
|
||||
category: MediaSessionUID.Category,
|
||||
musicRepository: MusicRepository
|
||||
): Int {
|
||||
val deviceLibrary = musicRepository.deviceLibrary ?: return 0
|
||||
val userLibrary = musicRepository.userLibrary ?: return 0
|
||||
return when (category) {
|
||||
MediaSessionUID.Category.ROOT -> MediaSessionUID.Category.IMPORTANT.size
|
||||
MediaSessionUID.Category.SONGS -> deviceLibrary.songs.size
|
||||
MediaSessionUID.Category.ALBUMS -> deviceLibrary.albums.size
|
||||
MediaSessionUID.Category.ARTISTS -> deviceLibrary.artists.size
|
||||
MediaSessionUID.Category.GENRES -> deviceLibrary.genres.size
|
||||
MediaSessionUID.Category.PLAYLISTS -> userLibrary.playlists.size
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun prepareSearch(query: String, controller: ControllerInfo) {
|
||||
searchSubscribers[controller] = query
|
||||
val existing = searchResults[query]
|
||||
if (existing == null) {
|
||||
val new = searchTo(query)
|
||||
searchResults[query] = new
|
||||
new.await()
|
||||
} else {
|
||||
val items = existing.await()
|
||||
invalidator?.invalidate(controller, query, items.count())
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getSearchResult(
|
||||
query: String,
|
||||
page: Int,
|
||||
pageSize: Int,
|
||||
): List<MediaItem>? {
|
||||
val deferred = searchResults[query] ?: searchTo(query).also { searchResults[query] = it }
|
||||
return deferred.await().concat().paginate(page, pageSize)
|
||||
}
|
||||
|
||||
private fun SearchEngine.Items.concat(): MutableList<MediaItem> {
|
||||
val music = mutableListOf<MediaItem>()
|
||||
if (songs != null) {
|
||||
music.addAll(songs.map { it.toMediaItem(context, null) })
|
||||
}
|
||||
if (albums != null) {
|
||||
music.addAll(albums.map { it.toMediaItem(context) })
|
||||
}
|
||||
if (artists != null) {
|
||||
music.addAll(artists.map { it.toMediaItem(context) })
|
||||
}
|
||||
if (genres != null) {
|
||||
music.addAll(genres.map { it.toMediaItem(context) })
|
||||
}
|
||||
if (playlists != null) {
|
||||
music.addAll(playlists.map { it.toMediaItem(context) })
|
||||
}
|
||||
return music
|
||||
}
|
||||
|
||||
private fun SearchEngine.Items.count(): Int {
|
||||
var count = 0
|
||||
if (songs != null) {
|
||||
count += songs.size
|
||||
}
|
||||
if (albums != null) {
|
||||
count += albums.size
|
||||
}
|
||||
if (artists != null) {
|
||||
count += artists.size
|
||||
}
|
||||
if (genres != null) {
|
||||
count += genres.size
|
||||
}
|
||||
if (playlists != null) {
|
||||
count += playlists.size
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
private fun searchTo(query: String) =
|
||||
searchScope.async {
|
||||
if (query.isEmpty()) {
|
||||
return@async SearchEngine.Items()
|
||||
}
|
||||
val deviceLibrary = musicRepository.deviceLibrary ?: return@async SearchEngine.Items()
|
||||
val userLibrary = musicRepository.userLibrary ?: return@async SearchEngine.Items()
|
||||
val items =
|
||||
SearchEngine.Items(
|
||||
deviceLibrary.songs,
|
||||
deviceLibrary.albums,
|
||||
deviceLibrary.artists,
|
||||
deviceLibrary.genres,
|
||||
userLibrary.playlists
|
||||
)
|
||||
val results = searchEngine.search(items, query)
|
||||
for (entry in searchSubscribers.entries) {
|
||||
if (entry.value == query) {
|
||||
invalidator?.invalidate(entry.key, query, results.count())
|
||||
}
|
||||
}
|
||||
results
|
||||
}
|
||||
|
||||
private fun List<MediaItem>.paginate(page: Int, pageSize: Int): List<MediaItem>? {
|
||||
if (page == Int.MAX_VALUE) {
|
||||
// I think if someone requests this page it more or less implies that I should
|
||||
// return all of the pages.
|
||||
return this
|
||||
}
|
||||
val start = page * pageSize
|
||||
val end = min((page + 1) * pageSize, size) // Tolerate partial page queries
|
||||
if (pageSize == 0 || start !in indices) {
|
||||
// These pages are probably invalid. Hopefully this won't backfire.
|
||||
return null
|
||||
}
|
||||
return subList(start, end).toMutableList()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
// TODO: Rely on detail item gen logic?
|
||||
val ARTIST_ALBUMS_SORT = Sort(Sort.Mode.ByDate, Sort.Direction.DESCENDING)
|
||||
val GENRE_ARTISTS_SORT = Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING)
|
||||
}
|
||||
}
|
|
@ -47,7 +47,6 @@ import org.oxycblt.auxio.music.MusicParent
|
|||
import org.oxycblt.auxio.music.MusicRepository
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.music.service.toMediaItem
|
||||
import org.oxycblt.auxio.music.service.toSong
|
||||
import org.oxycblt.auxio.playback.PlaybackSettings
|
||||
import org.oxycblt.auxio.playback.persist.PersistenceRepository
|
||||
import org.oxycblt.auxio.playback.replaygain.ReplayGainAudioProcessor
|
||||
|
@ -110,10 +109,6 @@ class ExoPlaybackStateHolder(
|
|||
override var parent: MusicParent? = null
|
||||
private set
|
||||
|
||||
val mediaSessionPlayer: Player
|
||||
get() =
|
||||
MediaSessionPlayer(context, player, playbackManager, commandFactory, musicRepository)
|
||||
|
||||
override val progression: Progression
|
||||
get() {
|
||||
val mediaItem = player.currentMediaItem ?: return Progression.nil()
|
||||
|
@ -147,7 +142,7 @@ class ExoPlaybackStateHolder(
|
|||
emptyList()
|
||||
}
|
||||
return RawQueue(
|
||||
heap.mapNotNull { it.toSong(deviceLibrary) },
|
||||
heap.mapNotNull { it.song },
|
||||
shuffledMapping,
|
||||
player.currentMediaItemIndex)
|
||||
}
|
||||
|
@ -226,7 +221,7 @@ class ExoPlaybackStateHolder(
|
|||
override fun newPlayback(command: PlaybackCommand) {
|
||||
parent = command.parent
|
||||
player.shuffleModeEnabled = command.shuffled
|
||||
player.setMediaItems(command.queue.map { it.toMediaItem(context, null) })
|
||||
player.setMediaItems(command.queue.map { it.buildMediaItem() })
|
||||
val startIndex =
|
||||
command.song
|
||||
?.let { command.queue.indexOf(it) }
|
||||
|
@ -316,16 +311,16 @@ class ExoPlaybackStateHolder(
|
|||
}
|
||||
|
||||
if (nextIndex == C.INDEX_UNSET) {
|
||||
player.addMediaItems(songs.map { it.toMediaItem(context, null) })
|
||||
player.addMediaItems(songs.map { it.buildMediaItem() })
|
||||
} else {
|
||||
player.addMediaItems(nextIndex, songs.map { it.toMediaItem(context, null) })
|
||||
player.addMediaItems(nextIndex, songs.map { it.buildMediaItem() })
|
||||
}
|
||||
playbackManager.ack(this, ack)
|
||||
deferSave()
|
||||
}
|
||||
|
||||
override fun addToQueue(songs: List<Song>, ack: StateAck.AddToQueue) {
|
||||
player.addMediaItems(songs.map { it.toMediaItem(context, null) })
|
||||
player.addMediaItems(songs.map { it.buildMediaItem() })
|
||||
playbackManager.ack(this, ack)
|
||||
deferSave()
|
||||
}
|
||||
|
@ -382,7 +377,7 @@ class ExoPlaybackStateHolder(
|
|||
sendEvent = true
|
||||
}
|
||||
if (rawQueue != resolveQueue()) {
|
||||
player.setMediaItems(rawQueue.heap.map { it.toMediaItem(context, null) })
|
||||
player.setMediaItems(rawQueue.heap.map { it.buildMediaItem() })
|
||||
if (rawQueue.isShuffled) {
|
||||
player.shuffleModeEnabled = true
|
||||
player.setShuffleOrder(BetterShuffleOrder(rawQueue.shuffledMapping.toIntArray()))
|
||||
|
@ -538,6 +533,52 @@ class ExoPlaybackStateHolder(
|
|||
currentSaveJob = saveScope.launch { block() }
|
||||
}
|
||||
|
||||
private fun Song.buildMediaItem() = MediaItem.Builder()
|
||||
.setUri(uri)
|
||||
.setTag(this)
|
||||
.build()
|
||||
|
||||
private val MediaItem.song: Song? get() = this.localConfiguration?.tag as? Song?
|
||||
|
||||
private fun Player.unscrambleQueueIndices(): List<Int> {
|
||||
val timeline = currentTimeline
|
||||
if (timeline.isEmpty) {
|
||||
return emptyList()
|
||||
}
|
||||
val queue = mutableListOf<Int>()
|
||||
|
||||
// Add the active queue item.
|
||||
val currentMediaItemIndex = currentMediaItemIndex
|
||||
queue.add(currentMediaItemIndex)
|
||||
|
||||
// Fill queue alternating with next and/or previous queue items.
|
||||
var firstMediaItemIndex = currentMediaItemIndex
|
||||
var lastMediaItemIndex = currentMediaItemIndex
|
||||
val shuffleModeEnabled = shuffleModeEnabled
|
||||
while ((firstMediaItemIndex != C.INDEX_UNSET || lastMediaItemIndex != C.INDEX_UNSET)) {
|
||||
// Begin with next to have a longer tail than head if an even sized queue needs to be
|
||||
// trimmed.
|
||||
if (lastMediaItemIndex != C.INDEX_UNSET) {
|
||||
lastMediaItemIndex =
|
||||
timeline.getNextWindowIndex(
|
||||
lastMediaItemIndex, Player.REPEAT_MODE_OFF, shuffleModeEnabled)
|
||||
if (lastMediaItemIndex != C.INDEX_UNSET) {
|
||||
queue.add(lastMediaItemIndex)
|
||||
}
|
||||
}
|
||||
if (firstMediaItemIndex != C.INDEX_UNSET) {
|
||||
firstMediaItemIndex =
|
||||
timeline.getPreviousWindowIndex(
|
||||
firstMediaItemIndex, Player.REPEAT_MODE_OFF, shuffleModeEnabled)
|
||||
if (firstMediaItemIndex != C.INDEX_UNSET) {
|
||||
queue.add(0, firstMediaItemIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return queue
|
||||
}
|
||||
|
||||
class Factory
|
||||
@Inject
|
||||
constructor(
|
||||
|
|
|
@ -39,16 +39,25 @@ import org.oxycblt.auxio.IntegerTable
|
|||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.image.BitmapProvider
|
||||
import org.oxycblt.auxio.image.ImageSettings
|
||||
import org.oxycblt.auxio.music.Album
|
||||
import org.oxycblt.auxio.music.Artist
|
||||
import org.oxycblt.auxio.music.Genre
|
||||
import org.oxycblt.auxio.music.Music
|
||||
import org.oxycblt.auxio.music.MusicParent
|
||||
import org.oxycblt.auxio.music.MusicRepository
|
||||
import org.oxycblt.auxio.music.Playlist
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.music.resolveNames
|
||||
import org.oxycblt.auxio.music.service.MediaSessionUID
|
||||
import org.oxycblt.auxio.playback.ActionMode
|
||||
import org.oxycblt.auxio.playback.PlaybackSettings
|
||||
import org.oxycblt.auxio.playback.service.PlaybackActions
|
||||
import org.oxycblt.auxio.playback.state.PlaybackCommand
|
||||
import org.oxycblt.auxio.playback.state.PlaybackStateManager
|
||||
import org.oxycblt.auxio.playback.state.Progression
|
||||
import org.oxycblt.auxio.playback.state.QueueChange
|
||||
import org.oxycblt.auxio.playback.state.RepeatMode
|
||||
import org.oxycblt.auxio.playback.state.ShuffleMode
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.auxio.util.newBroadcastPendingIntent
|
||||
import org.oxycblt.auxio.util.newMainPendingIntent
|
||||
|
@ -64,6 +73,8 @@ private constructor(
|
|||
private val context: Context,
|
||||
private val playbackManager: PlaybackStateManager,
|
||||
private val playbackSettings: PlaybackSettings,
|
||||
private val commandFactory: PlaybackCommand.Factory,
|
||||
private val musicRepository: MusicRepository,
|
||||
private val bitmapProvider: BitmapProvider,
|
||||
private val imageSettings: ImageSettings
|
||||
) :
|
||||
|
@ -77,12 +88,14 @@ private constructor(
|
|||
constructor(
|
||||
private val playbackManager: PlaybackStateManager,
|
||||
private val playbackSettings: PlaybackSettings,
|
||||
private val commandFactory: PlaybackCommand.Factory,
|
||||
private val musicRepository: MusicRepository,
|
||||
private val bitmapProvider: BitmapProvider,
|
||||
private val imageSettings: ImageSettings
|
||||
) {
|
||||
fun create(context: Context) =
|
||||
MediaSessionHolder(
|
||||
context, playbackManager, playbackSettings, bitmapProvider, imageSettings)
|
||||
context, playbackManager, playbackSettings, commandFactory, musicRepository, bitmapProvider, imageSettings)
|
||||
}
|
||||
|
||||
private val mediaSession =
|
||||
|
@ -201,27 +214,47 @@ private constructor(
|
|||
|
||||
override fun onPlayFromMediaId(mediaId: String?, extras: Bundle?) {
|
||||
super.onPlayFromMediaId(mediaId, extras)
|
||||
// STUB: Unimplemented, no media browser
|
||||
val uid = MediaSessionUID.fromString(mediaId ?: return) ?: return
|
||||
val command = expandIntoCommand(uid)
|
||||
requireNotNull(command) { "Invalid playback configuration" }
|
||||
playbackManager.play(command)
|
||||
}
|
||||
|
||||
override fun onPlayFromUri(uri: Uri?, extras: Bundle?) {
|
||||
super.onPlayFromUri(uri, extras)
|
||||
// STUB: Unimplemented, no media browser
|
||||
// STUB
|
||||
}
|
||||
|
||||
override fun onPlayFromSearch(query: String?, extras: Bundle?) {
|
||||
super.onPlayFromSearch(query, extras)
|
||||
// STUB: Unimplemented, no media browser
|
||||
// STUB: Unimplemented, no search engine
|
||||
}
|
||||
|
||||
override fun onAddQueueItem(description: MediaDescriptionCompat?) {
|
||||
override fun onAddQueueItem(description: MediaDescriptionCompat) {
|
||||
super.onAddQueueItem(description)
|
||||
// STUB: Unimplemented
|
||||
val deviceLibrary = musicRepository.deviceLibrary ?: return
|
||||
val uid = MediaSessionUID.fromString(description.mediaId ?: return) ?: return
|
||||
val song = when (uid) {
|
||||
is MediaSessionUID.SingleItem -> deviceLibrary.findSong(uid.uid)
|
||||
is MediaSessionUID.ChildItem -> deviceLibrary.findSong(uid.childUid)
|
||||
else -> null
|
||||
} ?: return
|
||||
playbackManager.addToQueue(song)
|
||||
}
|
||||
|
||||
override fun onRemoveQueueItem(description: MediaDescriptionCompat?) {
|
||||
override fun onRemoveQueueItem(description: MediaDescriptionCompat) {
|
||||
super.onRemoveQueueItem(description)
|
||||
// STUB: Unimplemented
|
||||
val deviceLibrary = musicRepository.deviceLibrary ?: return
|
||||
val uid = MediaSessionUID.fromString(description.mediaId ?: return) ?: return
|
||||
val song = when (uid) {
|
||||
is MediaSessionUID.SingleItem -> deviceLibrary.findSong(uid.uid)
|
||||
is MediaSessionUID.ChildItem -> deviceLibrary.findSong(uid.childUid)
|
||||
else -> null
|
||||
} ?: return
|
||||
val queueIndex = playbackManager.queue.indexOf(song)
|
||||
if (queueIndex > -1) {
|
||||
playbackManager.removeQueueItem(queueIndex)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPlay() {
|
||||
|
@ -392,6 +425,40 @@ private constructor(
|
|||
mediaSession.setQueue(queueItems)
|
||||
}
|
||||
|
||||
private fun expandIntoCommand(uid: MediaSessionUID): PlaybackCommand? {
|
||||
val music: Music
|
||||
var parent: MusicParent? = null
|
||||
when (uid) {
|
||||
is MediaSessionUID.SingleItem -> {
|
||||
music = musicRepository.find(uid.uid) ?: return null
|
||||
}
|
||||
is MediaSessionUID.ChildItem -> {
|
||||
music = musicRepository.find(uid.childUid) ?: return null
|
||||
parent = musicRepository.find(uid.parentUid) as? MusicParent ?: return null
|
||||
}
|
||||
else -> return null
|
||||
}
|
||||
|
||||
return when (music) {
|
||||
is Song -> inferSongFromParent(music, parent)
|
||||
is Album -> commandFactory.album(music, ShuffleMode.OFF)
|
||||
is Artist -> commandFactory.artist(music, ShuffleMode.OFF)
|
||||
is Genre -> commandFactory.genre(music, ShuffleMode.OFF)
|
||||
is Playlist -> commandFactory.playlist(music, ShuffleMode.OFF)
|
||||
}
|
||||
}
|
||||
|
||||
private fun inferSongFromParent(music: Song, parent: MusicParent?) =
|
||||
when (parent) {
|
||||
is Album -> commandFactory.songFromAlbum(music, ShuffleMode.IMPLICIT)
|
||||
is Artist -> commandFactory.songFromArtist(music, parent, ShuffleMode.IMPLICIT)
|
||||
?: commandFactory.songFromArtist(music, music.artists[0], ShuffleMode.IMPLICIT)
|
||||
is Genre -> commandFactory.songFromGenre(music, parent, ShuffleMode.IMPLICIT)
|
||||
?: commandFactory.songFromGenre(music, music.genres[0], ShuffleMode.IMPLICIT)
|
||||
is Playlist -> commandFactory.songFromPlaylist(music, parent, ShuffleMode.IMPLICIT)
|
||||
null -> commandFactory.songFromAll(music, ShuffleMode.IMPLICIT)
|
||||
}
|
||||
|
||||
/** Invalidate the current [MediaSessionCompat]'s [PlaybackStateCompat]. */
|
||||
private fun invalidateSessionState() {
|
||||
logD("Updating media session playback state")
|
||||
|
|
|
@ -1,390 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2024 Auxio Project
|
||||
* MediaSessionPlayer.kt is part of Auxio.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.playback.service
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.view.Surface
|
||||
import android.view.SurfaceHolder
|
||||
import android.view.SurfaceView
|
||||
import android.view.TextureView
|
||||
import androidx.media3.common.AudioAttributes
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.ForwardingPlayer
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.MediaMetadata
|
||||
import androidx.media3.common.PlaybackParameters
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.TrackSelectionParameters
|
||||
import java.lang.Exception
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.music.Album
|
||||
import org.oxycblt.auxio.music.Artist
|
||||
import org.oxycblt.auxio.music.Genre
|
||||
import org.oxycblt.auxio.music.Music
|
||||
import org.oxycblt.auxio.music.MusicParent
|
||||
import org.oxycblt.auxio.music.MusicRepository
|
||||
import org.oxycblt.auxio.music.Playlist
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.music.service.MediaSessionUID
|
||||
import org.oxycblt.auxio.music.service.toSong
|
||||
import org.oxycblt.auxio.playback.state.PlaybackCommand
|
||||
import org.oxycblt.auxio.playback.state.PlaybackStateManager
|
||||
import org.oxycblt.auxio.playback.state.RepeatMode
|
||||
import org.oxycblt.auxio.playback.state.ShuffleMode
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.auxio.util.logE
|
||||
|
||||
/**
|
||||
* A thin wrapper around the player instance that drastically reduces the command surface and
|
||||
* forwards all commands to PlaybackStateManager so I can ensure that all the unhinged commands that
|
||||
* Media3 will throw at me will be handled in a predictable way, rather than just clobbering the
|
||||
* playback state. Largely limited to the legacy media APIs.
|
||||
*
|
||||
* I'll add more support as I go along when I can confirm that apps will use the Media3 API and send
|
||||
* more advanced commands.
|
||||
*
|
||||
* @author Alexander Capehart
|
||||
*/
|
||||
class MediaSessionPlayer(
|
||||
private val context: Context,
|
||||
player: Player,
|
||||
private val playbackManager: PlaybackStateManager,
|
||||
private val commandFactory: PlaybackCommand.Factory,
|
||||
private val musicRepository: MusicRepository
|
||||
) : ForwardingPlayer(player) {
|
||||
override fun getAvailableCommands(): Player.Commands {
|
||||
return super.getAvailableCommands()
|
||||
.buildUpon()
|
||||
.addAll(Player.COMMAND_SEEK_TO_NEXT, Player.COMMAND_SEEK_TO_PREVIOUS)
|
||||
.build()
|
||||
}
|
||||
|
||||
override fun isCommandAvailable(command: Int): Boolean {
|
||||
// We can always skip forward and backward (this is to retain parity with the old behavior)
|
||||
return super.isCommandAvailable(command) ||
|
||||
command in setOf(Player.COMMAND_SEEK_TO_NEXT, Player.COMMAND_SEEK_TO_PREVIOUS)
|
||||
}
|
||||
|
||||
override fun setMediaItems(mediaItems: MutableList<MediaItem>, resetPosition: Boolean) {
|
||||
if (!resetPosition) {
|
||||
error("Playing MediaItems with custom position parameters is not supported")
|
||||
}
|
||||
|
||||
setMediaItems(mediaItems, C.INDEX_UNSET, C.TIME_UNSET)
|
||||
}
|
||||
|
||||
override fun getMediaMetadata() =
|
||||
super.getMediaMetadata().run {
|
||||
val existingExtras = extras
|
||||
val newExtras = existingExtras?.let { Bundle(it) } ?: Bundle()
|
||||
newExtras.apply {
|
||||
putString(
|
||||
"parent",
|
||||
playbackManager.parent?.name?.resolve(context)
|
||||
?: context.getString(R.string.lbl_all_songs))
|
||||
}
|
||||
|
||||
buildUpon().setExtras(newExtras).build()
|
||||
}
|
||||
|
||||
override fun setMediaItems(
|
||||
mediaItems: MutableList<MediaItem>,
|
||||
startIndex: Int,
|
||||
startPositionMs: Long
|
||||
) {
|
||||
// We assume the only people calling this method are going to be the MediaSession callbacks.
|
||||
// As part of this, we expand the given MediaItems into the command that should be sent to
|
||||
// the player.
|
||||
if (startIndex != C.INDEX_UNSET || startPositionMs != C.TIME_UNSET) {
|
||||
error("Playing MediaItems with custom position parameters is not supported")
|
||||
}
|
||||
if (mediaItems.size != 1) {
|
||||
error("Playing multiple MediaItems is not supported")
|
||||
}
|
||||
val command = expandMediaItemIntoCommand(mediaItems.first())
|
||||
requireNotNull(command) { "Invalid playback configuration" }
|
||||
playbackManager.play(command)
|
||||
}
|
||||
|
||||
private fun expandMediaItemIntoCommand(mediaItem: MediaItem): PlaybackCommand? {
|
||||
val uid = MediaSessionUID.fromString(mediaItem.mediaId) ?: return null
|
||||
val music: Music
|
||||
var parent: MusicParent? = null
|
||||
when (uid) {
|
||||
is MediaSessionUID.Single -> {
|
||||
music = musicRepository.find(uid.uid) ?: return null
|
||||
}
|
||||
is MediaSessionUID.Joined -> {
|
||||
music = musicRepository.find(uid.childUid) ?: return null
|
||||
parent = musicRepository.find(uid.parentUid) as? MusicParent ?: return null
|
||||
}
|
||||
else -> return null
|
||||
}
|
||||
|
||||
return when (music) {
|
||||
is Song -> inferSongFromParentCommand(music, parent)
|
||||
is Album -> commandFactory.album(music, ShuffleMode.OFF)
|
||||
is Artist -> commandFactory.artist(music, ShuffleMode.OFF)
|
||||
is Genre -> commandFactory.genre(music, ShuffleMode.OFF)
|
||||
is Playlist -> commandFactory.playlist(music, ShuffleMode.OFF)
|
||||
}
|
||||
}
|
||||
|
||||
private fun inferSongFromParentCommand(music: Song, parent: MusicParent?) =
|
||||
when (parent) {
|
||||
is Album -> commandFactory.songFromAlbum(music, ShuffleMode.IMPLICIT)
|
||||
is Artist -> commandFactory.songFromArtist(music, parent, ShuffleMode.IMPLICIT)
|
||||
?: commandFactory.songFromArtist(music, music.artists[0], ShuffleMode.IMPLICIT)
|
||||
is Genre -> commandFactory.songFromGenre(music, parent, ShuffleMode.IMPLICIT)
|
||||
?: commandFactory.songFromGenre(music, music.genres[0], ShuffleMode.IMPLICIT)
|
||||
is Playlist -> commandFactory.songFromPlaylist(music, parent, ShuffleMode.IMPLICIT)
|
||||
null -> commandFactory.songFromAll(music, ShuffleMode.IMPLICIT)
|
||||
}
|
||||
|
||||
override fun play() = playbackManager.playing(true)
|
||||
|
||||
override fun pause() = playbackManager.playing(false)
|
||||
|
||||
override fun setRepeatMode(repeatMode: Int) {
|
||||
val appRepeatMode =
|
||||
when (repeatMode) {
|
||||
Player.REPEAT_MODE_OFF -> RepeatMode.NONE
|
||||
Player.REPEAT_MODE_ONE -> RepeatMode.TRACK
|
||||
Player.REPEAT_MODE_ALL -> RepeatMode.ALL
|
||||
else -> throw IllegalStateException("Unknown repeat mode: $repeatMode")
|
||||
}
|
||||
playbackManager.repeatMode(appRepeatMode)
|
||||
}
|
||||
|
||||
override fun seekToDefaultPosition(mediaItemIndex: Int) {
|
||||
val indices = unscrambleQueueIndices()
|
||||
val fakeIndex = indices.indexOf(mediaItemIndex)
|
||||
if (fakeIndex < 0) {
|
||||
return
|
||||
}
|
||||
playbackManager.goto(fakeIndex)
|
||||
}
|
||||
|
||||
override fun seekToNext() = playbackManager.next()
|
||||
|
||||
override fun seekToNextMediaItem() = playbackManager.next()
|
||||
|
||||
override fun seekToPrevious() = playbackManager.prev()
|
||||
|
||||
override fun seekToPreviousMediaItem() = playbackManager.prev()
|
||||
|
||||
override fun seekTo(positionMs: Long) = playbackManager.seekTo(positionMs)
|
||||
|
||||
override fun seekTo(mediaItemIndex: Int, positionMs: Long) = notAllowed()
|
||||
|
||||
override fun seekToDefaultPosition() = notAllowed()
|
||||
|
||||
override fun addMediaItems(index: Int, mediaItems: MutableList<MediaItem>) {
|
||||
val deviceLibrary = musicRepository.deviceLibrary ?: return
|
||||
val songs = mediaItems.mapNotNull { it.toSong(deviceLibrary) }
|
||||
when {
|
||||
index ==
|
||||
currentTimeline.getNextWindowIndex(
|
||||
currentMediaItemIndex, Player.REPEAT_MODE_OFF, shuffleModeEnabled) -> {
|
||||
playbackManager.playNext(songs)
|
||||
}
|
||||
index >= mediaItemCount -> playbackManager.addToQueue(songs)
|
||||
else -> error("Unsupported index $index")
|
||||
}
|
||||
}
|
||||
|
||||
override fun setShuffleModeEnabled(shuffleModeEnabled: Boolean) {
|
||||
playbackManager.shuffled(shuffleModeEnabled)
|
||||
}
|
||||
|
||||
override fun moveMediaItem(currentIndex: Int, newIndex: Int) {
|
||||
val indices = unscrambleQueueIndices()
|
||||
val fakeFrom = indices.indexOf(currentIndex)
|
||||
if (fakeFrom < 0) {
|
||||
return
|
||||
}
|
||||
val fakeTo =
|
||||
if (newIndex >= mediaItemCount) {
|
||||
currentTimeline.getLastWindowIndex(shuffleModeEnabled)
|
||||
} else {
|
||||
indices.indexOf(newIndex)
|
||||
}
|
||||
if (fakeTo < 0) {
|
||||
return
|
||||
}
|
||||
playbackManager.moveQueueItem(fakeFrom, fakeTo)
|
||||
}
|
||||
|
||||
override fun moveMediaItems(fromIndex: Int, toIndex: Int, newIndex: Int) =
|
||||
error("Multi-item queue moves are unsupported")
|
||||
|
||||
override fun removeMediaItem(index: Int) {
|
||||
val indices = unscrambleQueueIndices()
|
||||
val fakeAt = indices.indexOf(index)
|
||||
if (fakeAt < 0) {
|
||||
return
|
||||
}
|
||||
playbackManager.removeQueueItem(fakeAt)
|
||||
}
|
||||
|
||||
override fun removeMediaItems(fromIndex: Int, toIndex: Int) =
|
||||
error("Any multi-item queue removal is unsupported")
|
||||
|
||||
override fun stop() = playbackManager.endSession()
|
||||
|
||||
// These methods I don't want MediaSession calling in any way since they'll do insane things
|
||||
// that I'm not tracking. If they do call them, I will know.
|
||||
|
||||
override fun setMediaItem(mediaItem: MediaItem) = notAllowed()
|
||||
|
||||
override fun setMediaItem(mediaItem: MediaItem, startPositionMs: Long) = notAllowed()
|
||||
|
||||
override fun setMediaItem(mediaItem: MediaItem, resetPosition: Boolean) = notAllowed()
|
||||
|
||||
override fun setMediaItems(mediaItems: MutableList<MediaItem>) = notAllowed()
|
||||
|
||||
override fun addMediaItem(mediaItem: MediaItem) = notAllowed()
|
||||
|
||||
override fun addMediaItem(index: Int, mediaItem: MediaItem) = notAllowed()
|
||||
|
||||
override fun addMediaItems(mediaItems: MutableList<MediaItem>) = notAllowed()
|
||||
|
||||
override fun replaceMediaItem(index: Int, mediaItem: MediaItem) = notAllowed()
|
||||
|
||||
override fun replaceMediaItems(
|
||||
fromIndex: Int,
|
||||
toIndex: Int,
|
||||
mediaItems: MutableList<MediaItem>
|
||||
) = notAllowed()
|
||||
|
||||
override fun clearMediaItems() = notAllowed()
|
||||
|
||||
override fun setPlaybackSpeed(speed: Float) = notAllowed()
|
||||
|
||||
override fun seekForward() = notAllowed()
|
||||
|
||||
override fun seekBack() = notAllowed()
|
||||
|
||||
@Deprecated("Deprecated in Java") override fun next() = notAllowed()
|
||||
|
||||
@Deprecated("Deprecated in Java") override fun previous() = notAllowed()
|
||||
|
||||
@Deprecated("Deprecated in Java") override fun seekToPreviousWindow() = notAllowed()
|
||||
|
||||
@Deprecated("Deprecated in Java") override fun seekToNextWindow() = notAllowed()
|
||||
|
||||
override fun prepare() = notAllowed()
|
||||
|
||||
override fun release() = notAllowed()
|
||||
|
||||
override fun setPlayWhenReady(playWhenReady: Boolean) = notAllowed()
|
||||
|
||||
override fun hasNextMediaItem() = notAllowed()
|
||||
|
||||
override fun setAudioAttributes(audioAttributes: AudioAttributes, handleAudioFocus: Boolean) =
|
||||
notAllowed()
|
||||
|
||||
override fun setVolume(volume: Float) = notAllowed()
|
||||
|
||||
override fun setDeviceVolume(volume: Int, flags: Int) = notAllowed()
|
||||
|
||||
override fun setDeviceMuted(muted: Boolean, flags: Int) = notAllowed()
|
||||
|
||||
override fun increaseDeviceVolume(flags: Int) = notAllowed()
|
||||
|
||||
override fun decreaseDeviceVolume(flags: Int) = notAllowed()
|
||||
|
||||
@Deprecated("Deprecated in Java") override fun increaseDeviceVolume() = notAllowed()
|
||||
|
||||
@Deprecated("Deprecated in Java") override fun decreaseDeviceVolume() = notAllowed()
|
||||
|
||||
@Deprecated("Deprecated in Java") override fun setDeviceVolume(volume: Int) = notAllowed()
|
||||
|
||||
@Deprecated("Deprecated in Java") override fun setDeviceMuted(muted: Boolean) = notAllowed()
|
||||
|
||||
override fun setPlaybackParameters(playbackParameters: PlaybackParameters) = notAllowed()
|
||||
|
||||
override fun setPlaylistMetadata(mediaMetadata: MediaMetadata) = notAllowed()
|
||||
|
||||
override fun setTrackSelectionParameters(parameters: TrackSelectionParameters) = notAllowed()
|
||||
|
||||
override fun setVideoSurface(surface: Surface?) = notAllowed()
|
||||
|
||||
override fun setVideoSurfaceHolder(surfaceHolder: SurfaceHolder?) = notAllowed()
|
||||
|
||||
override fun setVideoSurfaceView(surfaceView: SurfaceView?) = notAllowed()
|
||||
|
||||
override fun setVideoTextureView(textureView: TextureView?) = notAllowed()
|
||||
|
||||
override fun clearVideoSurface() = notAllowed()
|
||||
|
||||
override fun clearVideoSurface(surface: Surface?) = notAllowed()
|
||||
|
||||
override fun clearVideoSurfaceHolder(surfaceHolder: SurfaceHolder?) = notAllowed()
|
||||
|
||||
override fun clearVideoSurfaceView(surfaceView: SurfaceView?) = notAllowed()
|
||||
|
||||
override fun clearVideoTextureView(textureView: TextureView?) = notAllowed()
|
||||
|
||||
private fun notAllowed(): Nothing {
|
||||
logD("MediaSession unexpectedly called this method")
|
||||
logE(Exception().stackTraceToString())
|
||||
error("MediaSession unexpectedly called this method")
|
||||
}
|
||||
}
|
||||
|
||||
fun Player.unscrambleQueueIndices(): List<Int> {
|
||||
val timeline = currentTimeline
|
||||
if (timeline.isEmpty) {
|
||||
return emptyList()
|
||||
}
|
||||
val queue = mutableListOf<Int>()
|
||||
|
||||
// Add the active queue item.
|
||||
val currentMediaItemIndex = currentMediaItemIndex
|
||||
queue.add(currentMediaItemIndex)
|
||||
|
||||
// Fill queue alternating with next and/or previous queue items.
|
||||
var firstMediaItemIndex = currentMediaItemIndex
|
||||
var lastMediaItemIndex = currentMediaItemIndex
|
||||
val shuffleModeEnabled = shuffleModeEnabled
|
||||
while ((firstMediaItemIndex != C.INDEX_UNSET || lastMediaItemIndex != C.INDEX_UNSET)) {
|
||||
// Begin with next to have a longer tail than head if an even sized queue needs to be
|
||||
// trimmed.
|
||||
if (lastMediaItemIndex != C.INDEX_UNSET) {
|
||||
lastMediaItemIndex =
|
||||
timeline.getNextWindowIndex(
|
||||
lastMediaItemIndex, Player.REPEAT_MODE_OFF, shuffleModeEnabled)
|
||||
if (lastMediaItemIndex != C.INDEX_UNSET) {
|
||||
queue.add(lastMediaItemIndex)
|
||||
}
|
||||
}
|
||||
if (firstMediaItemIndex != C.INDEX_UNSET) {
|
||||
firstMediaItemIndex =
|
||||
timeline.getPreviousWindowIndex(
|
||||
firstMediaItemIndex, Player.REPEAT_MODE_OFF, shuffleModeEnabled)
|
||||
if (firstMediaItemIndex != C.INDEX_UNSET) {
|
||||
queue.add(0, firstMediaItemIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return queue
|
||||
}
|
|
@ -109,30 +109,8 @@ constructor(
|
|||
foregroundListener?.updateForeground(ForegroundListener.Change.MEDIA_SESSION)
|
||||
}
|
||||
|
||||
// override fun onConnect(
|
||||
// session: MediaSession,
|
||||
// controller: MediaSession.ControllerInfo
|
||||
// ): ConnectionResult {
|
||||
// val sessionCommands =
|
||||
// actionHandler.withCommands(ConnectionResult.DEFAULT_SESSION_AND_LIBRARY_COMMANDS)
|
||||
// return ConnectionResult.AcceptedResultBuilder(session)
|
||||
// .setAvailableSessionCommands(sessionCommands)
|
||||
// .setCustomLayout(actionHandler.createCustomLayout())
|
||||
// .build()
|
||||
// }
|
||||
//
|
||||
// override fun onCustomCommand(
|
||||
// session: MediaSession,
|
||||
// controller: MediaSession.ControllerInfo,
|
||||
// customCommand: SessionCommand,
|
||||
// args: Bundle
|
||||
// ): ListenableFuture<SessionResult> =
|
||||
// if (actionHandler.handleCommand(customCommand)) {
|
||||
// Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS))
|
||||
// } else {
|
||||
// super.onCustomCommand(session, controller, customCommand, args)
|
||||
// }
|
||||
//
|
||||
|
||||
|
||||
// override fun onGetLibraryRoot(
|
||||
// session: MediaLibrarySession,
|
||||
// browser: MediaSession.ControllerInfo,
|
||||
|
|
|
@ -163,6 +163,7 @@
|
|||
<string name="lbl_reset">Reset</string>
|
||||
<!-- As in to add a new folder in the "Music folders" setting -->
|
||||
<string name="lbl_add">Add</string>
|
||||
<string name="lbl_more">More</string>
|
||||
|
||||
<string name="lbl_path_style">Path style</string>
|
||||
<string name="lbl_path_style_absolute">Absolute</string>
|
||||
|
|
Loading…
Reference in a new issue