mirror of
https://github.com/TeamNewPipe/NewPipe
synced 2025-04-08 19:56:44 +00:00
Add MediaBrowserImpl
This class implements the media browser service interface as a standalone class for clearer separation of concerns (otherwise everything would need to go in PlayerService, since PlayerService overrides MediaBrowserServiceCompat) Co-authored-by: Haggai Eran <haggai.eran@gmail.com> Co-authored-by: Profpatsch <mail@profpatsch.de>
This commit is contained in:
parent
3fcac10e7f
commit
4c88a193bd
@ -0,0 +1,413 @@
|
||||
package org.schabi.newpipe.player.mediabrowser
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.support.v4.media.MediaBrowserCompat
|
||||
import android.support.v4.media.MediaDescriptionCompat
|
||||
import android.util.Log
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.media.MediaBrowserServiceCompat
|
||||
import androidx.media.MediaBrowserServiceCompat.Result
|
||||
import androidx.media.utils.MediaConstants
|
||||
import io.reactivex.rxjava3.core.Flowable
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.core.SingleSource
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import org.schabi.newpipe.MainActivity.DEBUG
|
||||
import org.schabi.newpipe.NewPipeDatabase
|
||||
import org.schabi.newpipe.R
|
||||
import org.schabi.newpipe.database.history.model.StreamHistoryEntry
|
||||
import org.schabi.newpipe.database.playlist.PlaylistLocalItem
|
||||
import org.schabi.newpipe.database.playlist.PlaylistStreamEntry
|
||||
import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity
|
||||
import org.schabi.newpipe.extractor.InfoItem
|
||||
import org.schabi.newpipe.extractor.InfoItem.InfoType
|
||||
import org.schabi.newpipe.extractor.ListInfo
|
||||
import org.schabi.newpipe.extractor.channel.ChannelInfoItem
|
||||
import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException
|
||||
import org.schabi.newpipe.extractor.exceptions.ContentNotSupportedException
|
||||
import org.schabi.newpipe.extractor.playlist.PlaylistInfoItem
|
||||
import org.schabi.newpipe.extractor.search.SearchInfo
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem
|
||||
import org.schabi.newpipe.local.bookmark.MergedPlaylistManager
|
||||
import org.schabi.newpipe.local.playlist.LocalPlaylistManager
|
||||
import org.schabi.newpipe.local.playlist.RemotePlaylistManager
|
||||
import org.schabi.newpipe.util.ExtractorHelper
|
||||
import org.schabi.newpipe.util.ServiceHelper
|
||||
import org.schabi.newpipe.util.image.ImageStrategy
|
||||
import java.util.function.Consumer
|
||||
|
||||
/**
|
||||
* This class is used to cleanly separate the Service implementation (in
|
||||
* [org.schabi.newpipe.player.PlayerService]) and the media browser implementation (in this file).
|
||||
*
|
||||
* @param notifyChildrenChanged takes the parent id of the children that changed
|
||||
*/
|
||||
class MediaBrowserImpl(
|
||||
private val context: Context,
|
||||
notifyChildrenChanged: Consumer<String>, // parentId
|
||||
) {
|
||||
private val database = NewPipeDatabase.getInstance(context)
|
||||
private var disposables = CompositeDisposable()
|
||||
|
||||
init {
|
||||
// this will listen to changes in the bookmarks until this MediaBrowserImpl is dispose()d
|
||||
disposables.add(
|
||||
getMergedPlaylists().subscribe { notifyChildrenChanged.accept(ID_BOOKMARKS) }
|
||||
)
|
||||
}
|
||||
|
||||
//region Cleanup
|
||||
fun dispose() {
|
||||
disposables.dispose()
|
||||
}
|
||||
//endregion
|
||||
|
||||
//region onGetRoot
|
||||
fun onGetRoot(
|
||||
clientPackageName: String,
|
||||
clientUid: Int,
|
||||
rootHints: Bundle?
|
||||
): MediaBrowserServiceCompat.BrowserRoot {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "onGetRoot($clientPackageName, $clientUid, $rootHints)")
|
||||
}
|
||||
|
||||
val extras = Bundle()
|
||||
extras.putBoolean(
|
||||
MediaConstants.BROWSER_SERVICE_EXTRAS_KEY_SEARCH_SUPPORTED, true
|
||||
)
|
||||
return MediaBrowserServiceCompat.BrowserRoot(ID_ROOT, extras)
|
||||
}
|
||||
//endregion
|
||||
|
||||
//region onLoadChildren
|
||||
fun onLoadChildren(parentId: String, result: Result<List<MediaBrowserCompat.MediaItem>>) {
|
||||
result.detach() // allows sendResult() to happen later
|
||||
disposables.add(
|
||||
onLoadChildren(parentId)
|
||||
.subscribe(
|
||||
{ result.sendResult(it) },
|
||||
{ throwable ->
|
||||
// null indicates an error, see the docs of MediaSessionCompat.onSearch()
|
||||
result.sendResult(null)
|
||||
Log.e(TAG, "onLoadChildren error for parentId=$parentId: $throwable")
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun onLoadChildren(parentId: String): Single<List<MediaBrowserCompat.MediaItem>> {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "onLoadChildren($parentId)")
|
||||
}
|
||||
|
||||
try {
|
||||
val parentIdUri = Uri.parse(parentId)
|
||||
val path = ArrayList(parentIdUri.pathSegments)
|
||||
|
||||
if (path.isEmpty()) {
|
||||
return Single.just(
|
||||
listOf(
|
||||
createRootMediaItem(
|
||||
ID_BOOKMARKS,
|
||||
context.resources.getString(R.string.tab_bookmarks_short),
|
||||
R.drawable.ic_bookmark_white
|
||||
),
|
||||
createRootMediaItem(
|
||||
ID_HISTORY,
|
||||
context.resources.getString(R.string.action_history),
|
||||
R.drawable.ic_history_white
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
when (/*val uriType = */path.removeAt(0)) {
|
||||
ID_BOOKMARKS -> {
|
||||
if (path.isEmpty()) {
|
||||
return populateBookmarks()
|
||||
}
|
||||
if (path.size == 2) {
|
||||
val localOrRemote = path[0]
|
||||
val playlistId = path[1].toLong()
|
||||
if (localOrRemote == ID_LOCAL) {
|
||||
return populateLocalPlaylist(playlistId)
|
||||
} else if (localOrRemote == ID_REMOTE) {
|
||||
return populateRemotePlaylist(playlistId)
|
||||
}
|
||||
}
|
||||
Log.w(TAG, "Unknown playlist URI: $parentId")
|
||||
throw parseError(parentId)
|
||||
}
|
||||
|
||||
ID_HISTORY -> return populateHistory()
|
||||
|
||||
else -> throw parseError(parentId)
|
||||
}
|
||||
} catch (e: ContentNotAvailableException) {
|
||||
return Single.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createRootMediaItem(
|
||||
mediaId: String?,
|
||||
folderName: String?,
|
||||
@DrawableRes iconResId: Int
|
||||
): MediaBrowserCompat.MediaItem {
|
||||
val builder = MediaDescriptionCompat.Builder()
|
||||
builder.setMediaId(mediaId)
|
||||
builder.setTitle(folderName)
|
||||
val resources = context.resources
|
||||
builder.setIconUri(
|
||||
Uri.Builder()
|
||||
.scheme(ContentResolver.SCHEME_ANDROID_RESOURCE)
|
||||
.authority(resources.getResourcePackageName(iconResId))
|
||||
.appendPath(resources.getResourceTypeName(iconResId))
|
||||
.appendPath(resources.getResourceEntryName(iconResId))
|
||||
.build()
|
||||
)
|
||||
|
||||
val extras = Bundle()
|
||||
extras.putString(
|
||||
MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE,
|
||||
context.getString(R.string.app_name)
|
||||
)
|
||||
builder.setExtras(extras)
|
||||
return MediaBrowserCompat.MediaItem(
|
||||
builder.build(),
|
||||
MediaBrowserCompat.MediaItem.FLAG_BROWSABLE
|
||||
)
|
||||
}
|
||||
|
||||
private fun createPlaylistMediaItem(playlist: PlaylistLocalItem): MediaBrowserCompat.MediaItem {
|
||||
val builder = MediaDescriptionCompat.Builder()
|
||||
builder
|
||||
.setMediaId(createMediaIdForInfoItem(playlist is PlaylistRemoteEntity, playlist.uid))
|
||||
.setTitle(playlist.orderingName)
|
||||
.setIconUri(playlist.thumbnailUrl?.let { Uri.parse(it) })
|
||||
|
||||
val extras = Bundle()
|
||||
extras.putString(
|
||||
MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE,
|
||||
context.resources.getString(R.string.tab_bookmarks),
|
||||
)
|
||||
builder.setExtras(extras)
|
||||
return MediaBrowserCompat.MediaItem(
|
||||
builder.build(),
|
||||
MediaBrowserCompat.MediaItem.FLAG_BROWSABLE,
|
||||
)
|
||||
}
|
||||
|
||||
private fun createInfoItemMediaItem(item: InfoItem): MediaBrowserCompat.MediaItem? {
|
||||
val builder = MediaDescriptionCompat.Builder()
|
||||
builder.setMediaId(createMediaIdForInfoItem(item))
|
||||
.setTitle(item.name)
|
||||
|
||||
when (item.infoType) {
|
||||
InfoType.STREAM -> builder.setSubtitle((item as StreamInfoItem).uploaderName)
|
||||
InfoType.PLAYLIST -> builder.setSubtitle((item as PlaylistInfoItem).uploaderName)
|
||||
InfoType.CHANNEL -> builder.setSubtitle((item as ChannelInfoItem).description)
|
||||
else -> return null
|
||||
}
|
||||
|
||||
ImageStrategy.choosePreferredImage(item.thumbnails)?.let {
|
||||
builder.setIconUri(Uri.parse(it))
|
||||
}
|
||||
|
||||
return MediaBrowserCompat.MediaItem(
|
||||
builder.build(),
|
||||
MediaBrowserCompat.MediaItem.FLAG_PLAYABLE
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildMediaId(): Uri.Builder {
|
||||
return Uri.Builder().authority(ID_AUTHORITY)
|
||||
}
|
||||
|
||||
private fun buildPlaylistMediaId(playlistType: String?): Uri.Builder {
|
||||
return buildMediaId()
|
||||
.appendPath(ID_BOOKMARKS)
|
||||
.appendPath(playlistType)
|
||||
}
|
||||
|
||||
private fun buildLocalPlaylistItemMediaId(isRemote: Boolean, playlistId: Long): Uri.Builder {
|
||||
return buildPlaylistMediaId(if (isRemote) ID_REMOTE else ID_LOCAL)
|
||||
.appendPath(playlistId.toString())
|
||||
}
|
||||
|
||||
private fun buildInfoItemMediaId(item: InfoItem): Uri.Builder {
|
||||
return buildMediaId()
|
||||
.appendPath(ID_INFO_ITEM)
|
||||
.appendPath(infoItemTypeToString(item.infoType))
|
||||
.appendPath(item.serviceId.toString())
|
||||
.appendQueryParameter(ID_URL, item.url)
|
||||
}
|
||||
|
||||
private fun createMediaIdForInfoItem(isRemote: Boolean, playlistId: Long): String {
|
||||
return buildLocalPlaylistItemMediaId(isRemote, playlistId)
|
||||
.build().toString()
|
||||
}
|
||||
|
||||
private fun createLocalPlaylistStreamMediaItem(
|
||||
playlistId: Long,
|
||||
item: PlaylistStreamEntry,
|
||||
index: Int,
|
||||
): MediaBrowserCompat.MediaItem {
|
||||
val builder = MediaDescriptionCompat.Builder()
|
||||
builder.setMediaId(createMediaIdForPlaylistIndex(false, playlistId, index))
|
||||
.setTitle(item.streamEntity.title)
|
||||
.setSubtitle(item.streamEntity.uploader)
|
||||
.setIconUri(Uri.parse(item.streamEntity.thumbnailUrl))
|
||||
|
||||
return MediaBrowserCompat.MediaItem(
|
||||
builder.build(),
|
||||
MediaBrowserCompat.MediaItem.FLAG_PLAYABLE
|
||||
)
|
||||
}
|
||||
|
||||
private fun createRemotePlaylistStreamMediaItem(
|
||||
playlistId: Long,
|
||||
item: StreamInfoItem,
|
||||
index: Int,
|
||||
): MediaBrowserCompat.MediaItem {
|
||||
val builder = MediaDescriptionCompat.Builder()
|
||||
builder.setMediaId(createMediaIdForPlaylistIndex(true, playlistId, index))
|
||||
.setTitle(item.name)
|
||||
.setSubtitle(item.uploaderName)
|
||||
|
||||
ImageStrategy.choosePreferredImage(item.thumbnails)?.let {
|
||||
builder.setIconUri(Uri.parse(it))
|
||||
}
|
||||
|
||||
return MediaBrowserCompat.MediaItem(
|
||||
builder.build(),
|
||||
MediaBrowserCompat.MediaItem.FLAG_PLAYABLE
|
||||
)
|
||||
}
|
||||
|
||||
private fun createMediaIdForPlaylistIndex(
|
||||
isRemote: Boolean,
|
||||
playlistId: Long,
|
||||
index: Int,
|
||||
): String {
|
||||
return buildLocalPlaylistItemMediaId(isRemote, playlistId)
|
||||
.appendPath(index.toString())
|
||||
.build().toString()
|
||||
}
|
||||
|
||||
private fun createMediaIdForInfoItem(item: InfoItem): String {
|
||||
return buildInfoItemMediaId(item).build().toString()
|
||||
}
|
||||
|
||||
private fun populateHistory(): Single<List<MediaBrowserCompat.MediaItem>> {
|
||||
val history = database.streamHistoryDAO().getHistory().firstOrError()
|
||||
return history.map { items ->
|
||||
items.map { this.createHistoryMediaItem(it) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun createHistoryMediaItem(streamHistoryEntry: StreamHistoryEntry): MediaBrowserCompat.MediaItem {
|
||||
val builder = MediaDescriptionCompat.Builder()
|
||||
val mediaId = buildMediaId()
|
||||
.appendPath(ID_HISTORY)
|
||||
.appendPath(streamHistoryEntry.streamId.toString())
|
||||
.build().toString()
|
||||
builder.setMediaId(mediaId)
|
||||
.setTitle(streamHistoryEntry.streamEntity.title)
|
||||
.setSubtitle(streamHistoryEntry.streamEntity.uploader)
|
||||
.setIconUri(Uri.parse(streamHistoryEntry.streamEntity.thumbnailUrl))
|
||||
|
||||
return MediaBrowserCompat.MediaItem(
|
||||
builder.build(),
|
||||
MediaBrowserCompat.MediaItem.FLAG_PLAYABLE
|
||||
)
|
||||
}
|
||||
|
||||
private fun getMergedPlaylists(): Flowable<MutableList<PlaylistLocalItem>> {
|
||||
return MergedPlaylistManager.getMergedOrderedPlaylists(
|
||||
LocalPlaylistManager(database),
|
||||
RemotePlaylistManager(database)
|
||||
)
|
||||
}
|
||||
|
||||
private fun populateBookmarks(): Single<List<MediaBrowserCompat.MediaItem>> {
|
||||
val playlists = getMergedPlaylists().firstOrError()
|
||||
return playlists.map { playlist ->
|
||||
playlist.map { this.createPlaylistMediaItem(it) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun populateLocalPlaylist(playlistId: Long): Single<List<MediaBrowserCompat.MediaItem>> {
|
||||
val playlist = LocalPlaylistManager(database).getPlaylistStreams(playlistId).firstOrError()
|
||||
return playlist.map { items ->
|
||||
items.mapIndexed { index, item ->
|
||||
createLocalPlaylistStreamMediaItem(playlistId, item, index)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun populateRemotePlaylist(playlistId: Long): Single<List<MediaBrowserCompat.MediaItem>> {
|
||||
return RemotePlaylistManager(database).getPlaylist(playlistId).firstOrError()
|
||||
.flatMap { ExtractorHelper.getPlaylistInfo(it.serviceId, it.url, false) }
|
||||
.flatMap { info ->
|
||||
info.errors.firstOrNull { it !is ContentNotSupportedException }?.let {
|
||||
return@flatMap Single.error(it)
|
||||
}
|
||||
Single.just(
|
||||
info.relatedItems.mapIndexed { index, item ->
|
||||
createRemotePlaylistStreamMediaItem(playlistId, item, index)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
//endregion
|
||||
|
||||
//region Search
|
||||
fun onSearch(
|
||||
query: String,
|
||||
result: Result<List<MediaBrowserCompat.MediaItem>>
|
||||
) {
|
||||
result.detach() // allows sendResult() to happen later
|
||||
disposables.add(
|
||||
searchMusicBySongTitle(query)
|
||||
.flatMap { this.mediaItemsFromInfoItemList(it) }
|
||||
.subscribeOn(Schedulers.io())
|
||||
.subscribe(
|
||||
{ result.sendResult(it) },
|
||||
{ throwable ->
|
||||
// null indicates an error, see the docs of MediaSessionCompat.onSearch()
|
||||
result.sendResult(null)
|
||||
Log.e(TAG, "Search error for query=\"$query\": $throwable")
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun searchMusicBySongTitle(query: String?): Single<SearchInfo> {
|
||||
val serviceId = ServiceHelper.getSelectedServiceId(context)
|
||||
return ExtractorHelper.searchFor(serviceId, query, listOf(), "")
|
||||
}
|
||||
|
||||
private fun mediaItemsFromInfoItemList(
|
||||
result: ListInfo<InfoItem>
|
||||
): SingleSource<List<MediaBrowserCompat.MediaItem>> {
|
||||
result.errors.firstOrNull()?.let { return@mediaItemsFromInfoItemList Single.error(it) }
|
||||
|
||||
return try {
|
||||
Single.just(
|
||||
result.relatedItems.mapNotNull { item -> this.createInfoItemMediaItem(item) }
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Single.error(e)
|
||||
}
|
||||
}
|
||||
//endregion
|
||||
|
||||
companion object {
|
||||
private val TAG: String = MediaBrowserImpl::class.java.getSimpleName()
|
||||
}
|
||||
}
|
10
app/src/main/res/drawable/ic_bookmark_white.xml
Normal file
10
app/src/main/res/drawable/ic_bookmark_white.xml
Normal file
@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:tint="@color/white"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M17,3H7c-1.1,0 -1.99,0.9 -1.99,2L5,21l7,-3 7,3V5c0,-1.1 -0.9,-2 -2,-2z" />
|
||||
</vector>
|
10
app/src/main/res/drawable/ic_history_white.xml
Normal file
10
app/src/main/res/drawable/ic_history_white.xml
Normal file
@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:tint="@color/white"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M13,3c-4.97,0 -9,4.03 -9,9L1,12l3.89,3.89 0.07,0.14L9,12L6,12c0,-3.87 3.13,-7 7,-7s7,3.13 7,7 -3.13,7 -7,7c-1.93,0 -3.68,-0.79 -4.94,-2.06l-1.42,1.42C8.27,19.99 10.51,21 13,21c4.97,0 9,-4.03 9,-9s-4.03,-9 -9,-9zM12,8v5l4.28,2.54 0.72,-1.21 -3.5,-2.08L13.5,8L12,8z" />
|
||||
</vector>
|
@ -33,6 +33,7 @@
|
||||
<string name="show_info">Show info</string>
|
||||
<string name="tab_subscriptions">Subscriptions</string>
|
||||
<string name="tab_bookmarks">Bookmarked Playlists</string>
|
||||
<string name="tab_bookmarks_short">Playlists</string>
|
||||
<string name="tab_choose">Choose Tab</string>
|
||||
<string name="controls_background_title">Background</string>
|
||||
<string name="controls_popup_title">Popup</string>
|
||||
|
Loading…
x
Reference in New Issue
Block a user