playback: port basic media descriptions

This commit is contained in:
Alexander Capehart 2024-08-27 10:33:54 -06:00
parent e43f55bc78
commit 69070e7b13
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
9 changed files with 656 additions and 1041 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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")

View file

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

View file

@ -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,

View file

@ -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>