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:
parent
89bdfef4b9
commit
bf59f1e09d
@ -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,
|
||||
}
|
||||
}
|
18
app/src/main/java/org/schabi/newpipe/database/LocalItem.kt
Normal file
18
app/src/main/java/org/schabi/newpipe/database/LocalItem.kt
Normal 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,
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
@ -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();
|
||||
}
|
@ -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?
|
||||
}
|
@ -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
|
||||
}
|
||||
|
@ -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"
|
||||
|
@ -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);
|
||||
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
236
app/src/main/java/org/schabi/newpipe/player/PlayerService.kt
Normal file
236
app/src/main/java/org/schabi/newpipe/player/PlayerService.kt
Normal 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
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user