1
0
mirror of https://github.com/TeamNewPipe/NewPipe synced 2025-01-22 23:17:00 +00:00

Convert new and important files to Kotlin and optimize

This commit is contained in:
Siddhesh Naik 2024-09-03 05:29:57 +05:30
parent 89bdfef4b9
commit bf59f1e09d
12 changed files with 1056 additions and 1051 deletions

View File

@ -1,13 +0,0 @@
package org.schabi.newpipe.database;
public interface LocalItem {
LocalItemType getLocalItemType();
enum LocalItemType {
PLAYLIST_LOCAL_ITEM,
PLAYLIST_REMOTE_ITEM,
PLAYLIST_STREAM_ITEM,
STATISTIC_STREAM_ITEM,
}
}

View File

@ -0,0 +1,18 @@
package org.schabi.newpipe.database
/**
* Represents a generic item that can be stored locally. This can be a playlist, a stream, etc.
*/
interface LocalItem {
/**
* The type of local item. Can be null if the type is unknown or not applicable.
*/
val localItemType: LocalItemType?
enum class LocalItemType {
PLAYLIST_LOCAL_ITEM,
PLAYLIST_REMOTE_ITEM,
PLAYLIST_STREAM_ITEM,
STATISTIC_STREAM_ITEM,
}
}

View File

@ -30,18 +30,16 @@ data class StreamHistoryEntry(
accessDate.isEqual(other.accessDate)
}
fun toStreamInfoItem(): StreamInfoItem {
val item = StreamInfoItem(
fun toStreamInfoItem(): StreamInfoItem =
StreamInfoItem(
streamEntity.serviceId,
streamEntity.url,
streamEntity.title,
streamEntity.streamType
)
item.duration = streamEntity.duration
item.uploaderName = streamEntity.uploader
item.uploaderUrl = streamEntity.uploaderUrl
item.thumbnails = ImageStrategy.dbUrlToImageList(streamEntity.thumbnailUrl)
return item
}
streamEntity.streamType,
).apply {
duration = streamEntity.duration
uploaderName = streamEntity.uploader
uploaderUrl = streamEntity.uploaderUrl
thumbnails = ImageStrategy.dbUrlToImageList(streamEntity.thumbnailUrl)
}
}

View File

@ -1,15 +0,0 @@
package org.schabi.newpipe.database.playlist;
import org.schabi.newpipe.database.LocalItem;
public interface PlaylistLocalItem extends LocalItem {
String getOrderingName();
long getDisplayIndex();
long getUid();
void setDisplayIndex(long displayIndex);
String getThumbnailUrl();
}

View File

@ -0,0 +1,28 @@
package org.schabi.newpipe.database.playlist
import org.schabi.newpipe.database.LocalItem
/**
* Represents a playlist item stored locally.
*/
interface PlaylistLocalItem : LocalItem {
/**
* The name used for ordering this item within the playlist. Can be null.
*/
val orderingName: String?
/**
* The index used to display this item within the playlist.
*/
var displayIndex: Long
/**
* The unique identifier for this playlist item.
*/
val uid: Long
/**
* The URL of the thumbnail image for this playlist item. Can be null.
*/
val thumbnailUrl: String?
}

View File

@ -22,19 +22,20 @@ data class PlaylistStreamEntry(
@ColumnInfo(name = PlaylistStreamEntity.JOIN_INDEX)
val joinIndex: Int
) : LocalItem {
@Throws(IllegalArgumentException::class)
fun toStreamInfoItem(): StreamInfoItem {
val item = StreamInfoItem(streamEntity.serviceId, streamEntity.url, streamEntity.title, streamEntity.streamType)
item.duration = streamEntity.duration
item.uploaderName = streamEntity.uploader
item.uploaderUrl = streamEntity.uploaderUrl
item.thumbnails = ImageStrategy.dbUrlToImageList(streamEntity.thumbnailUrl)
fun toStreamInfoItem() =
StreamInfoItem(
streamEntity.serviceId,
streamEntity.url,
streamEntity.title,
streamEntity.streamType,
).apply {
duration = streamEntity.duration
uploaderName = streamEntity.uploader
uploaderUrl = streamEntity.uploaderUrl
thumbnails = ImageStrategy.dbUrlToImageList(streamEntity.thumbnailUrl)
}
return item
}
override fun getLocalItemType(): LocalItem.LocalItemType {
return LocalItem.LocalItemType.PLAYLIST_STREAM_ITEM
}
override val localItemType: LocalItem.LocalItemType
get() = LocalItem.LocalItemType.PLAYLIST_STREAM_ITEM
}

View File

@ -26,19 +26,21 @@ class StreamStatisticsEntry(
@ColumnInfo(name = STREAM_WATCH_COUNT)
val watchCount: Long
) : LocalItem {
fun toStreamInfoItem(): StreamInfoItem {
val item = StreamInfoItem(streamEntity.serviceId, streamEntity.url, streamEntity.title, streamEntity.streamType)
item.duration = streamEntity.duration
item.uploaderName = streamEntity.uploader
item.uploaderUrl = streamEntity.uploaderUrl
item.thumbnails = ImageStrategy.dbUrlToImageList(streamEntity.thumbnailUrl)
fun toStreamInfoItem() =
StreamInfoItem(
streamEntity.serviceId,
streamEntity.url,
streamEntity.title,
streamEntity.streamType,
).apply {
duration = streamEntity.duration
uploaderName = streamEntity.uploader
uploaderUrl = streamEntity.uploaderUrl
thumbnails = ImageStrategy.dbUrlToImageList(streamEntity.thumbnailUrl)
}
return item
}
override fun getLocalItemType(): LocalItem.LocalItemType {
return LocalItem.LocalItemType.STATISTIC_STREAM_ITEM
}
override val localItemType: LocalItem.LocalItemType
get() = LocalItem.LocalItemType.STATISTIC_STREAM_ITEM
companion object {
const val STREAM_LATEST_DATE = "latestAccess"

View File

@ -45,6 +45,7 @@ import static org.schabi.newpipe.player.notification.NotificationConstants.ACTIO
import static org.schabi.newpipe.util.ListHelper.getPopupResolutionIndex;
import static org.schabi.newpipe.util.ListHelper.getResolutionIndex;
import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage;
import static java.util.Objects.requireNonNull;
import static java.util.concurrent.TimeUnit.MILLISECONDS;
import android.content.BroadcastReceiver;
@ -417,7 +418,7 @@ public final class Player implements PlaybackListener, Listener {
}
if (playQueue.getIndex() != newQueue.getIndex()) {
simpleExoPlayer.seekTo(newQueue.getIndex(),
newQueue.getItem().getRecoveryPosition());
requireNonNull(newQueue.getItem()).getRecoveryPosition());
}
simpleExoPlayer.setPlayWhenReady(playWhenReady);

View File

@ -1,256 +0,0 @@
/*
* Copyright 2017 Mauricio Colli <mauriciocolli@outlook.com>
* Part of NewPipe
*
* License: GPL-3.0+
* 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 <http://www.gnu.org/licenses/>.
*/
package org.schabi.newpipe.player;
import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage;
import android.content.Context;
import android.content.Intent;
import android.os.Binder;
import android.os.Bundle;
import android.os.IBinder;
import android.support.v4.media.MediaBrowserCompat.MediaItem;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.media.MediaBrowserServiceCompat;
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector;
import org.schabi.newpipe.player.mediabrowser.MediaBrowserConnector;
import org.schabi.newpipe.player.mediasession.MediaSessionPlayerUi;
import org.schabi.newpipe.player.notification.NotificationPlayerUi;
import org.schabi.newpipe.util.ThemeHelper;
import java.lang.ref.WeakReference;
import java.util.List;
import java.util.Objects;
import io.reactivex.rxjava3.disposables.CompositeDisposable;
import io.reactivex.rxjava3.disposables.Disposable;
/**
* One service for all players.
*/
public final class PlayerService extends MediaBrowserServiceCompat {
private static final String TAG = PlayerService.class.getSimpleName();
private static final boolean DEBUG = Player.DEBUG;
@Nullable
private Player player;
private final IBinder mBinder = new PlayerService.LocalBinder(this);
private MediaBrowserConnector mediaBrowserConnector;
private final CompositeDisposable compositeDisposableLoadChildren = new CompositeDisposable();
/*//////////////////////////////////////////////////////////////////////////
// Service's LifeCycle
//////////////////////////////////////////////////////////////////////////*/
@Override
public void onCreate() {
super.onCreate();
if (DEBUG) {
Log.d(TAG, "onCreate() called");
}
assureCorrectAppLanguage(this);
ThemeHelper.setTheme(this);
mediaBrowserConnector = new MediaBrowserConnector(this);
}
private void initializePlayerIfNeeded() {
if (player == null) {
player = new Player(this);
/*
Create the player notification and start immediately the service in foreground,
otherwise if nothing is played or initializing the player and its components (especially
loading stream metadata) takes a lot of time, the app would crash on Android 8+ as the
service would never be put in the foreground while we said to the system we would do so
*/
player.UIs().get(NotificationPlayerUi.class)
.ifPresent(NotificationPlayerUi::createNotificationAndStartForeground);
}
}
// Suppress Sonar warning to not always return the same value, as we need to do some actions
// before returning
@SuppressWarnings("squid:S3516")
@Override
public int onStartCommand(final Intent intent, final int flags, final int startId) {
if (DEBUG) {
Log.d(TAG, "onStartCommand() called with: intent = [" + intent
+ "], flags = [" + flags + "], startId = [" + startId + "]");
}
/*
Be sure that the player notification is set and the service is started in foreground,
otherwise, the app may crash on Android 8+ as the service would never be put in the
foreground while we said to the system we would do so
The service is always requested to be started in foreground, so always creating a
notification if there is no one already and starting the service in foreground should
not create any issues
If the service is already started in foreground, requesting it to be started shouldn't
do anything
*/
if (player != null) {
player.UIs().get(NotificationPlayerUi.class)
.ifPresent(NotificationPlayerUi::createNotificationAndStartForeground);
}
if (Intent.ACTION_MEDIA_BUTTON.equals(intent.getAction())
&& (player == null || player.getPlayQueue() == null)) {
/*
No need to process media button's actions if the player is not working, otherwise
the player service would strangely start with nothing to play
Stop the service in this case, which will be removed from the foreground and its
notification cancelled in its destruction
*/
stopSelf();
return START_NOT_STICKY;
}
initializePlayerIfNeeded();
Objects.requireNonNull(player).handleIntent(intent);
player.UIs().get(MediaSessionPlayerUi.class)
.ifPresent(ui -> ui.handleMediaButtonIntent(intent));
return START_NOT_STICKY;
}
public void stopForImmediateReusing() {
if (DEBUG) {
Log.d(TAG, "stopForImmediateReusing() called");
}
if (player != null && !player.exoPlayerIsNull()) {
// Releases wifi & cpu, disables keepScreenOn, etc.
// We can't just pause the player here because it will make transition
// from one stream to a new stream not smooth
player.smoothStopForImmediateReusing();
}
}
@Override
public void onTaskRemoved(final Intent rootIntent) {
super.onTaskRemoved(rootIntent);
if (player != null && !player.videoPlayerSelected()) {
return;
}
onDestroy();
// Unload from memory completely
Runtime.getRuntime().halt(0);
}
@Override
public void onDestroy() {
if (DEBUG) {
Log.d(TAG, "destroy() called");
}
cleanup();
if (mediaBrowserConnector != null) {
mediaBrowserConnector.release();
mediaBrowserConnector = null;
}
compositeDisposableLoadChildren.clear();
}
private void cleanup() {
if (player != null) {
player.destroy();
player = null;
}
}
public void stopService() {
cleanup();
stopSelf();
}
@Override
protected void attachBaseContext(final Context base) {
super.attachBaseContext(AudioServiceLeakFix.preventLeakOf(base));
}
@Override
public IBinder onBind(@NonNull final Intent intent) {
if (SERVICE_INTERFACE.equals(intent.getAction())) {
return super.onBind(intent);
}
return mBinder;
}
@NonNull
public MediaSessionConnector getSessionConnector() {
return mediaBrowserConnector.getSessionConnector();
}
// MediaBrowserServiceCompat methods
@Nullable
@Override
public BrowserRoot onGetRoot(@NonNull final String clientPackageName,
final int clientUid,
@Nullable final Bundle rootHints) {
return mediaBrowserConnector.onGetRoot(clientPackageName, clientUid, rootHints);
}
@Override
public void onLoadChildren(@NonNull final String parentId,
@NonNull final Result<List<MediaItem>> result) {
result.detach();
final Disposable disposable = mediaBrowserConnector.onLoadChildren(parentId)
.subscribe(result::sendResult);
compositeDisposableLoadChildren.add(disposable);
}
@Override
public void onSearch(@NonNull final String query,
final Bundle extras,
@NonNull final Result<List<MediaItem>> result) {
mediaBrowserConnector.onSearch(query, result);
}
public static final class LocalBinder extends Binder {
private final WeakReference<PlayerService> playerService;
LocalBinder(final PlayerService playerService) {
this.playerService = new WeakReference<>(playerService);
}
@Nullable
public PlayerService getService() {
return playerService.get();
}
@Nullable
public Player getPlayer() {
return playerService.get().player;
}
}
}

View File

@ -0,0 +1,236 @@
/*
* Copyright 2017 Mauricio Colli <mauriciocolli@outlook.com>
* Part of NewPipe
*
* License: GPL-3.0+
* 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 <http://www.gnu.org/licenses/>.
*/
package org.schabi.newpipe.player
import android.content.Context
import android.content.Intent
import android.os.Binder
import android.os.Bundle
import android.os.IBinder
import android.support.v4.media.MediaBrowserCompat
import android.util.Log
import androidx.media.MediaBrowserServiceCompat
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector
import io.reactivex.rxjava3.disposables.CompositeDisposable
import org.schabi.newpipe.player.mediabrowser.MediaBrowserConnector
import org.schabi.newpipe.player.mediasession.MediaSessionPlayerUi
import org.schabi.newpipe.player.notification.NotificationPlayerUi
import org.schabi.newpipe.util.Localization
import org.schabi.newpipe.util.ThemeHelper
import java.lang.ref.WeakReference
/**
* One service for all players.
*/
class PlayerService : MediaBrowserServiceCompat() {
private val player: Player by lazy {
Player(this).apply {
/*
Create the player notification and start immediately the service in foreground,
otherwise if nothing is played or initializing the player and its components (especially
loading stream metadata) takes a lot of time, the app would crash on Android 8+ as the
service would never be put in the foreground while we said to the system we would do so
*/
UIs()[NotificationPlayerUi::class.java].ifPresent {
it.createNotificationAndStartForeground()
}
}
}
private val mBinder: IBinder = LocalBinder(this)
private val compositeDisposableLoadChildren = CompositeDisposable()
private var mediaBrowserConnector: MediaBrowserConnector? = null
get() {
if (field == null) {
return MediaBrowserConnector(this)
}
return field
}
val sessionConnector: MediaSessionConnector?
get() = mediaBrowserConnector?.sessionConnector
/*//////////////////////////////////////////////////////////////////////////
// Service's LifeCycle
////////////////////////////////////////////////////////////////////////// */
override fun onCreate() {
super.onCreate()
if (DEBUG) {
Log.d(TAG, "onCreate() called")
}
Localization.assureCorrectAppLanguage(this)
ThemeHelper.setTheme(this)
/*
Create the player notification and start immediately the service in foreground,
otherwise if nothing is played or initializing the player and its components (especially
loading stream metadata) takes a lot of time, the app would crash on Android 8+ as the
service would never be put in the foreground while we said to the system we would do so
*/
player.UIs()[NotificationPlayerUi::class.java].ifPresent {
it.createNotificationAndStartForeground()
}
}
// Suppress Sonar warning to not always return the same value, as we need to do some actions
// before returning
override fun onStartCommand(
intent: Intent,
flags: Int,
startId: Int,
): Int {
if (DEBUG) {
Log.d(
TAG,
"onStartCommand() called with: intent = [" + intent + "], flags = [" + flags +
"], startId = [" + startId + "]",
)
}
/*
Be sure that the player notification is set and the service is started in foreground,
otherwise, the app may crash on Android 8+ as the service would never be put in the
foreground while we said to the system we would do so
The service is always requested to be started in foreground, so always creating a
notification if there is no one already and starting the service in foreground should
not create any issues
If the service is already started in foreground, requesting it to be started shouldn't
do anything
*/
player.UIs()[NotificationPlayerUi::class.java].ifPresent {
it.createNotificationAndStartForeground()
}
if (Intent.ACTION_MEDIA_BUTTON == intent.action && (player.playQueue == null)) {
/*
No need to process media button's actions if the player is not working, otherwise
the player service would strangely start with nothing to play
Stop the service in this case, which will be removed from the foreground and its
notification cancelled in its destruction
*/
stopSelf()
return START_NOT_STICKY
}
player.handleIntent(intent)
player.UIs()[MediaSessionPlayerUi::class.java].ifPresent {
it.handleMediaButtonIntent(intent)
}
return START_NOT_STICKY
}
fun stopForImmediateReusing() {
if (DEBUG) {
Log.d(TAG, "stopForImmediateReusing() called")
}
if (!player.exoPlayerIsNull()) {
// Releases wifi & cpu, disables keepScreenOn, etc.
// We can't just pause the player here because it will make transition
// from one stream to a new stream not smooth
player.smoothStopForImmediateReusing()
}
}
override fun onTaskRemoved(rootIntent: Intent) {
super.onTaskRemoved(rootIntent)
if (!player.videoPlayerSelected()) {
return
}
onDestroy()
// Unload from memory completely
Runtime.getRuntime().halt(0)
}
override fun onDestroy() {
super.onDestroy()
if (DEBUG) {
Log.d(TAG, "destroy() called")
}
cleanup()
mediaBrowserConnector?.release()
mediaBrowserConnector = null
compositeDisposableLoadChildren.clear()
}
private fun cleanup() {
player.destroy()
}
fun stopService() {
cleanup()
stopSelf()
}
override fun attachBaseContext(base: Context) {
super.attachBaseContext(AudioServiceLeakFix.preventLeakOf(base))
}
override fun onBind(intent: Intent): IBinder = mBinder
// MediaBrowserServiceCompat methods
override fun onGetRoot(
clientPackageName: String,
clientUid: Int,
rootHints: Bundle?,
): BrowserRoot? = mediaBrowserConnector?.onGetRoot(clientPackageName, clientUid, rootHints)
override fun onLoadChildren(
parentId: String,
result: Result<List<MediaBrowserCompat.MediaItem>>,
) {
result.detach()
mediaBrowserConnector?.let {
val disposable =
it.onLoadChildren(parentId).subscribe { mediaItems ->
result.sendResult(mediaItems)
}
compositeDisposableLoadChildren.add(disposable)
}
}
override fun onSearch(
query: String,
extras: Bundle,
result: Result<List<MediaBrowserCompat.MediaItem>>,
) {
mediaBrowserConnector?.onSearch(query, result)
}
class LocalBinder internal constructor(
playerService: PlayerService,
) : Binder() {
private val playerService = WeakReference(playerService)
val service: PlayerService?
get() = playerService.get()
fun getPlayer(): Player = service?.player ?: throw Error("Player service is null")
}
companion object {
private val TAG: String = PlayerService::class.java.simpleName
private val DEBUG = Player.DEBUG
}
}

View File

@ -1,730 +0,0 @@
package org.schabi.newpipe.player.mediabrowser;
import static org.schabi.newpipe.MainActivity.DEBUG;
import android.content.ContentResolver;
import android.content.res.Resources;
import android.net.Uri;
import android.os.Bundle;
import android.os.ResultReceiver;
import android.support.v4.media.MediaBrowserCompat.MediaItem;
import android.support.v4.media.MediaDescriptionCompat;
import android.support.v4.media.session.MediaSessionCompat;
import android.support.v4.media.session.PlaybackStateCompat;
import android.util.Log;
import android.util.Pair;
import androidx.annotation.DrawableRes;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.StringRes;
import androidx.media.MediaBrowserServiceCompat;
import androidx.media.utils.MediaConstants;
import com.google.android.exoplayer2.Player;
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector;
import org.schabi.newpipe.BuildConfig;
import org.schabi.newpipe.NewPipeDatabase;
import org.schabi.newpipe.R;
import org.schabi.newpipe.database.AppDatabase;
import org.schabi.newpipe.database.history.dao.StreamHistoryDAO;
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.error.ErrorInfo;
import org.schabi.newpipe.error.UserAction;
import org.schabi.newpipe.extractor.InfoItem;
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.linkhandler.ListLinkHandler;
import org.schabi.newpipe.extractor.playlist.PlaylistInfoItem;
import org.schabi.newpipe.extractor.stream.StreamInfoItem;
import org.schabi.newpipe.local.bookmark.MergedPlaylistManager;
import org.schabi.newpipe.extractor.search.SearchExtractor;
import org.schabi.newpipe.extractor.search.SearchInfo;
import org.schabi.newpipe.local.playlist.LocalPlaylistManager;
import org.schabi.newpipe.local.playlist.RemotePlaylistManager;
import org.schabi.newpipe.player.PlayerService;
import org.schabi.newpipe.player.playqueue.ChannelTabPlayQueue;
import org.schabi.newpipe.player.playqueue.PlayQueue;
import org.schabi.newpipe.player.playqueue.PlaylistPlayQueue;
import org.schabi.newpipe.player.playqueue.SinglePlayQueue;
import org.schabi.newpipe.util.ChannelTabHelper;
import org.schabi.newpipe.util.ExtractorHelper;
import org.schabi.newpipe.util.NavigationHelper;
import org.schabi.newpipe.util.ServiceHelper;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.core.Flowable;
import io.reactivex.rxjava3.core.Single;
import io.reactivex.rxjava3.disposables.Disposable;
import io.reactivex.rxjava3.core.SingleSource;
import io.reactivex.rxjava3.schedulers.Schedulers;
public class MediaBrowserConnector implements MediaSessionConnector.PlaybackPreparer {
private static final String TAG = MediaBrowserConnector.class.getSimpleName();
@NonNull
private final PlayerService playerService;
@NonNull
private final MediaSessionConnector sessionConnector;
@NonNull
private final MediaSessionCompat mediaSession;
private AppDatabase database;
private LocalPlaylistManager localPlaylistManager;
private RemotePlaylistManager remotePlaylistManager;
private Disposable prepareOrPlayDisposable;
private Disposable searchDisposable;
public MediaBrowserConnector(@NonNull final PlayerService playerService) {
this.playerService = playerService;
mediaSession = new MediaSessionCompat(playerService, TAG);
sessionConnector = new MediaSessionConnector(mediaSession);
sessionConnector.setMetadataDeduplicationEnabled(true);
sessionConnector.setPlaybackPreparer(this);
playerService.setSessionToken(mediaSession.getSessionToken());
setupBookmarksNotifications();
}
@NonNull
public MediaSessionConnector getSessionConnector() {
return sessionConnector;
}
public void release() {
disposePrepareOrPlayCommands();
disposeBookmarksNotifications();
mediaSession.release();
}
@NonNull
private static final String ID_AUTHORITY = BuildConfig.APPLICATION_ID;
@NonNull
private static final String ID_ROOT = "//" + ID_AUTHORITY;
@NonNull
private static final String ID_BOOKMARKS = "playlists";
@NonNull
private static final String ID_HISTORY = "history";
@NonNull
private static final String ID_INFO_ITEM = "item";
@NonNull
private static final String ID_LOCAL = "local";
@NonNull
private static final String ID_REMOTE = "remote";
@NonNull
private static final String ID_URL = "url";
@NonNull
private static final String ID_STREAM = "stream";
@NonNull
private static final String ID_PLAYLIST = "playlist";
@NonNull
private static final String ID_CHANNEL = "channel";
@NonNull
private MediaItem createRootMediaItem(@Nullable final String mediaId,
final String folderName,
@DrawableRes final int iconResId) {
final var builder = new MediaDescriptionCompat.Builder();
builder.setMediaId(mediaId);
builder.setTitle(folderName);
final Resources resources = playerService.getResources();
builder.setIconUri(new Uri.Builder()
.scheme(ContentResolver.SCHEME_ANDROID_RESOURCE)
.authority(resources.getResourcePackageName(iconResId))
.appendPath(resources.getResourceTypeName(iconResId))
.appendPath(resources.getResourceEntryName(iconResId))
.build());
final Bundle extras = new Bundle();
extras.putString(MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE,
playerService.getString(R.string.app_name));
builder.setExtras(extras);
return new MediaItem(builder.build(), MediaItem.FLAG_BROWSABLE);
}
@NonNull
private MediaItem createPlaylistMediaItem(@NonNull final PlaylistLocalItem playlist) {
final var builder = new MediaDescriptionCompat.Builder();
final boolean remote = playlist instanceof PlaylistRemoteEntity;
builder.setMediaId(createMediaIdForInfoItem(remote, playlist.getUid()))
.setTitle(playlist.getOrderingName())
.setIconUri(Uri.parse(playlist.getThumbnailUrl()));
final Bundle extras = new Bundle();
extras.putString(MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE,
playerService.getResources().getString(R.string.tab_bookmarks));
builder.setExtras(extras);
return new MediaItem(builder.build(), MediaItem.FLAG_BROWSABLE);
}
private MediaItem createInfoItemMediaItem(@NonNull final InfoItem item) {
final var builder = new MediaDescriptionCompat.Builder();
builder.setMediaId(createMediaIdForInfoItem(item))
.setTitle(item.getName());
switch (item.getInfoType()) {
case STREAM:
builder.setSubtitle(((StreamInfoItem) item).getUploaderName());
break;
case PLAYLIST:
builder.setSubtitle(((PlaylistInfoItem) item).getUploaderName());
break;
case CHANNEL:
builder.setSubtitle(((ChannelInfoItem) item).getDescription());
break;
default:
break;
}
final var thumbnails = item.getThumbnails();
if (!thumbnails.isEmpty()) {
builder.setIconUri(Uri.parse(thumbnails.get(0).getUrl()));
}
return new MediaItem(builder.build(), MediaItem.FLAG_PLAYABLE);
}
@NonNull
private Uri.Builder buildMediaId() {
return new Uri.Builder().authority(ID_AUTHORITY);
}
@NonNull
private Uri.Builder buildPlaylistMediaId(final String playlistType) {
return buildMediaId()
.appendPath(ID_BOOKMARKS)
.appendPath(playlistType);
}
@NonNull
private Uri.Builder buildLocalPlaylistItemMediaId(final boolean remote, final long playlistId) {
return buildPlaylistMediaId(remote ? ID_REMOTE : ID_LOCAL)
.appendPath(Long.toString(playlistId));
}
private static String infoItemTypeToString(final InfoItem.InfoType type) {
return switch (type) {
case STREAM -> ID_STREAM;
case PLAYLIST -> ID_PLAYLIST;
case CHANNEL -> ID_CHANNEL;
default ->
throw new IllegalStateException("Unexpected value: " + type);
};
}
private static InfoItem.InfoType infoItemTypeFromString(final String type) {
return switch (type) {
case ID_STREAM -> InfoItem.InfoType.STREAM;
case ID_PLAYLIST -> InfoItem.InfoType.PLAYLIST;
case ID_CHANNEL -> InfoItem.InfoType.CHANNEL;
default ->
throw new IllegalStateException("Unexpected value: " + type);
};
}
@NonNull
private Uri.Builder buildInfoItemMediaId(@NonNull final InfoItem item) {
return buildMediaId()
.appendPath(ID_INFO_ITEM)
.appendPath(infoItemTypeToString(item.getInfoType()))
.appendPath(Integer.toString(item.getServiceId()))
.appendQueryParameter(ID_URL, item.getUrl());
}
@NonNull
private String createMediaIdForInfoItem(final boolean remote, final long playlistId) {
return buildLocalPlaylistItemMediaId(remote, playlistId)
.build().toString();
}
@NonNull
private MediaItem createLocalPlaylistStreamMediaItem(final long playlistId,
@NonNull final PlaylistStreamEntry item,
final int index) {
final var builder = new MediaDescriptionCompat.Builder();
builder.setMediaId(createMediaIdForPlaylistIndex(false, playlistId, index))
.setTitle(item.getStreamEntity().getTitle())
.setSubtitle(item.getStreamEntity().getUploader())
.setIconUri(Uri.parse(item.getStreamEntity().getThumbnailUrl()));
return new MediaItem(builder.build(), MediaItem.FLAG_PLAYABLE);
}
@NonNull
private MediaItem createRemotePlaylistStreamMediaItem(final long playlistId,
@NonNull final StreamInfoItem item,
final int index) {
final var builder = new MediaDescriptionCompat.Builder();
builder.setMediaId(createMediaIdForPlaylistIndex(true, playlistId, index))
.setTitle(item.getName())
.setSubtitle(item.getUploaderName());
final var thumbnails = item.getThumbnails();
if (!thumbnails.isEmpty()) {
builder.setIconUri(Uri.parse(thumbnails.get(0).getUrl()));
}
return new MediaItem(builder.build(), MediaItem.FLAG_PLAYABLE);
}
@NonNull
private String createMediaIdForPlaylistIndex(final boolean remote, final long playlistId,
final int index) {
return buildLocalPlaylistItemMediaId(remote, playlistId)
.appendPath(Integer.toString(index))
.build().toString();
}
@NonNull
private String createMediaIdForInfoItem(@NonNull final InfoItem item) {
return buildInfoItemMediaId(item).build().toString();
}
@Nullable
public MediaBrowserServiceCompat.BrowserRoot onGetRoot(@NonNull final String clientPackageName,
final int clientUid,
@Nullable final Bundle rootHints) {
if (DEBUG) {
Log.d(TAG, String.format("MediaBrowserService.onGetRoot(%s, %s, %s)",
clientPackageName, clientUid, rootHints));
}
final Bundle extras = new Bundle();
extras.putBoolean(
MediaConstants.BROWSER_SERVICE_EXTRAS_KEY_SEARCH_SUPPORTED, true);
return new MediaBrowserServiceCompat.BrowserRoot(ID_ROOT, extras);
}
public Single<List<MediaItem>> onLoadChildren(@NonNull final String parentId) {
if (DEBUG) {
Log.d(TAG, String.format("MediaBrowserService.onLoadChildren(%s)", parentId));
}
try {
final Uri parentIdUri = Uri.parse(parentId);
final List<String> path = new ArrayList<>(parentIdUri.getPathSegments());
if (path.isEmpty()) {
final List<MediaItem> mediaItems = new ArrayList<>();
mediaItems.add(
createRootMediaItem(ID_BOOKMARKS,
playerService.getResources().getString(
R.string.tab_bookmarks_short),
R.drawable.ic_bookmark_white));
mediaItems.add(
createRootMediaItem(ID_HISTORY,
playerService.getResources().getString(R.string.action_history),
R.drawable.ic_history_white));
return Single.just(mediaItems);
}
final String uriType = path.get(0);
path.remove(0);
switch (uriType) {
case ID_BOOKMARKS:
if (path.isEmpty()) {
return populateBookmarks();
}
if (path.size() == 2) {
final String localOrRemote = path.get(0);
final long playlistId = Long.parseLong(path.get(1));
if (localOrRemote.equals(ID_LOCAL)) {
return populateLocalPlaylist(playlistId);
} else if (localOrRemote.equals(ID_REMOTE)) {
return populateRemotePlaylist(playlistId);
}
}
Log.w(TAG, "Unknown playlist URI: " + parentId);
throw parseError();
case ID_HISTORY:
return populateHistory();
default:
throw parseError();
}
} catch (final ContentNotAvailableException e) {
return Single.error(e);
}
}
private Single<List<MediaItem>> populateHistory() {
final StreamHistoryDAO streamHistory = getDatabase().streamHistoryDAO();
final var history = streamHistory.getHistory().firstOrError();
return history.map(items -> items.stream()
.map(this::createHistoryMediaItem)
.collect(Collectors.toList()));
}
@NonNull
private MediaItem createHistoryMediaItem(@NonNull final StreamHistoryEntry streamHistoryEntry) {
final var builder = new MediaDescriptionCompat.Builder();
final var mediaId = buildMediaId()
.appendPath(ID_HISTORY)
.appendPath(Long.toString(streamHistoryEntry.getStreamId()))
.build().toString();
builder.setMediaId(mediaId)
.setTitle(streamHistoryEntry.getStreamEntity().getTitle())
.setSubtitle(streamHistoryEntry.getStreamEntity().getUploader())
.setIconUri(Uri.parse(streamHistoryEntry.getStreamEntity().getThumbnailUrl()));
return new MediaItem(builder.build(), MediaItem.FLAG_PLAYABLE);
}
private AppDatabase getDatabase() {
if (database == null) {
database = NewPipeDatabase.getInstance(playerService);
}
return database;
}
private Flowable<List<PlaylistLocalItem>> getPlaylists() {
if (localPlaylistManager == null) {
localPlaylistManager = new LocalPlaylistManager(getDatabase());
}
if (remotePlaylistManager == null) {
remotePlaylistManager = new RemotePlaylistManager(getDatabase());
}
return MergedPlaylistManager.getMergedOrderedPlaylists(localPlaylistManager,
remotePlaylistManager);
}
@Nullable Disposable bookmarksNotificationsDisposable;
private void setupBookmarksNotifications() {
bookmarksNotificationsDisposable = getPlaylists().subscribe(
playlistMetadataEntries -> playerService.notifyChildrenChanged(ID_BOOKMARKS));
}
private void disposeBookmarksNotifications() {
if (bookmarksNotificationsDisposable != null) {
bookmarksNotificationsDisposable.dispose();
bookmarksNotificationsDisposable = null;
}
}
// Suppress Sonar warning replace list collection by Stream.toList call, as this method is only
// available in Android API 34 and not currently available with desugaring
@SuppressWarnings("squid:S6204")
private Single<List<MediaItem>> populateBookmarks() {
final var playlists = getPlaylists().firstOrError();
return playlists.map(playlist -> playlist.stream()
.map(this::createPlaylistMediaItem)
.collect(Collectors.toList()));
}
private Single<List<MediaItem>> populateLocalPlaylist(final long playlistId) {
final var playlist = localPlaylistManager.getPlaylistStreams(playlistId).firstOrError();
return playlist.map(items -> {
final List<MediaItem> results = new ArrayList<>();
int index = 0;
for (final PlaylistStreamEntry item : items) {
results.add(createLocalPlaylistStreamMediaItem(playlistId, item, index));
++index;
}
return results;
});
}
private Single<List<Pair<StreamInfoItem, Integer>>> getRemotePlaylist(final long playlistId) {
final var playlistFlow = remotePlaylistManager.getPlaylist(playlistId).firstOrError();
return playlistFlow.flatMap(item -> {
final var playlist = item.get(0);
final var playlistInfo = ExtractorHelper.getPlaylistInfo(playlist.getServiceId(),
playlist.getUrl(), false);
return playlistInfo.flatMap(info -> {
final var infoItemsPage = info.getRelatedItems();
if (!info.getErrors().isEmpty()) {
final List<Throwable> errors = new ArrayList<>(info.getErrors());
errors.removeIf(ContentNotSupportedException.class::isInstance);
if (!errors.isEmpty()) {
return Single.error(errors.get(0));
}
}
return Single.just(IntStream.range(0, infoItemsPage.size())
.mapToObj(i -> Pair.create(infoItemsPage.get(i), i))
.toList());
});
});
}
private Single<List<MediaItem>> populateRemotePlaylist(final long playlistId) {
return getRemotePlaylist(playlistId).map(items ->
items.stream().map(pair ->
createRemotePlaylistStreamMediaItem(playlistId, pair.first, pair.second)
).toList()
);
}
private void playbackError(@StringRes final int resId, final int code) {
playerService.stopForImmediateReusing();
sessionConnector.setCustomErrorMessage(playerService.getString(resId), code);
}
private void playbackError(@NonNull final ErrorInfo errorInfo) {
playbackError(errorInfo.getMessageStringId(), PlaybackStateCompat.ERROR_CODE_APP_ERROR);
}
private Single<PlayQueue> extractLocalPlayQueue(final long playlistId, final int index) {
return localPlaylistManager.getPlaylistStreams(playlistId)
.firstOrError()
.map(items -> {
final List<StreamInfoItem> infoItems = items.stream()
.map(PlaylistStreamEntry::toStreamInfoItem)
.collect(Collectors.toList());
return new SinglePlayQueue(infoItems, index);
});
}
private Single<PlayQueue> extractRemotePlayQueue(final long playlistId, final int index) {
return getRemotePlaylist(playlistId).map(items -> {
final var infoItems = items.stream().map(pair -> pair.first).toList();
return new SinglePlayQueue(infoItems, index);
});
}
private static ContentNotAvailableException parseError() {
return new ContentNotAvailableException("Failed to parse media ID");
}
private Single<PlayQueue> extractPlayQueueFromMediaId(final String mediaId) {
try {
final Uri mediaIdUri = Uri.parse(mediaId);
final List<String> path = new ArrayList<>(mediaIdUri.getPathSegments());
if (path.isEmpty()) {
throw parseError();
}
final String uriType = path.get(0);
path.remove(0);
return switch (uriType) {
case ID_BOOKMARKS -> extractPlayQueueFromPlaylistMediaId(path,
mediaIdUri.getQueryParameter(ID_URL));
case ID_HISTORY -> extractPlayQueueFromHistoryMediaId(path);
case ID_INFO_ITEM -> extractPlayQueueFromInfoItemMediaId(path,
mediaIdUri.getQueryParameter(ID_URL));
default -> throw parseError();
};
} catch (final ContentNotAvailableException e) {
return Single.error(e);
}
}
private Single<PlayQueue>
extractPlayQueueFromPlaylistMediaId(
@NonNull final List<String> path,
@Nullable final String url) throws ContentNotAvailableException {
if (path.isEmpty()) {
throw parseError();
}
final String playlistType = path.get(0);
path.remove(0);
switch (playlistType) {
case ID_LOCAL, ID_REMOTE:
if (path.size() != 2) {
throw parseError();
}
final long playlistId = Long.parseLong(path.get(0));
final int index = Integer.parseInt(path.get(1));
return playlistType.equals(ID_LOCAL)
? extractLocalPlayQueue(playlistId, index)
: extractRemotePlayQueue(playlistId, index);
case ID_URL:
if (path.size() != 1) {
throw parseError();
}
final int serviceId = Integer.parseInt(path.get(0));
return ExtractorHelper.getPlaylistInfo(serviceId, url, false)
.map(PlaylistPlayQueue::new);
default:
throw parseError();
}
}
private Single<PlayQueue> extractPlayQueueFromHistoryMediaId(
final List<String> path) throws ContentNotAvailableException {
if (path.size() != 1) {
throw parseError();
}
final long streamId = Long.parseLong(path.get(0));
return getDatabase().streamHistoryDAO().getHistory()
.firstOrError()
.map(items -> {
final List<StreamInfoItem> infoItems = items.stream()
.filter(it -> it.getStreamId() == streamId)
.map(StreamHistoryEntry::toStreamInfoItem)
.collect(Collectors.toList());
return new SinglePlayQueue(infoItems, 0);
});
}
private static Single<PlayQueue> extractPlayQueueFromInfoItemMediaId(
final List<String> path, final String url) throws ContentNotAvailableException {
if (path.size() != 2) {
throw parseError();
}
final var infoItemType = infoItemTypeFromString(path.get(0));
final int serviceId = Integer.parseInt(path.get(1));
return switch (infoItemType) {
case STREAM -> ExtractorHelper.getStreamInfo(serviceId, url, false)
.map(SinglePlayQueue::new);
case PLAYLIST -> ExtractorHelper.getPlaylistInfo(serviceId, url, false)
.map(PlaylistPlayQueue::new);
case CHANNEL -> ExtractorHelper.getChannelInfo(serviceId, url, false)
.map(info -> {
final Optional<ListLinkHandler> playableTab = info.getTabs()
.stream()
.filter(ChannelTabHelper::isStreamsTab)
.findFirst();
if (playableTab.isPresent()) {
return new ChannelTabPlayQueue(serviceId,
new ListLinkHandler(playableTab.get()));
} else {
throw new ContentNotAvailableException("No streams tab found");
}
});
default -> throw parseError();
};
}
@Override
public long getSupportedPrepareActions() {
return PlaybackStateCompat.ACTION_PLAY_FROM_MEDIA_ID;
}
private void disposePrepareOrPlayCommands() {
if (prepareOrPlayDisposable != null) {
prepareOrPlayDisposable.dispose();
prepareOrPlayDisposable = null;
}
}
@Override
public void onPrepare(final boolean playWhenReady) {
disposePrepareOrPlayCommands();
// No need to prepare
}
@Override
public void onPrepareFromMediaId(@NonNull final String mediaId,
final boolean playWhenReady,
@Nullable final Bundle extras) {
if (DEBUG) {
Log.d(TAG, String.format("MediaBrowserConnector.onPrepareFromMediaId(%s, %s, %s)",
mediaId, playWhenReady, extras));
}
disposePrepareOrPlayCommands();
prepareOrPlayDisposable = extractPlayQueueFromMediaId(mediaId)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
playQueue -> {
sessionConnector.setCustomErrorMessage(null);
NavigationHelper.playOnBackgroundPlayer(playerService, playQueue,
playWhenReady);
},
throwable -> playbackError(new ErrorInfo(throwable, UserAction.PLAY_STREAM,
"Failed playback of media ID [" + mediaId + "]: "))
);
}
@Override
public void onPrepareFromSearch(@NonNull final String query,
final boolean playWhenReady,
@Nullable final Bundle extras) {
disposePrepareOrPlayCommands();
playbackError(R.string.content_not_supported,
PlaybackStateCompat.ERROR_CODE_NOT_SUPPORTED);
}
private @NonNull Single<SearchInfo> searchMusicBySongTitle(final String query) {
final var serviceId = ServiceHelper.getSelectedServiceId(playerService);
return ExtractorHelper.searchFor(serviceId, query,
new ArrayList<>(), "");
}
private @NonNull SingleSource<List<MediaItem>>
mediaItemsFromInfoItemList(final ListInfo<InfoItem> result) {
final List<Throwable> exceptions = result.getErrors();
if (!exceptions.isEmpty()
&& !(exceptions.size() == 1
&& exceptions.get(0) instanceof SearchExtractor.NothingFoundException)) {
return Single.error(exceptions.get(0));
}
final List<InfoItem> items = result.getRelatedItems();
if (items.isEmpty()) {
return Single.error(new NullPointerException("Got no search results."));
}
try {
final List<MediaItem> results = items.stream()
.filter(item ->
item.getInfoType() == InfoItem.InfoType.STREAM
|| item.getInfoType() == InfoItem.InfoType.PLAYLIST
|| item.getInfoType() == InfoItem.InfoType.CHANNEL)
.map(this::createInfoItemMediaItem).toList();
return Single.just(results);
} catch (final Exception e) {
return Single.error(e);
}
}
private void handleSearchError(final Throwable throwable) {
Log.e(TAG, "Search error: " + throwable);
disposePrepareOrPlayCommands();
playbackError(R.string.content_not_supported, PlaybackStateCompat.ERROR_CODE_NOT_SUPPORTED);
}
@Override
public void onPrepareFromUri(@NonNull final Uri uri,
final boolean playWhenReady,
@Nullable final Bundle extras) {
disposePrepareOrPlayCommands();
playbackError(R.string.content_not_supported, PlaybackStateCompat.ERROR_CODE_NOT_SUPPORTED);
}
@Override
public boolean onCommand(@NonNull final Player player,
@NonNull final String command,
@Nullable final Bundle extras,
@Nullable final ResultReceiver cb) {
return false;
}
public void onSearch(@NonNull final String query,
@NonNull final MediaBrowserServiceCompat.Result<List<MediaItem>> result) {
result.detach();
if (searchDisposable != null) {
searchDisposable.dispose();
}
searchDisposable = searchMusicBySongTitle(query)
.flatMap(this::mediaItemsFromInfoItemList)
.subscribeOn(Schedulers.io())
.subscribe(result::sendResult,
this::handleSearchError);
}
}

View File

@ -0,0 +1,735 @@
package org.schabi.newpipe.player.mediabrowser
import android.content.ContentResolver
import android.net.Uri
import android.os.Bundle
import android.os.ResultReceiver
import android.support.v4.media.MediaBrowserCompat
import android.support.v4.media.MediaDescriptionCompat
import android.support.v4.media.session.MediaSessionCompat
import android.support.v4.media.session.PlaybackStateCompat
import android.util.Log
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.media.MediaBrowserServiceCompat
import androidx.media.utils.MediaConstants
import com.google.android.exoplayer2.Player
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector.PlaybackPreparer
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Flowable
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.core.SingleSource
import io.reactivex.rxjava3.disposables.Disposable
import io.reactivex.rxjava3.schedulers.Schedulers
import org.schabi.newpipe.BuildConfig
import org.schabi.newpipe.MainActivity
import org.schabi.newpipe.NewPipeDatabase
import org.schabi.newpipe.R
import org.schabi.newpipe.database.AppDatabase
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.error.ErrorInfo
import org.schabi.newpipe.error.UserAction
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.linkhandler.ListLinkHandler
import org.schabi.newpipe.extractor.playlist.PlaylistInfo
import org.schabi.newpipe.extractor.playlist.PlaylistInfoItem
import org.schabi.newpipe.extractor.search.SearchExtractor.NothingFoundException
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.player.PlayerService
import org.schabi.newpipe.player.playqueue.ChannelTabPlayQueue
import org.schabi.newpipe.player.playqueue.PlayQueue
import org.schabi.newpipe.player.playqueue.PlaylistPlayQueue
import org.schabi.newpipe.player.playqueue.SinglePlayQueue
import org.schabi.newpipe.util.ChannelTabHelper
import org.schabi.newpipe.util.ExtractorHelper
import org.schabi.newpipe.util.NavigationHelper
import org.schabi.newpipe.util.ServiceHelper
import java.util.stream.Collectors
class MediaBrowserConnector(private val playerService: PlayerService) : PlaybackPreparer {
private val mediaSession = MediaSessionCompat(playerService, TAG)
val sessionConnector = MediaSessionConnector(mediaSession).apply {
setMetadataDeduplicationEnabled(true)
setPlaybackPreparer(this@MediaBrowserConnector)
}
private val database: AppDatabase by lazy { NewPipeDatabase.getInstance(playerService) }
private val localPlaylistManager: LocalPlaylistManager by lazy { LocalPlaylistManager(database) }
private val remotePlaylistManager: RemotePlaylistManager by lazy {
RemotePlaylistManager(database)
}
private val playlists: Flowable<List<PlaylistLocalItem?>>
get() {
return MergedPlaylistManager.getMergedOrderedPlaylists(localPlaylistManager, remotePlaylistManager)
}
private var prepareOrPlayDisposable: Disposable? = null
private var searchDisposable: Disposable? = null
private var bookmarksNotificationsDisposable: Disposable? = null
init {
playerService.sessionToken = mediaSession.sessionToken
setupBookmarksNotifications()
}
fun release() {
disposePrepareOrPlayCommands()
disposeBookmarksNotifications()
mediaSession.release()
}
private fun createRootMediaItem(
mediaId: String?,
folderName: String,
@DrawableRes iconResId: Int
): MediaBrowserCompat.MediaItem {
val builder = MediaDescriptionCompat.Builder()
builder.setMediaId(mediaId)
builder.setTitle(folderName)
val resources = playerService.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,
playerService.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()
val remote = playlist is PlaylistRemoteEntity
builder.setMediaId(createMediaIdForInfoItem(remote, playlist.uid))
.setTitle(playlist.orderingName)
.setIconUri(Uri.parse(playlist.thumbnailUrl))
val extras = Bundle()
extras.putString(
MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE,
playerService.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 -> {}
}
item.thumbnails.firstOrNull()?.let {
builder.setIconUri(Uri.parse(it.url))
}
return MediaBrowserCompat.MediaItem(
builder.build(),
MediaBrowserCompat.MediaItem.FLAG_PLAYABLE
)
}
private fun buildMediaId() = Uri.Builder().authority(ID_AUTHORITY)
private fun buildPlaylistMediaId(playlistType: String) =
buildMediaId()
.appendPath(ID_BOOKMARKS)
.appendPath(playlistType)
private fun buildLocalPlaylistItemMediaId(
remote: Boolean,
playlistId: Long,
) = buildPlaylistMediaId(if (remote) ID_REMOTE else ID_LOCAL)
.appendPath(playlistId.toString())
private fun buildInfoItemMediaId(item: InfoItem) =
buildMediaId()
.appendPath(ID_INFO_ITEM)
.appendPath(infoItemTypeToString(item.infoType))
.appendPath(item.serviceId.toString())
.appendQueryParameter(ID_URL, item.url)
private fun createMediaIdForInfoItem(
remote: Boolean,
playlistId: Long,
) = buildLocalPlaylistItemMediaId(remote, 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)
item.thumbnails.firstOrNull()?.let {
builder.setIconUri(Uri.parse(it.url))
}
return MediaBrowserCompat.MediaItem(
builder.build(),
MediaBrowserCompat.MediaItem.FLAG_PLAYABLE
)
}
private fun createMediaIdForPlaylistIndex(
remote: Boolean,
playlistId: Long,
index: Int,
) = buildLocalPlaylistItemMediaId(remote, playlistId)
.appendPath(index.toString())
.build()
.toString()
private fun createMediaIdForInfoItem(item: InfoItem) = buildInfoItemMediaId(item).build().toString()
fun onGetRoot(
clientPackageName: String,
clientUid: Int,
rootHints: Bundle?
): MediaBrowserServiceCompat.BrowserRoot {
if (MainActivity.DEBUG) {
Log.d(
TAG,
String.format(
"MediaBrowserService.onGetRoot(%s, %s, %s)",
clientPackageName, clientUid, rootHints
)
)
}
val extras = Bundle()
extras.putBoolean(
MediaConstants.BROWSER_SERVICE_EXTRAS_KEY_SEARCH_SUPPORTED, true
)
return MediaBrowserServiceCompat.BrowserRoot(ID_ROOT, extras)
}
fun onLoadChildren(parentId: String): Single<List<MediaBrowserCompat.MediaItem>> {
if (MainActivity.DEBUG) {
Log.d(TAG, String.format("MediaBrowserService.onLoadChildren(%s)", parentId))
}
try {
val parentIdUri = Uri.parse(parentId)
val path = parentIdUri.pathSegments
if (path.isEmpty()) {
val mediaItems: MutableList<MediaBrowserCompat.MediaItem> = ArrayList()
mediaItems.add(
createRootMediaItem(
ID_BOOKMARKS,
playerService.resources.getString(
R.string.tab_bookmarks_short
),
R.drawable.ic_bookmark_white
)
)
mediaItems.add(
createRootMediaItem(
ID_HISTORY,
playerService.resources.getString(R.string.action_history),
R.drawable.ic_history_white
)
)
return Single.just(mediaItems)
}
val uriType = path[0]
path.removeAt(0)
when (uriType) {
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()
}
ID_HISTORY -> return populateHistory()
else -> throw parseError()
}
} catch (e: ContentNotAvailableException) {
return Single.error(e)
}
}
private fun populateHistory(): Single<List<MediaBrowserCompat.MediaItem>> =
database
.streamHistoryDAO()
.history
.firstOrError()
.map { items -> items.map(::createHistoryMediaItem) }
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 setupBookmarksNotifications() {
bookmarksNotificationsDisposable =
playlists.subscribe { _ ->
playerService.notifyChildrenChanged(ID_BOOKMARKS)
}
}
private fun disposeBookmarksNotifications() {
bookmarksNotificationsDisposable?.dispose()
}
// Suppress Sonar warning replace list collection by Stream.toList call, as this method is only
// available in Android API 34 and not currently available with desugaring
private fun populateBookmarks() =
playlists.firstOrError().map { playlist ->
playlist.filterNotNull().map { createPlaylistMediaItem(it) }
}
private fun populateLocalPlaylist(playlistId: Long): Single<List<MediaBrowserCompat.MediaItem>> =
localPlaylistManager
.getPlaylistStreams(playlistId)
.firstOrError()
.map { items: List<PlaylistStreamEntry> ->
val results: MutableList<MediaBrowserCompat.MediaItem> = ArrayList()
for ((index, item) in items.withIndex()) {
results.add(createLocalPlaylistStreamMediaItem(playlistId, item, index))
}
results
}
private fun getRemotePlaylist(playlistId: Long): Single<List<Pair<StreamInfoItem, Int>>> =
remotePlaylistManager
.getPlaylist(playlistId)
.firstOrError()
.map { playlistEntities ->
val playlist = playlistEntities[0]
ExtractorHelper
.getPlaylistInfo(playlist.serviceId, playlist.url, false)
.map { info ->
handlePlaylistInfoErrors(info)
info.relatedItems.withIndex().map { (index, item) -> item to index }
}
}.flatMap { it }
private fun handlePlaylistInfoErrors(info: PlaylistInfo) {
val nonContentNotSupportedErrors = info.errors.filterNot { it is ContentNotSupportedException }
if (nonContentNotSupportedErrors.isNotEmpty()) {
throw nonContentNotSupportedErrors.first()
}
}
private fun populateRemotePlaylist(playlistId: Long): Single<List<MediaBrowserCompat.MediaItem>> =
getRemotePlaylist(playlistId).map { items ->
items.map { pair ->
createRemotePlaylistStreamMediaItem(
playlistId,
pair.first,
pair.second,
)
}
}
private fun playbackError(@StringRes resId: Int, code: Int) {
playerService.stopForImmediateReusing()
sessionConnector.setCustomErrorMessage(playerService.getString(resId), code)
}
private fun playbackError(errorInfo: ErrorInfo) {
playbackError(errorInfo.messageStringId, PlaybackStateCompat.ERROR_CODE_APP_ERROR)
}
private fun extractLocalPlayQueue(playlistId: Long, index: Int): Single<PlayQueue> {
return localPlaylistManager.getPlaylistStreams(playlistId)
.firstOrError()
.map { items: List<PlaylistStreamEntry> ->
val infoItems = items.stream()
.map { obj: PlaylistStreamEntry -> obj.toStreamInfoItem() }
.collect(Collectors.toList())
SinglePlayQueue(infoItems, index)
}
}
private fun extractRemotePlayQueue(playlistId: Long, index: Int): Single<PlayQueue> {
return getRemotePlaylist(playlistId).map { items ->
val infoItems = items.map { (item, _) -> item }
SinglePlayQueue(infoItems, index)
}
}
private fun extractPlayQueueFromMediaId(mediaId: String): Single<PlayQueue> {
try {
val mediaIdUri = Uri.parse(mediaId)
val path = mediaIdUri.pathSegments
if (path.isEmpty()) {
throw parseError()
}
val uriType = path[0]
path.removeAt(0)
return when (uriType) {
ID_BOOKMARKS -> extractPlayQueueFromPlaylistMediaId(
path,
mediaIdUri.getQueryParameter(ID_URL)
)
ID_HISTORY -> extractPlayQueueFromHistoryMediaId(path)
ID_INFO_ITEM -> extractPlayQueueFromInfoItemMediaId(
path,
mediaIdUri.getQueryParameter(ID_URL)
)
else -> throw parseError()
}
} catch (error: ContentNotAvailableException) {
return Single.error(error)
}
}
@Throws(ContentNotAvailableException::class)
private fun extractPlayQueueFromPlaylistMediaId(
mediaIdSegments: List<String>,
url: String?,
): Single<PlayQueue> {
if (mediaIdSegments.isEmpty()) {
throw parseError()
}
when (val playlistType = mediaIdSegments.first()) {
ID_LOCAL, ID_REMOTE -> {
if (mediaIdSegments.size != 2) {
throw parseError()
}
val playlistId = mediaIdSegments[0].toLong()
val index = mediaIdSegments[1].toInt()
return if (playlistType == ID_LOCAL) {
extractLocalPlayQueue(playlistId, index)
} else {
extractRemotePlayQueue(playlistId, index)
}
}
ID_URL -> {
if (mediaIdSegments.size != 1) {
throw parseError()
}
val serviceId = mediaIdSegments[0].toInt()
return ExtractorHelper
.getPlaylistInfo(serviceId, url, false)
.map(::PlaylistPlayQueue)
}
else -> throw parseError()
}
}
@Throws(ContentNotAvailableException::class)
private fun extractPlayQueueFromHistoryMediaId(
path: List<String>
): Single<PlayQueue> {
if (path.size != 1) {
throw parseError()
}
val streamId = path[0].toLong()
return database
.streamHistoryDAO()
.history
.firstOrError()
.map { items ->
val infoItems =
items
.filter { streamHistoryEntry -> streamHistoryEntry.streamId == streamId }
.map { streamHistoryEntry -> streamHistoryEntry.toStreamInfoItem() }
SinglePlayQueue(infoItems, 0)
}
}
override fun getSupportedPrepareActions() = PlaybackStateCompat.ACTION_PLAY_FROM_MEDIA_ID
private fun disposePrepareOrPlayCommands() {
prepareOrPlayDisposable?.dispose()
}
override fun onPrepare(playWhenReady: Boolean) {
disposePrepareOrPlayCommands()
// No need to prepare
}
override fun onPrepareFromMediaId(
mediaId: String,
playWhenReady: Boolean,
extras: Bundle?
) {
if (MainActivity.DEBUG) {
Log.d(
TAG,
String.format(
"MediaBrowserConnector.onPrepareFromMediaId(%s, %s, %s)",
mediaId, playWhenReady, extras
)
)
}
disposePrepareOrPlayCommands()
prepareOrPlayDisposable = extractPlayQueueFromMediaId(mediaId)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{ playQueue ->
sessionConnector.setCustomErrorMessage(null)
NavigationHelper.playOnBackgroundPlayer(
playerService, playQueue,
playWhenReady
)
},
{ throwable ->
playbackError(
ErrorInfo(
throwable, UserAction.PLAY_STREAM,
"Failed playback of media ID [$mediaId]: "
)
)
}
)
}
override fun onPrepareFromSearch(
query: String,
playWhenReady: Boolean,
extras: Bundle?
) {
disposePrepareOrPlayCommands()
playbackError(
R.string.content_not_supported,
PlaybackStateCompat.ERROR_CODE_NOT_SUPPORTED
)
}
private fun searchMusicBySongTitle(query: String): Single<SearchInfo> {
val serviceId = ServiceHelper.getSelectedServiceId(playerService)
return ExtractorHelper.searchFor(
serviceId, query,
ArrayList(), ""
)
}
private fun mediaItemsFromInfoItemList(result: ListInfo<InfoItem>): SingleSource<List<MediaBrowserCompat.MediaItem>> {
result.errors
.takeIf { exceptions ->
exceptions.isNotEmpty() &&
!(
exceptions.size == 1 &&
exceptions.first() is NothingFoundException
)
}?.let { exceptions ->
return Single.error(exceptions.first())
}
val items = result.relatedItems
if (items.isEmpty()) {
return Single.error(NullPointerException("Got no search results."))
}
try {
val results =
items
.filter { item ->
item.infoType == InfoType.STREAM ||
item.infoType == InfoType.PLAYLIST ||
item.infoType == InfoType.CHANNEL
}.map { item -> this.createInfoItemMediaItem(item) }
return Single.just(results)
} catch (error: Exception) {
return Single.error(error)
}
}
private fun handleSearchError(throwable: Throwable) {
Log.e(TAG, "Search error: $throwable")
disposePrepareOrPlayCommands()
playbackError(R.string.content_not_supported, PlaybackStateCompat.ERROR_CODE_NOT_SUPPORTED)
}
override fun onPrepareFromUri(
uri: Uri,
playWhenReady: Boolean,
extras: Bundle?
) {
disposePrepareOrPlayCommands()
playbackError(R.string.content_not_supported, PlaybackStateCompat.ERROR_CODE_NOT_SUPPORTED)
}
override fun onCommand(
player: Player,
command: String,
extras: Bundle?,
cb: ResultReceiver?,
) = false
fun onSearch(
query: String,
result: MediaBrowserServiceCompat.Result<List<MediaBrowserCompat.MediaItem>>,
) {
result.detach()
searchDisposable?.dispose()
searchDisposable =
searchMusicBySongTitle(query)
.flatMap(::mediaItemsFromInfoItemList)
.subscribeOn(Schedulers.io())
.subscribe(
{ mediaItemsResult -> result.sendResult(mediaItemsResult) },
{ throwable -> this.handleSearchError(throwable) },
)
}
companion object {
private val TAG: String = MediaBrowserConnector::class.java.simpleName
private const val ID_AUTHORITY = BuildConfig.APPLICATION_ID
private const val ID_ROOT = "//$ID_AUTHORITY"
private const val ID_BOOKMARKS = "playlists"
private const val ID_HISTORY = "history"
private const val ID_INFO_ITEM = "item"
private const val ID_LOCAL = "local"
private const val ID_REMOTE = "remote"
private const val ID_URL = "url"
private const val ID_STREAM = "stream"
private const val ID_PLAYLIST = "playlist"
private const val ID_CHANNEL = "channel"
private fun infoItemTypeToString(type: InfoType): String {
return when (type) {
InfoType.STREAM -> ID_STREAM
InfoType.PLAYLIST -> ID_PLAYLIST
InfoType.CHANNEL -> ID_CHANNEL
else -> throw IllegalStateException("Unexpected value: $type")
}
}
private fun infoItemTypeFromString(type: String): InfoType {
return when (type) {
ID_STREAM -> InfoType.STREAM
ID_PLAYLIST -> InfoType.PLAYLIST
ID_CHANNEL -> InfoType.CHANNEL
else -> throw IllegalStateException("Unexpected value: $type")
}
}
private fun parseError(): ContentNotAvailableException {
return ContentNotAvailableException("Failed to parse media ID")
}
@Throws(ContentNotAvailableException::class)
private fun extractPlayQueueFromInfoItemMediaId(
path: List<String>,
url: String?
): Single<PlayQueue> {
if (path.size != 2) {
throw parseError()
}
val infoItemType = infoItemTypeFromString(path[0])
val serviceId = path[1].toInt()
return when (infoItemType) {
InfoType.STREAM -> ExtractorHelper.getStreamInfo(serviceId, url, false)
.map(::SinglePlayQueue)
InfoType.PLAYLIST -> ExtractorHelper.getPlaylistInfo(serviceId, url, false)
.map(::PlaylistPlayQueue)
InfoType.CHANNEL -> ExtractorHelper.getChannelInfo(serviceId, url, false)
.map { info ->
val playableTab = info.tabs
.stream()
.filter { tab -> ChannelTabHelper.isStreamsTab(tab) }
.findFirst()
if (playableTab.isPresent) {
return@map ChannelTabPlayQueue(
serviceId,
ListLinkHandler(playableTab.get())
)
} else {
throw ContentNotAvailableException("No streams tab found")
}
}
else -> throw parseError()
}
}
}
}