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.Bundle
|
||||||
import android.os.IBinder
|
import android.os.IBinder
|
||||||
import android.support.v4.media.MediaBrowserCompat
|
import android.support.v4.media.MediaBrowserCompat
|
||||||
|
import android.support.v4.media.MediaBrowserCompat.MediaItem
|
||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
import androidx.core.app.NotificationChannelCompat
|
import androidx.core.app.NotificationChannelCompat
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
|
@ -81,31 +82,29 @@ class AuxioService : MediaBrowserServiceCompat(), ForegroundListener {
|
||||||
clientPackageName: String,
|
clientPackageName: String,
|
||||||
clientUid: Int,
|
clientUid: Int,
|
||||||
rootHints: Bundle?
|
rootHints: Bundle?
|
||||||
): BrowserRoot? {
|
): BrowserRoot? = null
|
||||||
TODO("Not yet implemented")
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onLoadChildren(
|
override fun onLoadChildren(
|
||||||
parentId: String,
|
parentId: String,
|
||||||
result: Result<MutableList<MediaBrowserCompat.MediaItem>>
|
result: Result<MutableList<MediaItem>>
|
||||||
) = throw NotImplementedError()
|
) = throw NotImplementedError()
|
||||||
|
|
||||||
override fun onLoadChildren(
|
override fun onLoadChildren(
|
||||||
parentId: String,
|
parentId: String,
|
||||||
result: Result<MutableList<MediaBrowserCompat.MediaItem>>,
|
result: Result<MutableList<MediaItem>>,
|
||||||
options: Bundle
|
options: Bundle
|
||||||
) {
|
) {
|
||||||
super.onLoadChildren(parentId, result, options)
|
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)
|
super.onLoadItem(itemId, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onSearch(
|
override fun onSearch(
|
||||||
query: String,
|
query: String,
|
||||||
extras: Bundle?,
|
extras: Bundle?,
|
||||||
result: Result<MutableList<MediaBrowserCompat.MediaItem>>
|
result: Result<MutableList<MediaItem>>
|
||||||
) {
|
) {
|
||||||
super.onSearch(query, extras, result)
|
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.Bitmap
|
||||||
import android.graphics.BitmapFactory
|
import android.graphics.BitmapFactory
|
||||||
import android.os.Bundle
|
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.DrawableRes
|
||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
import androidx.media.utils.MediaConstants
|
import androidx.media.utils.MediaConstants
|
||||||
import androidx.media3.common.MediaItem
|
|
||||||
import androidx.media3.common.MediaMetadata
|
import androidx.media3.common.MediaMetadata
|
||||||
import java.io.ByteArrayOutputStream
|
|
||||||
import org.oxycblt.auxio.BuildConfig
|
import org.oxycblt.auxio.BuildConfig
|
||||||
import org.oxycblt.auxio.R
|
import org.oxycblt.auxio.R
|
||||||
import org.oxycblt.auxio.music.Album
|
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.MusicParent
|
||||||
import org.oxycblt.auxio.music.Playlist
|
import org.oxycblt.auxio.music.Playlist
|
||||||
import org.oxycblt.auxio.music.Song
|
import org.oxycblt.auxio.music.Song
|
||||||
import org.oxycblt.auxio.music.device.DeviceLibrary
|
|
||||||
import org.oxycblt.auxio.music.resolveNames
|
import org.oxycblt.auxio.music.resolveNames
|
||||||
import org.oxycblt.auxio.util.getPlural
|
import org.oxycblt.auxio.util.getPlural
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
|
import kotlin.math.ceil
|
||||||
|
|
||||||
fun MediaSessionUID.Category.toMediaItem(context: Context): MediaItem {
|
enum class Category(val id: String, @StringRes val nameRes: Int, @DrawableRes val bitmapRes: Int?) {
|
||||||
// TODO: Make custom overflow menu for compat
|
ROOT("root", R.string.info_app_name, null),
|
||||||
val style =
|
MORE("more", R.string.lbl_more, R.drawable.ic_more_24),
|
||||||
Bundle().apply {
|
SONGS("songs", R.string.lbl_songs, R.drawable.ic_song_bitmap_24),
|
||||||
putInt(
|
ALBUMS("albums", R.string.lbl_albums, R.drawable.ic_album_bitmap_24),
|
||||||
MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_SINGLE_ITEM,
|
ARTISTS("artists", R.string.lbl_artists, R.drawable.ic_artist_bitmap_24),
|
||||||
MediaConstants.DESCRIPTION_EXTRAS_VALUE_CONTENT_STYLE_CATEGORY_LIST_ITEM)
|
GENRES("genres", R.string.lbl_genres, R.drawable.ic_genre_bitmap_24),
|
||||||
}
|
PLAYLISTS("playlists", R.string.lbl_playlists, R.drawable.ic_playlist_bitmap_24);
|
||||||
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"
|
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
val DEVICE_MUSIC = listOf(ROOT, SONGS, ALBUMS, ARTISTS, GENRES)
|
val DEVICE_MUSIC = listOf(ROOT, SONGS, ALBUMS, ARTISTS, GENRES)
|
||||||
val USER_MUSIC = listOf(ROOT, PLAYLISTS)
|
val USER_MUSIC = listOf(ROOT, PLAYLISTS)
|
||||||
val IMPORTANT = listOf(SONGS, ALBUMS, ARTISTS, GENRES, 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"
|
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"
|
override fun toString() = "$ID_ITEM:$parentUid>$childUid"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -284,22 +83,23 @@ sealed interface MediaSessionUID {
|
||||||
}
|
}
|
||||||
return when (parts[0]) {
|
return when (parts[0]) {
|
||||||
ID_CATEGORY ->
|
ID_CATEGORY ->
|
||||||
when (parts[1]) {
|
CategoryItem(when (parts[1]) {
|
||||||
Category.ROOT.id -> Category.ROOT
|
Category.ROOT.id -> Category.ROOT
|
||||||
|
Category.MORE.id -> Category.MORE
|
||||||
Category.SONGS.id -> Category.SONGS
|
Category.SONGS.id -> Category.SONGS
|
||||||
Category.ALBUMS.id -> Category.ALBUMS
|
Category.ALBUMS.id -> Category.ALBUMS
|
||||||
Category.ARTISTS.id -> Category.ARTISTS
|
Category.ARTISTS.id -> Category.ARTISTS
|
||||||
Category.GENRES.id -> Category.GENRES
|
Category.GENRES.id -> Category.GENRES
|
||||||
Category.PLAYLISTS.id -> Category.PLAYLISTS
|
Category.PLAYLISTS.id -> Category.PLAYLISTS
|
||||||
else -> null
|
else -> return null
|
||||||
}
|
})
|
||||||
ID_ITEM -> {
|
ID_ITEM -> {
|
||||||
val uids = parts[1].split(">", limit = 2)
|
val uids = parts[1].split(">", limit = 2)
|
||||||
if (uids.size == 1) {
|
if (uids.size == 1) {
|
||||||
Music.UID.fromString(uids[0])?.let { Single(it) }
|
Music.UID.fromString(uids[0])?.let { SingleItem(it) }
|
||||||
} else {
|
} else {
|
||||||
Music.UID.fromString(uids[0])?.let { parent ->
|
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.MusicRepository
|
||||||
import org.oxycblt.auxio.music.Song
|
import org.oxycblt.auxio.music.Song
|
||||||
import org.oxycblt.auxio.music.service.toMediaItem
|
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.PlaybackSettings
|
||||||
import org.oxycblt.auxio.playback.persist.PersistenceRepository
|
import org.oxycblt.auxio.playback.persist.PersistenceRepository
|
||||||
import org.oxycblt.auxio.playback.replaygain.ReplayGainAudioProcessor
|
import org.oxycblt.auxio.playback.replaygain.ReplayGainAudioProcessor
|
||||||
|
@ -110,10 +109,6 @@ class ExoPlaybackStateHolder(
|
||||||
override var parent: MusicParent? = null
|
override var parent: MusicParent? = null
|
||||||
private set
|
private set
|
||||||
|
|
||||||
val mediaSessionPlayer: Player
|
|
||||||
get() =
|
|
||||||
MediaSessionPlayer(context, player, playbackManager, commandFactory, musicRepository)
|
|
||||||
|
|
||||||
override val progression: Progression
|
override val progression: Progression
|
||||||
get() {
|
get() {
|
||||||
val mediaItem = player.currentMediaItem ?: return Progression.nil()
|
val mediaItem = player.currentMediaItem ?: return Progression.nil()
|
||||||
|
@ -147,7 +142,7 @@ class ExoPlaybackStateHolder(
|
||||||
emptyList()
|
emptyList()
|
||||||
}
|
}
|
||||||
return RawQueue(
|
return RawQueue(
|
||||||
heap.mapNotNull { it.toSong(deviceLibrary) },
|
heap.mapNotNull { it.song },
|
||||||
shuffledMapping,
|
shuffledMapping,
|
||||||
player.currentMediaItemIndex)
|
player.currentMediaItemIndex)
|
||||||
}
|
}
|
||||||
|
@ -226,7 +221,7 @@ class ExoPlaybackStateHolder(
|
||||||
override fun newPlayback(command: PlaybackCommand) {
|
override fun newPlayback(command: PlaybackCommand) {
|
||||||
parent = command.parent
|
parent = command.parent
|
||||||
player.shuffleModeEnabled = command.shuffled
|
player.shuffleModeEnabled = command.shuffled
|
||||||
player.setMediaItems(command.queue.map { it.toMediaItem(context, null) })
|
player.setMediaItems(command.queue.map { it.buildMediaItem() })
|
||||||
val startIndex =
|
val startIndex =
|
||||||
command.song
|
command.song
|
||||||
?.let { command.queue.indexOf(it) }
|
?.let { command.queue.indexOf(it) }
|
||||||
|
@ -316,16 +311,16 @@ class ExoPlaybackStateHolder(
|
||||||
}
|
}
|
||||||
|
|
||||||
if (nextIndex == C.INDEX_UNSET) {
|
if (nextIndex == C.INDEX_UNSET) {
|
||||||
player.addMediaItems(songs.map { it.toMediaItem(context, null) })
|
player.addMediaItems(songs.map { it.buildMediaItem() })
|
||||||
} else {
|
} else {
|
||||||
player.addMediaItems(nextIndex, songs.map { it.toMediaItem(context, null) })
|
player.addMediaItems(nextIndex, songs.map { it.buildMediaItem() })
|
||||||
}
|
}
|
||||||
playbackManager.ack(this, ack)
|
playbackManager.ack(this, ack)
|
||||||
deferSave()
|
deferSave()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun addToQueue(songs: List<Song>, ack: StateAck.AddToQueue) {
|
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)
|
playbackManager.ack(this, ack)
|
||||||
deferSave()
|
deferSave()
|
||||||
}
|
}
|
||||||
|
@ -382,7 +377,7 @@ class ExoPlaybackStateHolder(
|
||||||
sendEvent = true
|
sendEvent = true
|
||||||
}
|
}
|
||||||
if (rawQueue != resolveQueue()) {
|
if (rawQueue != resolveQueue()) {
|
||||||
player.setMediaItems(rawQueue.heap.map { it.toMediaItem(context, null) })
|
player.setMediaItems(rawQueue.heap.map { it.buildMediaItem() })
|
||||||
if (rawQueue.isShuffled) {
|
if (rawQueue.isShuffled) {
|
||||||
player.shuffleModeEnabled = true
|
player.shuffleModeEnabled = true
|
||||||
player.setShuffleOrder(BetterShuffleOrder(rawQueue.shuffledMapping.toIntArray()))
|
player.setShuffleOrder(BetterShuffleOrder(rawQueue.shuffledMapping.toIntArray()))
|
||||||
|
@ -538,6 +533,52 @@ class ExoPlaybackStateHolder(
|
||||||
currentSaveJob = saveScope.launch { block() }
|
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
|
class Factory
|
||||||
@Inject
|
@Inject
|
||||||
constructor(
|
constructor(
|
||||||
|
|
|
@ -39,16 +39,25 @@ import org.oxycblt.auxio.IntegerTable
|
||||||
import org.oxycblt.auxio.R
|
import org.oxycblt.auxio.R
|
||||||
import org.oxycblt.auxio.image.BitmapProvider
|
import org.oxycblt.auxio.image.BitmapProvider
|
||||||
import org.oxycblt.auxio.image.ImageSettings
|
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.MusicParent
|
||||||
|
import org.oxycblt.auxio.music.MusicRepository
|
||||||
|
import org.oxycblt.auxio.music.Playlist
|
||||||
import org.oxycblt.auxio.music.Song
|
import org.oxycblt.auxio.music.Song
|
||||||
import org.oxycblt.auxio.music.resolveNames
|
import org.oxycblt.auxio.music.resolveNames
|
||||||
|
import org.oxycblt.auxio.music.service.MediaSessionUID
|
||||||
import org.oxycblt.auxio.playback.ActionMode
|
import org.oxycblt.auxio.playback.ActionMode
|
||||||
import org.oxycblt.auxio.playback.PlaybackSettings
|
import org.oxycblt.auxio.playback.PlaybackSettings
|
||||||
import org.oxycblt.auxio.playback.service.PlaybackActions
|
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.PlaybackStateManager
|
||||||
import org.oxycblt.auxio.playback.state.Progression
|
import org.oxycblt.auxio.playback.state.Progression
|
||||||
import org.oxycblt.auxio.playback.state.QueueChange
|
import org.oxycblt.auxio.playback.state.QueueChange
|
||||||
import org.oxycblt.auxio.playback.state.RepeatMode
|
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.logD
|
||||||
import org.oxycblt.auxio.util.newBroadcastPendingIntent
|
import org.oxycblt.auxio.util.newBroadcastPendingIntent
|
||||||
import org.oxycblt.auxio.util.newMainPendingIntent
|
import org.oxycblt.auxio.util.newMainPendingIntent
|
||||||
|
@ -64,6 +73,8 @@ private constructor(
|
||||||
private val context: Context,
|
private val context: Context,
|
||||||
private val playbackManager: PlaybackStateManager,
|
private val playbackManager: PlaybackStateManager,
|
||||||
private val playbackSettings: PlaybackSettings,
|
private val playbackSettings: PlaybackSettings,
|
||||||
|
private val commandFactory: PlaybackCommand.Factory,
|
||||||
|
private val musicRepository: MusicRepository,
|
||||||
private val bitmapProvider: BitmapProvider,
|
private val bitmapProvider: BitmapProvider,
|
||||||
private val imageSettings: ImageSettings
|
private val imageSettings: ImageSettings
|
||||||
) :
|
) :
|
||||||
|
@ -77,12 +88,14 @@ private constructor(
|
||||||
constructor(
|
constructor(
|
||||||
private val playbackManager: PlaybackStateManager,
|
private val playbackManager: PlaybackStateManager,
|
||||||
private val playbackSettings: PlaybackSettings,
|
private val playbackSettings: PlaybackSettings,
|
||||||
|
private val commandFactory: PlaybackCommand.Factory,
|
||||||
|
private val musicRepository: MusicRepository,
|
||||||
private val bitmapProvider: BitmapProvider,
|
private val bitmapProvider: BitmapProvider,
|
||||||
private val imageSettings: ImageSettings
|
private val imageSettings: ImageSettings
|
||||||
) {
|
) {
|
||||||
fun create(context: Context) =
|
fun create(context: Context) =
|
||||||
MediaSessionHolder(
|
MediaSessionHolder(
|
||||||
context, playbackManager, playbackSettings, bitmapProvider, imageSettings)
|
context, playbackManager, playbackSettings, commandFactory, musicRepository, bitmapProvider, imageSettings)
|
||||||
}
|
}
|
||||||
|
|
||||||
private val mediaSession =
|
private val mediaSession =
|
||||||
|
@ -201,27 +214,47 @@ private constructor(
|
||||||
|
|
||||||
override fun onPlayFromMediaId(mediaId: String?, extras: Bundle?) {
|
override fun onPlayFromMediaId(mediaId: String?, extras: Bundle?) {
|
||||||
super.onPlayFromMediaId(mediaId, extras)
|
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?) {
|
override fun onPlayFromUri(uri: Uri?, extras: Bundle?) {
|
||||||
super.onPlayFromUri(uri, extras)
|
super.onPlayFromUri(uri, extras)
|
||||||
// STUB: Unimplemented, no media browser
|
// STUB
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onPlayFromSearch(query: String?, extras: Bundle?) {
|
override fun onPlayFromSearch(query: String?, extras: Bundle?) {
|
||||||
super.onPlayFromSearch(query, extras)
|
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)
|
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)
|
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() {
|
override fun onPlay() {
|
||||||
|
@ -392,6 +425,40 @@ private constructor(
|
||||||
mediaSession.setQueue(queueItems)
|
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]. */
|
/** Invalidate the current [MediaSessionCompat]'s [PlaybackStateCompat]. */
|
||||||
private fun invalidateSessionState() {
|
private fun invalidateSessionState() {
|
||||||
logD("Updating media session playback state")
|
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)
|
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(
|
// override fun onGetLibraryRoot(
|
||||||
// session: MediaLibrarySession,
|
// session: MediaLibrarySession,
|
||||||
// browser: MediaSession.ControllerInfo,
|
// browser: MediaSession.ControllerInfo,
|
||||||
|
|
|
@ -163,6 +163,7 @@
|
||||||
<string name="lbl_reset">Reset</string>
|
<string name="lbl_reset">Reset</string>
|
||||||
<!-- As in to add a new folder in the "Music folders" setting -->
|
<!-- As in to add a new folder in the "Music folders" setting -->
|
||||||
<string name="lbl_add">Add</string>
|
<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">Path style</string>
|
||||||
<string name="lbl_path_style_absolute">Absolute</string>
|
<string name="lbl_path_style_absolute">Absolute</string>
|
||||||
|
|
Loading…
Reference in a new issue