diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index d11de9f478d..e52dded5e1a 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -64,6 +64,9 @@
+
+
+
+
+
+
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 a93ba1652f6..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
@@ -3,6 +3,8 @@ package org.schabi.newpipe.database.history.model
import androidx.room.ColumnInfo
import androidx.room.Embedded
import org.schabi.newpipe.database.stream.model.StreamEntity
+import org.schabi.newpipe.extractor.stream.StreamInfoItem
+import org.schabi.newpipe.util.image.ImageStrategy
import java.time.OffsetDateTime
data class StreamHistoryEntry(
@@ -27,4 +29,17 @@ data class StreamHistoryEntry(
return this.streamEntity.uid == other.streamEntity.uid && streamId == other.streamId &&
accessDate.isEqual(other.accessDate)
}
+
+ fun toStreamInfoItem(): StreamInfoItem =
+ StreamInfoItem(
+ streamEntity.serviceId,
+ streamEntity.url,
+ streamEntity.title,
+ 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 072c49e2c07..00000000000
--- a/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistLocalItem.java
+++ /dev/null
@@ -1,13 +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);
-}
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/PlaylistMetadataEntry.java b/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistMetadataEntry.java
index 03a1e1e308a..4b0338b390e 100644
--- a/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistMetadataEntry.java
+++ b/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistMetadataEntry.java
@@ -71,4 +71,9 @@ public long getUid() {
public void setDisplayIndex(final long displayIndex) {
this.displayIndex = displayIndex;
}
+
+ @Override
+ public String getThumbnailUrl() {
+ return thumbnailUrl;
+ }
}
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/fragments/detail/VideoDetailFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java
index 1d1e166e7d7..6c9a8504646 100644
--- a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java
+++ b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java
@@ -229,6 +229,7 @@ public final class VideoDetailFragment
private ContentObserver settingsContentObserver;
@Nullable
private PlayerService playerService;
+ @Nullable
private Player player;
private final PlayerHolder playerHolder = PlayerHolder.getInstance();
@@ -236,7 +237,7 @@ public final class VideoDetailFragment
// Service management
//////////////////////////////////////////////////////////////////////////*/
@Override
- public void onServiceConnected(final Player connectedPlayer,
+ public void onServiceConnected(@Nullable final Player connectedPlayer,
final PlayerService connectedPlayerService,
final boolean playAfterConnect) {
player = connectedPlayer;
diff --git a/app/src/main/java/org/schabi/newpipe/local/dialog/PlaylistDialog.java b/app/src/main/java/org/schabi/newpipe/local/dialog/PlaylistDialog.java
index 612c3818187..da408bb50aa 100644
--- a/app/src/main/java/org/schabi/newpipe/local/dialog/PlaylistDialog.java
+++ b/app/src/main/java/org/schabi/newpipe/local/dialog/PlaylistDialog.java
@@ -163,7 +163,7 @@ public static Disposable createCorrespondingDialog(
* @return the disposable that was created
*/
public static Disposable showForPlayQueue(
- final Player player,
+ @NonNull final Player player,
@NonNull final FragmentManager fragmentManager) {
final List streamEntities = Stream.of(player.getPlayQueue())
diff --git a/app/src/main/java/org/schabi/newpipe/local/playlist/RemotePlaylistManager.java b/app/src/main/java/org/schabi/newpipe/local/playlist/RemotePlaylistManager.java
index 4cc51f7525e..7104f59629a 100644
--- a/app/src/main/java/org/schabi/newpipe/local/playlist/RemotePlaylistManager.java
+++ b/app/src/main/java/org/schabi/newpipe/local/playlist/RemotePlaylistManager.java
@@ -26,6 +26,10 @@ public Flowable> getPlaylists() {
return playlistRemoteTable.getPlaylists().subscribeOn(Schedulers.io());
}
+ public Flowable> getPlaylist(final long playlistId) {
+ return playlistRemoteTable.getPlaylist(playlistId).subscribeOn(Schedulers.io());
+ }
+
public Flowable> getPlaylist(final PlaylistInfo info) {
return playlistRemoteTable.getPlaylist(info.getServiceId(), info.getUrl())
.subscribeOn(Schedulers.io());
diff --git a/app/src/main/java/org/schabi/newpipe/player/PlayQueueActivity.java b/app/src/main/java/org/schabi/newpipe/player/PlayQueueActivity.java
index 195baecbda8..dc959afea01 100644
--- a/app/src/main/java/org/schabi/newpipe/player/PlayQueueActivity.java
+++ b/app/src/main/java/org/schabi/newpipe/player/PlayQueueActivity.java
@@ -61,6 +61,7 @@ public final class PlayQueueActivity extends AppCompatActivity
private static final int MENU_ID_AUDIO_TRACK = 71;
+ @Nullable
private Player player;
private boolean serviceBound;
@@ -137,30 +138,38 @@ public boolean onOptionsItemSelected(final MenuItem item) {
NavigationHelper.openSettings(this);
return true;
case R.id.action_append_playlist:
- PlaylistDialog.showForPlayQueue(player, getSupportFragmentManager());
+ if (player != null) {
+ PlaylistDialog.showForPlayQueue(player, getSupportFragmentManager());
+ }
return true;
case R.id.action_playback_speed:
openPlaybackParameterDialog();
return true;
case R.id.action_mute:
- player.toggleMute();
+ if (player != null) {
+ player.toggleMute();
+ }
return true;
case R.id.action_system_audio:
startActivity(new Intent(Settings.ACTION_SOUND_SETTINGS));
return true;
case R.id.action_switch_main:
- this.player.setRecovery();
- NavigationHelper.playOnMainPlayer(this, player.getPlayQueue(), true);
+ if (player != null) {
+ this.player.setRecovery();
+ NavigationHelper.playOnMainPlayer(this, player.getPlayQueue(), true);
+ }
return true;
case R.id.action_switch_popup:
- if (PermissionHelper.isPopupEnabledElseAsk(this)) {
+ if (PermissionHelper.isPopupEnabledElseAsk(this) && player != null) {
this.player.setRecovery();
NavigationHelper.playOnPopupPlayer(this, player.getPlayQueue(), true);
}
return true;
case R.id.action_switch_background:
- this.player.setRecovery();
- NavigationHelper.playOnBackgroundPlayer(this, player.getPlayQueue(), true);
+ if (player != null) {
+ this.player.setRecovery();
+ NavigationHelper.playOnBackgroundPlayer(this, player.getPlayQueue(), true);
+ }
return true;
}
@@ -309,7 +318,7 @@ public void onMove(final int sourceIndex, final int targetIndex) {
@Override
public void onSwiped(final int index) {
- if (index != -1) {
+ if (index != -1 && player != null) {
player.getPlayQueue().remove(index);
}
}
@@ -659,7 +668,7 @@ private void buildAudioTrackMenu() {
* @param itemId index of the selected item
*/
private void onAudioTrackClick(final int itemId) {
- if (player.getCurrentMetadata() == null) {
+ if (player == null || player.getCurrentMetadata() == null) {
return;
}
player.getCurrentMetadata().getMaybeAudioTrack().ifPresent(audioTrack -> {
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 920435a7e3b..b19df82fa01 100644
--- a/app/src/main/java/org/schabi/newpipe/player/Player.java
+++ b/app/src/main/java/org/schabi/newpipe/player/Player.java
@@ -86,8 +86,8 @@
import org.schabi.newpipe.error.ErrorInfo;
import org.schabi.newpipe.error.ErrorUtil;
import org.schabi.newpipe.error.UserAction;
-import org.schabi.newpipe.extractor.stream.AudioStream;
import org.schabi.newpipe.extractor.Image;
+import org.schabi.newpipe.extractor.stream.AudioStream;
import org.schabi.newpipe.extractor.stream.StreamInfo;
import org.schabi.newpipe.extractor.stream.StreamType;
import org.schabi.newpipe.extractor.stream.VideoStream;
@@ -118,9 +118,9 @@
import org.schabi.newpipe.util.DependentPreferenceHelper;
import org.schabi.newpipe.util.ListHelper;
import org.schabi.newpipe.util.NavigationHelper;
-import org.schabi.newpipe.util.image.PicassoHelper;
import org.schabi.newpipe.util.SerializedCache;
import org.schabi.newpipe.util.StreamTypeUtil;
+import org.schabi.newpipe.util.image.PicassoHelper;
import java.util.List;
import java.util.Optional;
@@ -302,7 +302,7 @@ public Player(@NonNull final PlayerService service) {
// notification ui in the UIs list, since the notification depends on the media session in
// PlayerUi#initPlayer(), and UIs.call() guarantees UI order is preserved.
UIs = new PlayerUiList(
- new MediaSessionPlayerUi(this),
+ new MediaSessionPlayerUi(this, service.getSessionConnector()),
new NotificationPlayerUi(this)
);
}
@@ -415,6 +415,13 @@ public void handleIntent(@NonNull final Intent intent) {
== com.google.android.exoplayer2.Player.STATE_IDLE) {
simpleExoPlayer.prepare();
}
+ // Seeks to a specific index and position in the player if the queue index has changed.
+ if (playQueue.getIndex() != newQueue.getIndex()) {
+ final PlayQueueItem queueItem = newQueue.getItem();
+ if (queueItem != null) {
+ simpleExoPlayer.seekTo(newQueue.getIndex(), queueItem.getRecoveryPosition());
+ }
+ }
simpleExoPlayer.setPlayWhenReady(playWhenReady);
} else if (intent.getBooleanExtra(RESUME_PLAYBACK, false)
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 e7abf4320d5..00000000000
--- a/app/src/main/java/org/schabi/newpipe/player/PlayerService.java
+++ /dev/null
@@ -1,185 +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.app.Service;
-import android.content.Context;
-import android.content.Intent;
-import android.os.Binder;
-import android.os.IBinder;
-import android.util.Log;
-
-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;
-
-
-/**
- * One service for all players.
- */
-public final class PlayerService extends Service {
- private static final String TAG = PlayerService.class.getSimpleName();
- private static final boolean DEBUG = Player.DEBUG;
-
- private Player player;
-
- private final IBinder mBinder = new PlayerService.LocalBinder(this);
-
-
- /*//////////////////////////////////////////////////////////////////////////
- // Service's LifeCycle
- //////////////////////////////////////////////////////////////////////////*/
-
- @Override
- public void onCreate() {
- if (DEBUG) {
- Log.d(TAG, "onCreate() called");
- }
- assureCorrectAppLanguage(this);
- ThemeHelper.setTheme(this);
-
- 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);
- }
-
- @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;
- }
-
- if (player != null) {
- 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();
- }
-
- 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(final Intent intent) {
- return mBinder;
- }
-
- public static class LocalBinder extends Binder {
- private final WeakReference playerService;
-
- LocalBinder(final PlayerService playerService) {
- this.playerService = new WeakReference<>(playerService);
- }
-
- public PlayerService getService() {
- return playerService.get();
- }
-
- 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..10c750f7b51
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/player/PlayerService.kt
@@ -0,0 +1,229 @@
+/*
+ * 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.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 var player: Player? = null
+
+ private val mBinder: IBinder = LocalBinder(this)
+ private val disposables = CompositeDisposable()
+ private var _mediaBrowserConnector: MediaBrowserConnector? = null
+ private val mediaBrowserConnector: MediaBrowserConnector
+ get() {
+ return _mediaBrowserConnector ?: run {
+ val newMediaBrowserConnector = MediaBrowserConnector(this)
+ _mediaBrowserConnector = newMediaBrowserConnector
+ newMediaBrowserConnector
+ }
+ }
+
+ 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)
+
+ player = 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()[NotificationPlayerUi::class.java].ifPresent {
+ it.createNotificationAndStartForeground()
+ }
+ }
+
+ 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()?.get(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()?.get(MediaSessionPlayerUi::class.java)?.ifPresent {
+ it.handleMediaButtonIntent(intent)
+ }
+
+ return START_NOT_STICKY
+ }
+
+ fun 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 fun onTaskRemoved(rootIntent: Intent) {
+ super.onTaskRemoved(rootIntent)
+ if (player != null && !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
+
+ disposables.clear()
+ }
+
+ private fun cleanup() {
+ player?.destroy()
+ player = null
+ }
+
+ 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) {
+ // For actions related to the media browser service, pass the onBind to the superclass
+ return super.onBind(intent)
+ }
+ return mBinder
+ }
+
+ // MediaBrowserServiceCompat methods (they defer function calls to mediaBrowserConnector)
+ 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)
+ }
+ disposables.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)
+
+ fun getPlayer(): Player? = playerService.get()?.player
+ }
+
+ 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/event/PlayerServiceExtendedEventListener.java b/app/src/main/java/org/schabi/newpipe/player/event/PlayerServiceExtendedEventListener.java
index 8effe2f0e93..15852088b6f 100644
--- a/app/src/main/java/org/schabi/newpipe/player/event/PlayerServiceExtendedEventListener.java
+++ b/app/src/main/java/org/schabi/newpipe/player/event/PlayerServiceExtendedEventListener.java
@@ -1,10 +1,12 @@
package org.schabi.newpipe.player.event;
+import androidx.annotation.Nullable;
+
import org.schabi.newpipe.player.PlayerService;
import org.schabi.newpipe.player.Player;
public interface PlayerServiceExtendedEventListener extends PlayerServiceEventListener {
- void onServiceConnected(Player player,
+ void onServiceConnected(@Nullable Player player,
PlayerService playerService,
boolean playAfterConnect);
void onServiceDisconnected();
diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHolder.java b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHolder.java
index b55a6547ab7..ba4fb377260 100644
--- a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHolder.java
+++ b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHolder.java
@@ -166,7 +166,7 @@ public void onServiceConnected(final ComponentName compName, final IBinder servi
}
final PlayerService.LocalBinder localBinder = (PlayerService.LocalBinder) service;
- playerService = localBinder.getService();
+ playerService = localBinder.getPlayer().getService();
player = localBinder.getPlayer();
if (listener != null) {
listener.onServiceConnected(player, playerService, playAfterConnect);
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..3228753354c
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserConnector.kt
@@ -0,0 +1,746 @@
+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()
+ builder
+ .setMediaId(createMediaIdForInfoItem(playlist is PlaylistRemoteEntity, playlist.uid))
+ .setTitle(playlist.orderingName)
+ .setIconUri(playlist.thumbnailUrl?.let { Uri.parse(it) })
+
+ val extras = Bundle()
+ extras.putString(
+ MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE,
+ 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(
+ isRemote: Boolean,
+ playlistId: Long,
+ ) = buildPlaylistMediaId(if (isRemote) 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(
+ isRemote: Boolean,
+ playlistId: Long,
+ ) = buildLocalPlaylistItemMediaId(isRemote, playlistId)
+ .build()
+ .toString()
+
+ private fun createLocalPlaylistStreamMediaItem(
+ playlistId: Long,
+ item: PlaylistStreamEntry,
+ index: Int
+ ): MediaBrowserCompat.MediaItem {
+ val builder = MediaDescriptionCompat.Builder()
+ builder.setMediaId(createMediaIdForPlaylistIndex(false, playlistId, index))
+ .setTitle(item.streamEntity.title)
+ .setSubtitle(item.streamEntity.uploader)
+ .setIconUri(Uri.parse(item.streamEntity.thumbnailUrl))
+
+ return MediaBrowserCompat.MediaItem(
+ builder.build(),
+ MediaBrowserCompat.MediaItem.FLAG_PLAYABLE
+ )
+ }
+
+ private fun createRemotePlaylistStreamMediaItem(
+ playlistId: Long,
+ item: StreamInfoItem,
+ index: Int
+ ): MediaBrowserCompat.MediaItem {
+ val builder = MediaDescriptionCompat.Builder()
+ builder.setMediaId(createMediaIdForPlaylistIndex(true, playlistId, index))
+ .setTitle(item.name)
+ .setSubtitle(item.uploaderName)
+ item.thumbnails.firstOrNull()?.let {
+ builder.setIconUri(Uri.parse(it.url))
+ }
+
+ return MediaBrowserCompat.MediaItem(
+ builder.build(),
+ MediaBrowserCompat.MediaItem.FLAG_PLAYABLE
+ )
+ }
+
+ private fun createMediaIdForPlaylistIndex(
+ isRemote: Boolean,
+ playlistId: Long,
+ index: Int,
+ ) = buildLocalPlaylistItemMediaId(isRemote, 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 = ArrayList(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(parentId)
+ }
+
+ ID_HISTORY -> return populateHistory()
+ else -> throw parseError(parentId)
+ }
+ } 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()
+ }
+
+ 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 = ArrayList(mediaIdUri.pathSegments)
+
+ if (path.isEmpty()) {
+ throw parseError(mediaId)
+ }
+
+ val uriType = path[0]
+ path.removeAt(0)
+
+ return when (uriType) {
+ ID_BOOKMARKS -> extractPlayQueueFromPlaylistMediaId(
+ mediaId,
+ path,
+ mediaIdUri.getQueryParameter(ID_URL)
+ )
+
+ ID_HISTORY -> extractPlayQueueFromHistoryMediaId(mediaId, path)
+ ID_INFO_ITEM -> extractPlayQueueFromInfoItemMediaId(
+ mediaId,
+ path,
+ mediaIdUri.getQueryParameter(ID_URL)
+ )
+
+ else -> throw parseError(mediaId)
+ }
+ } catch (error: ContentNotAvailableException) {
+ return Single.error(error)
+ }
+ }
+
+ @Throws(ContentNotAvailableException::class)
+ private fun extractPlayQueueFromPlaylistMediaId(
+ mediaId: String,
+ mediaIdSegments: ArrayList,
+ url: String?,
+ ): Single {
+ if (mediaIdSegments.isEmpty()) {
+ throw parseError(mediaId)
+ }
+
+ val playlistType = mediaIdSegments.first()
+ mediaIdSegments.removeAt(0)
+
+ when (playlistType) {
+ ID_LOCAL, ID_REMOTE -> {
+ if (mediaIdSegments.size != 2) {
+ throw parseError(mediaId)
+ }
+ 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(mediaId)
+ }
+
+ val serviceId = mediaIdSegments[0].toInt()
+ return ExtractorHelper
+ .getPlaylistInfo(serviceId, url, false)
+ .map(::PlaylistPlayQueue)
+ }
+
+ else -> throw parseError(mediaId)
+ }
+ }
+
+ @Throws(ContentNotAvailableException::class)
+ private fun extractPlayQueueFromHistoryMediaId(
+ mediaId: String,
+ path: List
+ ): Single {
+ if (path.size != 1) {
+ throw parseError(mediaId)
+ }
+
+ 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()
+ sessionConnector.setCustomErrorMessage(
+ playerService.getString(R.string.search_no_results),
+ PlaybackStateCompat.ERROR_CODE_APP_ERROR,
+ )
+ }
+
+ 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(mediaId: String): ContentNotAvailableException {
+ return ContentNotAvailableException("Failed to parse media ID $mediaId")
+ }
+
+ @Throws(ContentNotAvailableException::class)
+ private fun extractPlayQueueFromInfoItemMediaId(
+ mediaId: String,
+ path: List,
+ url: String?
+ ): Single {
+ if (path.size != 2) {
+ throw parseError(mediaId)
+ }
+ 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(mediaId)
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/org/schabi/newpipe/player/mediasession/MediaSessionPlayerUi.java b/app/src/main/java/org/schabi/newpipe/player/mediasession/MediaSessionPlayerUi.java
index c673e688c47..8b69db82fa9 100644
--- a/app/src/main/java/org/schabi/newpipe/player/mediasession/MediaSessionPlayerUi.java
+++ b/app/src/main/java/org/schabi/newpipe/player/mediasession/MediaSessionPlayerUi.java
@@ -50,8 +50,11 @@ public class MediaSessionPlayerUi extends PlayerUi
private List prevNotificationActions = List.of();
- public MediaSessionPlayerUi(@NonNull final Player player) {
+ public MediaSessionPlayerUi(@NonNull final Player player,
+ @NonNull final MediaSessionConnector sessionConnector) {
super(player);
+ this.mediaSession = sessionConnector.mediaSession;
+ this.sessionConnector = sessionConnector;
ignoreHardwareMediaButtonsKey =
context.getString(R.string.ignore_hardware_media_buttons_key);
}
@@ -61,10 +64,8 @@ public void initPlayer() {
super.initPlayer();
destroyPlayer(); // release previously used resources
- mediaSession = new MediaSessionCompat(context, TAG);
mediaSession.setActive(true);
- sessionConnector = new MediaSessionConnector(mediaSession);
sessionConnector.setQueueNavigator(new PlayQueueNavigator(mediaSession, player));
sessionConnector.setPlayer(getForwardingPlayer());
@@ -77,7 +78,6 @@ public void initPlayer() {
updateShouldIgnoreHardwareMediaButtons(player.getPrefs());
player.getPrefs().registerOnSharedPreferenceChangeListener(this);
- sessionConnector.setMetadataDeduplicationEnabled(true);
sessionConnector.setMediaMetadataProvider(exoPlayer -> buildMediaMetadata());
// force updating media session actions by resetting the previous ones
@@ -89,27 +89,23 @@ public void initPlayer() {
public void destroyPlayer() {
super.destroyPlayer();
player.getPrefs().unregisterOnSharedPreferenceChangeListener(this);
- if (sessionConnector != null) {
- sessionConnector.setMediaButtonEventHandler(null);
- sessionConnector.setPlayer(null);
- sessionConnector.setQueueNavigator(null);
- sessionConnector = null;
- }
- if (mediaSession != null) {
- mediaSession.setActive(false);
- mediaSession.release();
- mediaSession = null;
- }
+
+ sessionConnector.setMediaButtonEventHandler(null);
+ sessionConnector.setPlayer(null);
+ sessionConnector.setQueueNavigator(null);
+ sessionConnector.setMediaMetadataProvider(null);
+
+ mediaSession.setActive(false);
+
prevNotificationActions = List.of();
}
@Override
public void onThumbnailLoaded(@Nullable final Bitmap bitmap) {
super.onThumbnailLoaded(bitmap);
- if (sessionConnector != null) {
- // the thumbnail is now loaded: invalidate the metadata to trigger a metadata update
- sessionConnector.invalidateMediaSessionMetadata();
- }
+
+ // the thumbnail is now loaded: invalidate the metadata to trigger a metadata update
+ sessionConnector.invalidateMediaSessionMetadata();
}
@@ -132,7 +128,7 @@ public void handleMediaButtonIntent(final Intent intent) {
}
public Optional getSessionToken() {
- return Optional.ofNullable(mediaSession).map(MediaSessionCompat::getSessionToken);
+ return Optional.of(mediaSession.getSessionToken());
}
diff --git a/app/src/main/res/drawable/ic_bookmark_white.xml b/app/src/main/res/drawable/ic_bookmark_white.xml
new file mode 100644
index 00000000000..a04ed256e9d
--- /dev/null
+++ b/app/src/main/res/drawable/ic_bookmark_white.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_history_white.xml b/app/src/main/res/drawable/ic_history_white.xml
new file mode 100644
index 00000000000..585285b890c
--- /dev/null
+++ b/app/src/main/res/drawable/ic_history_white.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 766491d538a..cd67b7e6ca7 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -33,6 +33,7 @@
Show info
Subscriptions
Bookmarked Playlists
+ Playlists
Choose Tab
Background
Popup
diff --git a/app/src/main/res/xml/automotive_app_desc.xml b/app/src/main/res/xml/automotive_app_desc.xml
new file mode 100644
index 00000000000..90e6f30efe6
--- /dev/null
+++ b/app/src/main/res/xml/automotive_app_desc.xml
@@ -0,0 +1,3 @@
+
+
+