diff --git a/app/src/main/java/org/schabi/newpipe/database/LocalItem.java b/app/src/main/java/org/schabi/newpipe/database/LocalItem.java deleted file mode 100644 index 54b856b0653..00000000000 --- a/app/src/main/java/org/schabi/newpipe/database/LocalItem.java +++ /dev/null @@ -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, - } -} diff --git a/app/src/main/java/org/schabi/newpipe/database/LocalItem.kt b/app/src/main/java/org/schabi/newpipe/database/LocalItem.kt new file mode 100644 index 00000000000..87084cd51d3 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/database/LocalItem.kt @@ -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, + } +} diff --git a/app/src/main/java/org/schabi/newpipe/database/history/model/StreamHistoryEntry.kt b/app/src/main/java/org/schabi/newpipe/database/history/model/StreamHistoryEntry.kt index 1eb299890e6..27fc429f1b9 100644 --- a/app/src/main/java/org/schabi/newpipe/database/history/model/StreamHistoryEntry.kt +++ b/app/src/main/java/org/schabi/newpipe/database/history/model/StreamHistoryEntry.kt @@ -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) + } } diff --git a/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistLocalItem.java b/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistLocalItem.java deleted file mode 100644 index a974a09d0da..00000000000 --- a/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistLocalItem.java +++ /dev/null @@ -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(); -} diff --git a/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistLocalItem.kt b/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistLocalItem.kt new file mode 100644 index 00000000000..22d57572c24 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistLocalItem.kt @@ -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? +} diff --git a/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistStreamEntry.kt b/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistStreamEntry.kt index 1d74c6d31dc..1b40d223fa1 100644 --- a/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistStreamEntry.kt +++ b/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistStreamEntry.kt @@ -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) - - return item - } - - override fun getLocalItemType(): LocalItem.LocalItemType { - return LocalItem.LocalItemType.PLAYLIST_STREAM_ITEM - } + 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) + } + + override val localItemType: LocalItem.LocalItemType + get() = LocalItem.LocalItemType.PLAYLIST_STREAM_ITEM } diff --git a/app/src/main/java/org/schabi/newpipe/database/stream/StreamStatisticsEntry.kt b/app/src/main/java/org/schabi/newpipe/database/stream/StreamStatisticsEntry.kt index 1f3654e7ae4..60c913d1190 100644 --- a/app/src/main/java/org/schabi/newpipe/database/stream/StreamStatisticsEntry.kt +++ b/app/src/main/java/org/schabi/newpipe/database/stream/StreamStatisticsEntry.kt @@ -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) - - return item - } - - override fun getLocalItemType(): LocalItem.LocalItemType { - return LocalItem.LocalItemType.STATISTIC_STREAM_ITEM - } + 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) + } + + override val localItemType: LocalItem.LocalItemType + get() = LocalItem.LocalItemType.STATISTIC_STREAM_ITEM companion object { const val STREAM_LATEST_DATE = "latestAccess" diff --git a/app/src/main/java/org/schabi/newpipe/player/Player.java b/app/src/main/java/org/schabi/newpipe/player/Player.java index 4b117401429..a922f573bb3 100644 --- a/app/src/main/java/org/schabi/newpipe/player/Player.java +++ b/app/src/main/java/org/schabi/newpipe/player/Player.java @@ -45,6 +45,7 @@ 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 void handleIntent(@NonNull final Intent intent) { } if (playQueue.getIndex() != newQueue.getIndex()) { simpleExoPlayer.seekTo(newQueue.getIndex(), - newQueue.getItem().getRecoveryPosition()); + requireNonNull(newQueue.getItem()).getRecoveryPosition()); } simpleExoPlayer.setPlayWhenReady(playWhenReady); diff --git a/app/src/main/java/org/schabi/newpipe/player/PlayerService.java b/app/src/main/java/org/schabi/newpipe/player/PlayerService.java deleted file mode 100644 index e088290c921..00000000000 --- a/app/src/main/java/org/schabi/newpipe/player/PlayerService.java +++ /dev/null @@ -1,256 +0,0 @@ -/* - * Copyright 2017 Mauricio Colli - * 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 . - */ - -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> 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> result) { - mediaBrowserConnector.onSearch(query, result); - } - - public static final class LocalBinder extends Binder { - private final WeakReference 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; - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/PlayerService.kt b/app/src/main/java/org/schabi/newpipe/player/PlayerService.kt new file mode 100644 index 00000000000..478dc2b9344 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/PlayerService.kt @@ -0,0 +1,213 @@ +/* + * Copyright 2017 Mauricio Colli + * 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 . + */ +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.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() + .get(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) + } + + // 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 + "]", + ) + } + + 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) + + 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? { + if (SERVICE_INTERFACE == intent.action) { + return super.onBind(intent) + } + return 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>, + ) { + 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>, + ) { + 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 + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserConnector.java b/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserConnector.java deleted file mode 100644 index e7856db067f..00000000000 --- a/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserConnector.java +++ /dev/null @@ -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> 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 path = new ArrayList<>(parentIdUri.getPathSegments()); - - if (path.isEmpty()) { - final List 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> 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> 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> populateBookmarks() { - final var playlists = getPlaylists().firstOrError(); - return playlists.map(playlist -> playlist.stream() - .map(this::createPlaylistMediaItem) - .collect(Collectors.toList())); - } - - private Single> populateLocalPlaylist(final long playlistId) { - final var playlist = localPlaylistManager.getPlaylistStreams(playlistId).firstOrError(); - return playlist.map(items -> { - final List results = new ArrayList<>(); - int index = 0; - for (final PlaylistStreamEntry item : items) { - results.add(createLocalPlaylistStreamMediaItem(playlistId, item, index)); - ++index; - } - return results; - }); - } - - private Single>> 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 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> 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 extractLocalPlayQueue(final long playlistId, final int index) { - return localPlaylistManager.getPlaylistStreams(playlistId) - .firstOrError() - .map(items -> { - final List infoItems = items.stream() - .map(PlaylistStreamEntry::toStreamInfoItem) - .collect(Collectors.toList()); - return new SinglePlayQueue(infoItems, index); - }); - } - - private Single 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 extractPlayQueueFromMediaId(final String mediaId) { - try { - final Uri mediaIdUri = Uri.parse(mediaId); - final List 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 - extractPlayQueueFromPlaylistMediaId( - @NonNull final List 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 extractPlayQueueFromHistoryMediaId( - final List 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 infoItems = items.stream() - .filter(it -> it.getStreamId() == streamId) - .map(StreamHistoryEntry::toStreamInfoItem) - .collect(Collectors.toList()); - return new SinglePlayQueue(infoItems, 0); - }); - } - - private static Single extractPlayQueueFromInfoItemMediaId( - final List 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 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 searchMusicBySongTitle(final String query) { - final var serviceId = ServiceHelper.getSelectedServiceId(playerService); - return ExtractorHelper.searchFor(serviceId, query, - new ArrayList<>(), ""); - } - - private @NonNull SingleSource> - mediaItemsFromInfoItemList(final ListInfo result) { - final List exceptions = result.getErrors(); - if (!exceptions.isEmpty() - && !(exceptions.size() == 1 - && exceptions.get(0) instanceof SearchExtractor.NothingFoundException)) { - return Single.error(exceptions.get(0)); - } - - final List items = result.getRelatedItems(); - if (items.isEmpty()) { - return Single.error(new NullPointerException("Got no search results.")); - } - try { - final List 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> result) { - result.detach(); - if (searchDisposable != null) { - searchDisposable.dispose(); - } - searchDisposable = searchMusicBySongTitle(query) - .flatMap(this::mediaItemsFromInfoItemList) - .subscribeOn(Schedulers.io()) - .subscribe(result::sendResult, - this::handleSearchError); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserConnector.kt b/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserConnector.kt new file mode 100644 index 00000000000..f08d8985556 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserConnector.kt @@ -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> + 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> { + 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 = 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> = + 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> = + localPlaylistManager + .getPlaylistStreams(playlistId) + .firstOrError() + .map { items: List -> + val results: MutableList = ArrayList() + for ((index, item) in items.withIndex()) { + results.add(createLocalPlaylistStreamMediaItem(playlistId, item, index)) + } + results + } + + private fun getRemotePlaylist(playlistId: Long): Single>> = + 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> = + 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 { + return localPlaylistManager.getPlaylistStreams(playlistId) + .firstOrError() + .map { items: List -> + val infoItems = items.stream() + .map { obj: PlaylistStreamEntry -> obj.toStreamInfoItem() } + .collect(Collectors.toList()) + SinglePlayQueue(infoItems, index) + } + } + + private fun extractRemotePlayQueue(playlistId: Long, index: Int): Single { + return getRemotePlaylist(playlistId).map { items -> + val infoItems = items.map { (item, _) -> item } + SinglePlayQueue(infoItems, index) + } + } + + private fun extractPlayQueueFromMediaId(mediaId: String): Single { + 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, + url: String?, + ): Single { + 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 + ): Single { + 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 { + val serviceId = ServiceHelper.getSelectedServiceId(playerService) + return ExtractorHelper.searchFor( + serviceId, query, + ArrayList(), "" + ) + } + + private fun mediaItemsFromInfoItemList(result: ListInfo): SingleSource> { + 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>, + ) { + 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, + url: String? + ): Single { + 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() + } + } + } +}