diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index d11de9f47..e52dded5e 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/MainActivity.java b/app/src/main/java/org/schabi/newpipe/MainActivity.java
index b9592085b..c4937431f 100644
--- a/app/src/main/java/org/schabi/newpipe/MainActivity.java
+++ b/app/src/main/java/org/schabi/newpipe/MainActivity.java
@@ -862,7 +862,8 @@ public class MainActivity extends AppCompatActivity {
@Override
public void onReceive(final Context context, final Intent intent) {
if (Objects.equals(intent.getAction(),
- VideoDetailFragment.ACTION_PLAYER_STARTED)) {
+ VideoDetailFragment.ACTION_PLAYER_STARTED)
+ && PlayerHolder.getInstance().isPlayerOpen()) {
openMiniPlayerIfMissing();
// At this point the player is added 100%, we can unregister. Other actions
// are useless since the fragment will not be removed after that.
@@ -874,6 +875,10 @@ public class MainActivity extends AppCompatActivity {
final IntentFilter intentFilter = new IntentFilter();
intentFilter.addAction(VideoDetailFragment.ACTION_PLAYER_STARTED);
registerReceiver(broadcastReceiver, intentFilter);
+
+ // If the PlayerHolder is not bound yet, but the service is running, try to bind to it.
+ // Once the connection is established, the ACTION_PLAYER_STARTED will be sent.
+ PlayerHolder.getInstance().tryBindIfNeeded(this);
}
}
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 a93ba1652..27fc429f1 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
index 072c49e2c..91f4622e9 100644
--- a/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistLocalItem.java
+++ b/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistLocalItem.java
@@ -1,5 +1,7 @@
package org.schabi.newpipe.database.playlist;
+import androidx.annotation.Nullable;
+
import org.schabi.newpipe.database.LocalItem;
public interface PlaylistLocalItem extends LocalItem {
@@ -10,4 +12,7 @@ public interface PlaylistLocalItem extends LocalItem {
long getUid();
void setDisplayIndex(long displayIndex);
+
+ @Nullable
+ String getThumbnailUrl();
}
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 03a1e1e30..8fbadb020 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
@@ -9,6 +9,8 @@ import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST
import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_THUMBNAIL_STREAM_ID;
import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_THUMBNAIL_URL;
+import androidx.annotation.Nullable;
+
public class PlaylistMetadataEntry implements PlaylistLocalItem {
public static final String PLAYLIST_STREAM_COUNT = "streamCount";
@@ -71,4 +73,10 @@ public class PlaylistMetadataEntry implements PlaylistLocalItem {
public void setDisplayIndex(final long displayIndex) {
this.displayIndex = displayIndex;
}
+
+ @Nullable
+ @Override
+ public String getThumbnailUrl() {
+ return thumbnailUrl;
+ }
}
diff --git a/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistRemoteDAO.java b/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistRemoteDAO.java
index 8ab8a2afd..ef77d5ade 100644
--- a/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistRemoteDAO.java
+++ b/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistRemoteDAO.java
@@ -34,7 +34,7 @@ public interface PlaylistRemoteDAO extends BasicDAO {
@Query("SELECT * FROM " + REMOTE_PLAYLIST_TABLE + " WHERE "
+ REMOTE_PLAYLIST_ID + " = :playlistId")
- Flowable> getPlaylist(long playlistId);
+ Flowable getPlaylist(long playlistId);
@Query("SELECT * FROM " + REMOTE_PLAYLIST_TABLE + " WHERE "
+ REMOTE_PLAYLIST_URL + " = :url AND " + REMOTE_PLAYLIST_SERVICE_ID + " = :serviceId")
diff --git a/app/src/main/java/org/schabi/newpipe/database/playlist/model/PlaylistRemoteEntity.java b/app/src/main/java/org/schabi/newpipe/database/playlist/model/PlaylistRemoteEntity.java
index 60027a057..0b0e3605e 100644
--- a/app/src/main/java/org/schabi/newpipe/database/playlist/model/PlaylistRemoteEntity.java
+++ b/app/src/main/java/org/schabi/newpipe/database/playlist/model/PlaylistRemoteEntity.java
@@ -2,6 +2,7 @@ package org.schabi.newpipe.database.playlist.model;
import android.text.TextUtils;
+import androidx.annotation.Nullable;
import androidx.room.ColumnInfo;
import androidx.room.Entity;
import androidx.room.Ignore;
@@ -134,6 +135,8 @@ public class PlaylistRemoteEntity implements PlaylistLocalItem {
this.name = name;
}
+ @Nullable
+ @Override
public String getThumbnailUrl() {
return thumbnailUrl;
}
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 40a22103b..083d1fe05 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
@@ -236,11 +236,14 @@ public final class VideoDetailFragment
// Service management
//////////////////////////////////////////////////////////////////////////*/
@Override
- public void onServiceConnected(final Player connectedPlayer,
- final PlayerService connectedPlayerService,
- final boolean playAfterConnect) {
- player = connectedPlayer;
+ public void onServiceConnected(@NonNull final PlayerService connectedPlayerService) {
playerService = connectedPlayerService;
+ }
+
+ @Override
+ public void onPlayerConnected(@NonNull final Player connectedPlayer,
+ final boolean playAfterConnect) {
+ player = connectedPlayer;
// It will do nothing if the player is not in fullscreen mode
hideSystemUiIfNeeded();
@@ -272,11 +275,18 @@ public final class VideoDetailFragment
updateOverlayPlayQueueButtonVisibility();
}
+ @Override
+ public void onPlayerDisconnected() {
+ player = null;
+ // the binding could be null at this point, if the app is finishing
+ if (binding != null) {
+ restoreDefaultBrightness();
+ }
+ }
+
@Override
public void onServiceDisconnected() {
playerService = null;
- player = null;
- restoreDefaultBrightness();
}
@@ -1848,13 +1858,16 @@ public final class VideoDetailFragment
@Override
public void onServiceStopped() {
- setOverlayPlayPauseImage(false);
- if (currentInfo != null) {
- updateOverlayData(currentInfo.getName(),
- currentInfo.getUploaderName(),
- currentInfo.getThumbnails());
+ // the binding could be null at this point, if the app is finishing
+ if (binding != null) {
+ setOverlayPlayPauseImage(false);
+ if (currentInfo != null) {
+ updateOverlayData(currentInfo.getName(),
+ currentInfo.getUploaderName(),
+ currentInfo.getThumbnails());
+ }
+ updateOverlayPlayQueueButtonVisibility();
}
- updateOverlayPlayQueueButtonVisibility();
}
@Override
diff --git a/app/src/main/java/org/schabi/newpipe/ktx/Bundle.kt b/app/src/main/java/org/schabi/newpipe/ktx/Bundle.kt
index 61721d546..e32376960 100644
--- a/app/src/main/java/org/schabi/newpipe/ktx/Bundle.kt
+++ b/app/src/main/java/org/schabi/newpipe/ktx/Bundle.kt
@@ -7,3 +7,16 @@ import androidx.core.os.BundleCompat
inline fun Bundle.parcelableArrayList(key: String?): ArrayList? {
return BundleCompat.getParcelableArrayList(this, key, T::class.java)
}
+
+fun Bundle?.toDebugString(): String {
+ if (this == null) {
+ return "null"
+ }
+ val string = StringBuilder("Bundle{")
+ for (key in this.keySet()) {
+ @Suppress("DEPRECATION") // we want this[key] to return items of any type
+ string.append(" ").append(key).append(" => ").append(this[key]).append(";")
+ }
+ string.append(" }")
+ return string.toString()
+}
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 4cc51f752..08b203a7e 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 class RemotePlaylistManager {
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 195baecbd..49aff657a 100644
--- a/app/src/main/java/org/schabi/newpipe/player/PlayQueueActivity.java
+++ b/app/src/main/java/org/schabi/newpipe/player/PlayQueueActivity.java
@@ -183,7 +183,10 @@ public final class PlayQueueActivity extends AppCompatActivity
////////////////////////////////////////////////////////////////////////////
private void bind() {
+ // Note: this code should not really exist, and PlayerHolder should be used instead, but
+ // it will be rewritten when NewPlayer will replace the current player.
final Intent bindIntent = new Intent(this, PlayerService.class);
+ bindIntent.setAction(PlayerService.BIND_PLAYER_HOLDER_ACTION);
final boolean success = bindService(bindIntent, serviceConnection, BIND_AUTO_CREATE);
if (!success) {
unbindService(serviceConnection);
@@ -221,7 +224,7 @@ public final class PlayQueueActivity extends AppCompatActivity
Log.d(TAG, "Player service is connected");
if (service instanceof PlayerService.LocalBinder) {
- player = ((PlayerService.LocalBinder) service).getPlayer();
+ player = ((PlayerService.LocalBinder) service).getService().getPlayer();
}
if (player == null || player.getPlayQueue() == null || player.exoPlayerIsNull()) {
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 920435a7e..040f0dc99 100644
--- a/app/src/main/java/org/schabi/newpipe/player/Player.java
+++ b/app/src/main/java/org/schabi/newpipe/player/Player.java
@@ -55,6 +55,7 @@ import android.content.SharedPreferences;
import android.graphics.Bitmap;
import android.graphics.drawable.Drawable;
import android.media.AudioManager;
+import android.support.v4.media.session.MediaSessionCompat;
import android.util.Log;
import android.view.LayoutInflater;
@@ -71,6 +72,7 @@ import com.google.android.exoplayer2.PlaybackParameters;
import com.google.android.exoplayer2.Player.PositionInfo;
import com.google.android.exoplayer2.Timeline;
import com.google.android.exoplayer2.Tracks;
+import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector;
import com.google.android.exoplayer2.source.MediaSource;
import com.google.android.exoplayer2.text.CueGroup;
import com.google.android.exoplayer2.trackselection.DefaultTrackSelector;
@@ -269,7 +271,16 @@ public final class Player implements PlaybackListener, Listener {
//////////////////////////////////////////////////////////////////////////*/
//region Constructor
- public Player(@NonNull final PlayerService service) {
+ /**
+ * @param service the service this player resides in
+ * @param mediaSession used to build the {@link MediaSessionPlayerUi}, lives in the service and
+ * could possibly be reused with multiple player instances
+ * @param sessionConnector used to build the {@link MediaSessionPlayerUi}, lives in the service
+ * and could possibly be reused with multiple player instances
+ */
+ public Player(@NonNull final PlayerService service,
+ @NonNull final MediaSessionCompat mediaSession,
+ @NonNull final MediaSessionConnector sessionConnector) {
this.service = service;
context = service;
prefs = PreferenceManager.getDefaultSharedPreferences(context);
@@ -302,7 +313,7 @@ public final class Player implements PlaybackListener, Listener {
// 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, mediaSession, sessionConnector),
new NotificationPlayerUi(this)
);
}
@@ -646,7 +657,7 @@ public final class Player implements PlaybackListener, Listener {
Log.d(TAG, "onPlaybackShutdown() called");
}
// destroys the service, which in turn will destroy the player
- service.stopService();
+ service.destroyPlayerAndStopService();
}
public void smoothStopForImmediateReusing() {
@@ -718,7 +729,7 @@ public final class Player implements PlaybackListener, Listener {
pause();
break;
case ACTION_CLOSE:
- service.stopService();
+ service.destroyPlayerAndStopService();
break;
case ACTION_PLAY_PAUSE:
playPause();
@@ -1375,6 +1386,19 @@ public final class Player implements PlaybackListener, Listener {
public void onCues(@NonNull final CueGroup cueGroup) {
UIs.call(playerUi -> playerUi.onCues(cueGroup.cues));
}
+
+ /**
+ * To be called when the {@code PlaybackPreparer} set in the {@link MediaSessionConnector}
+ * receives an {@code onPrepare()} call. This function allows restoring the default behavior
+ * that would happen if there was no playback preparer set, i.e. to just call
+ * {@code player.prepare()}. You can find the default behavior in `onPlay()` inside the
+ * {@link MediaSessionConnector} file.
+ */
+ public void onPrepare() {
+ if (!exoPlayerIsNull()) {
+ simpleExoPlayer.prepare();
+ }
+ }
//endregion
diff --git a/app/src/main/java/org/schabi/newpipe/player/PlayerService.java b/app/src/main/java/org/schabi/newpipe/player/PlayerService.java
index e7abf4320..1888bce01 100644
--- a/app/src/main/java/org/schabi/newpipe/player/PlayerService.java
+++ b/app/src/main/java/org/schabi/newpipe/player/PlayerService.java
@@ -21,75 +21,142 @@ 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.Bundle;
import android.os.IBinder;
+import android.support.v4.media.MediaBrowserCompat;
+import android.support.v4.media.session.MediaSessionCompat;
import android.util.Log;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.core.app.ServiceCompat;
+import androidx.media.MediaBrowserServiceCompat;
+
+import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector;
+
+import org.schabi.newpipe.ktx.BundleKt;
+import org.schabi.newpipe.player.mediabrowser.MediaBrowserImpl;
+import org.schabi.newpipe.player.mediabrowser.MediaBrowserPlaybackPreparer;
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.function.Consumer;
/**
* One service for all players.
*/
-public final class PlayerService extends Service {
+public final class PlayerService extends MediaBrowserServiceCompat {
private static final String TAG = PlayerService.class.getSimpleName();
private static final boolean DEBUG = Player.DEBUG;
+ public static final String SHOULD_START_FOREGROUND_EXTRA = "should_start_foreground_extra";
+ public static final String BIND_PLAYER_HOLDER_ACTION = "bind_player_holder_action";
+
+ // These objects are used to cleanly separate the Service implementation (in this file) and the
+ // media browser and playback preparer implementations. At the moment the playback preparer is
+ // only used in conjunction with the media browser.
+ private MediaBrowserImpl mediaBrowserImpl;
+ private MediaBrowserPlaybackPreparer mediaBrowserPlaybackPreparer;
+
+ // these are instantiated in onCreate() as per
+ // https://developer.android.com/training/cars/media#browser_workflow
+ private MediaSessionCompat mediaSession;
+ private MediaSessionConnector sessionConnector;
+
+ @Nullable
private Player player;
private final IBinder mBinder = new PlayerService.LocalBinder(this);
+ /**
+ * The parameter taken by this {@link Consumer} can be null to indicate the player is being
+ * stopped.
+ */
+ @Nullable
+ private Consumer onPlayerStartedOrStopped = null;
- /*//////////////////////////////////////////////////////////////////////////
- // Service's LifeCycle
- //////////////////////////////////////////////////////////////////////////*/
+ //region Service lifecycle
@Override
public void onCreate() {
+ super.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);
+ mediaBrowserImpl = new MediaBrowserImpl(this, this::notifyChildrenChanged);
+
+ // see https://developer.android.com/training/cars/media#browser_workflow
+ mediaSession = new MediaSessionCompat(this, "MediaSessionPlayerServ");
+ setSessionToken(mediaSession.getSessionToken());
+ sessionConnector = new MediaSessionConnector(mediaSession);
+ sessionConnector.setMetadataDeduplicationEnabled(true);
+
+ mediaBrowserPlaybackPreparer = new MediaBrowserPlaybackPreparer(
+ this,
+ sessionConnector::setCustomErrorMessage,
+ () -> sessionConnector.setCustomErrorMessage(null),
+ (playWhenReady) -> {
+ if (player != null) {
+ player.onPrepare();
+ }
+ }
+ );
+ sessionConnector.setPlaybackPreparer(mediaBrowserPlaybackPreparer);
+
+ // Note: you might be tempted to create the player instance and call startForeground here,
+ // but be aware that the Android system might start the service just to perform media
+ // queries. In those cases creating a player instance is a waste of resources, and calling
+ // startForeground means creating a useless empty notification. In case it's really needed
+ // the player instance can be created here, but startForeground() should definitely not be
+ // called here unless the service is actually starting in the foreground, to avoid the
+ // useless notification.
}
@Override
public int onStartCommand(final Intent intent, final int flags, final int startId) {
if (DEBUG) {
Log.d(TAG, "onStartCommand() called with: intent = [" + intent
+ + "], extras = [" + BundleKt.toDebugString(intent.getExtras())
+ "], 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) {
+ // All internal NewPipe intents used to interact with the player, that are sent to the
+ // PlayerService using startForegroundService(), will have SHOULD_START_FOREGROUND_EXTRA,
+ // to ensure startForeground() is called (otherwise Android will force-crash the app).
+ if (intent.getBooleanExtra(SHOULD_START_FOREGROUND_EXTRA, false)) {
+ final boolean playerWasNull = (player == null);
+ if (playerWasNull) {
+ // make sure the player exists, in case the service was resumed
+ player = new Player(this, mediaSession, sessionConnector);
+ }
+
+ // 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)
.ifPresent(NotificationPlayerUi::createNotificationAndStartForeground);
+
+ if (playerWasNull && onPlayerStartedOrStopped != null) {
+ // notify that a new player was created (but do it after creating the foreground
+ // notification just to make sure we don't incur, due to slowness, in
+ // "Context.startForegroundService() did not then call Service.startForeground()")
+ onPlayerStartedOrStopped.accept(player);
+ }
}
if (Intent.ACTION_MEDIA_BUTTON.equals(intent.getAction())
@@ -100,7 +167,7 @@ public final class PlayerService extends Service {
Stop the service in this case, which will be removed from the foreground and its
notification cancelled in its destruction
*/
- stopSelf();
+ destroyPlayerAndStopService();
return START_NOT_STICKY;
}
@@ -142,29 +209,84 @@ public final class PlayerService extends Service {
if (DEBUG) {
Log.d(TAG, "destroy() called");
}
+ super.onDestroy();
+
cleanup();
+
+ mediaBrowserPlaybackPreparer.dispose();
+ mediaSession.release();
+ mediaBrowserImpl.dispose();
}
private void cleanup() {
if (player != null) {
+ if (onPlayerStartedOrStopped != null) {
+ // notify that the player is being destroyed
+ onPlayerStartedOrStopped.accept(null);
+ }
player.destroy();
player = null;
}
+
+ // Should already be handled by MediaSessionPlayerUi, but just to be sure.
+ mediaSession.setActive(false);
+
+ // Should already be handled by NotificationUtil.cancelNotificationAndStopForeground() in
+ // NotificationPlayerUi, but let's make sure that the foreground service is stopped.
+ ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE);
}
- public void stopService() {
+ /**
+ * Destroys the player and allows the player instance to be garbage collected. Sets the media
+ * session to inactive. Stops the foreground service and removes the player notification
+ * associated with it. Tries to stop the {@link PlayerService} completely, but this step will
+ * have no effect in case some service connection still uses the service (e.g. the Android Auto
+ * system accesses the media browser even when no player is running).
+ */
+ public void destroyPlayerAndStopService() {
+ if (DEBUG) {
+ Log.d(TAG, "destroyPlayerAndStopService() called");
+ }
+
cleanup();
- stopSelf();
+
+ // This only really stops the service if there are no other service connections (see docs):
+ // for example the (Android Auto) media browser binder will block stopService().
+ // This is why we also stopForeground() above, to make sure the notification is removed.
+ // If we were to call stopSelf(), then the service would be surely stopped (regardless of
+ // other service connections), but this would be a waste of resources since the service
+ // would be immediately restarted by those same connections to perform the queries.
+ stopService(new Intent(this, PlayerService.class));
}
@Override
protected void attachBaseContext(final Context base) {
super.attachBaseContext(AudioServiceLeakFix.preventLeakOf(base));
}
+ //endregion
+ //region Bind
@Override
public IBinder onBind(final Intent intent) {
- return mBinder;
+ if (DEBUG) {
+ Log.d(TAG, "onBind() called with: intent = [" + intent
+ + "], extras = [" + BundleKt.toDebugString(intent.getExtras()) + "]");
+ }
+
+ if (BIND_PLAYER_HOLDER_ACTION.equals(intent.getAction())) {
+ // Note that this binder might be reused multiple times while the service is alive, even
+ // after unbind() has been called: https://stackoverflow.com/a/8794930 .
+ return mBinder;
+
+ } else if (MediaBrowserServiceCompat.SERVICE_INTERFACE.equals(intent.getAction())) {
+ // MediaBrowserService also uses its own binder, so for actions related to the media
+ // browser service, pass the onBind to the superclass.
+ return super.onBind(intent);
+
+ } else {
+ // This is an unknown request, avoid returning any binder to not leak objects.
+ return null;
+ }
}
public static class LocalBinder extends Binder {
@@ -177,9 +299,52 @@ public final class PlayerService extends Service {
public PlayerService getService() {
return playerService.get();
}
+ }
- public Player getPlayer() {
- return playerService.get().player;
+ /**
+ * @return the current active player instance. May be null, since the player service can outlive
+ * the player e.g. to respond to Android Auto media browser queries.
+ */
+ @Nullable
+ public Player getPlayer() {
+ return player;
+ }
+
+ /**
+ * Sets the listener that will be called when the player is started or stopped. If a
+ * {@code null} listener is passed, then the current listener will be unset. The parameter taken
+ * by the {@link Consumer} can be null to indicate that the player is stopping.
+ * @param listener the listener to set or unset
+ */
+ public void setPlayerListener(@Nullable final Consumer listener) {
+ this.onPlayerStartedOrStopped = listener;
+ if (listener != null) {
+ // if there is no player, then `null` will be sent here, to ensure the state is synced
+ listener.accept(player);
}
}
+ //endregion
+
+ //region Media browser
+ @Override
+ public BrowserRoot onGetRoot(@NonNull final String clientPackageName,
+ final int clientUid,
+ @Nullable final Bundle rootHints) {
+ // TODO check if the accessing package has permission to view data
+ return mediaBrowserImpl.onGetRoot(clientPackageName, clientUid, rootHints);
+ }
+
+ @Override
+ public void onLoadChildren(@NonNull final String parentId,
+ @NonNull final Result> result) {
+ mediaBrowserImpl.onLoadChildren(parentId, result);
+ }
+
+ @Override
+ public void onSearch(@NonNull final String query,
+ final Bundle extras,
+ @NonNull final Result> result) {
+ mediaBrowserImpl.onSearch(query, result);
+ }
+ //endregion
}
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 8effe2f0e..549abc952 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,11 +1,48 @@
package org.schabi.newpipe.player.event;
+import androidx.annotation.NonNull;
+
import org.schabi.newpipe.player.PlayerService;
import org.schabi.newpipe.player.Player;
+/**
+ * In addition to {@link PlayerServiceEventListener}, provides callbacks for service and player
+ * connections and disconnections. "Connected" here means that the service (resp. the
+ * player) is running and is bound to {@link org.schabi.newpipe.player.helper.PlayerHolder}.
+ * "Disconnected" means that either the service (resp. the player) was stopped completely, or that
+ * {@link org.schabi.newpipe.player.helper.PlayerHolder} is not bound.
+ */
public interface PlayerServiceExtendedEventListener extends PlayerServiceEventListener {
- void onServiceConnected(Player player,
- PlayerService playerService,
- boolean playAfterConnect);
+ /**
+ * The player service just connected to {@link org.schabi.newpipe.player.helper.PlayerHolder},
+ * but the player may not be active at this moment, e.g. in case the service is running to
+ * respond to Android Auto media browser queries without playing anything.
+ * {@link #onPlayerConnected(Player, boolean)} will be called right after this function if there
+ * is a player.
+ *
+ * @param playerService the newly connected player service
+ */
+ void onServiceConnected(@NonNull PlayerService playerService);
+
+ /**
+ * The player service is already connected and the player was just started.
+ *
+ * @param player the newly connected or started player
+ * @param playAfterConnect whether to open the video player in the video details fragment
+ */
+ void onPlayerConnected(@NonNull Player player, boolean playAfterConnect);
+
+ /**
+ * The player got disconnected, for one of these reasons: the player is getting closed while
+ * leaving the service open for future media browser queries, the service is stopping
+ * completely, or {@link org.schabi.newpipe.player.helper.PlayerHolder} is unbinding.
+ */
+ void onPlayerDisconnected();
+
+ /**
+ * The service got disconnected from {@link org.schabi.newpipe.player.helper.PlayerHolder},
+ * either because {@link org.schabi.newpipe.player.helper.PlayerHolder} is unbinding or because
+ * the service is stopping completely.
+ */
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 b55a6547a..20a0f3766 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
@@ -22,6 +22,10 @@ import org.schabi.newpipe.player.PlayerType;
import org.schabi.newpipe.player.event.PlayerServiceEventListener;
import org.schabi.newpipe.player.event.PlayerServiceExtendedEventListener;
import org.schabi.newpipe.player.playqueue.PlayQueue;
+import org.schabi.newpipe.util.NavigationHelper;
+
+import java.util.Optional;
+import java.util.function.Consumer;
public final class PlayerHolder {
@@ -44,7 +48,16 @@ public final class PlayerHolder {
private final PlayerServiceConnection serviceConnection = new PlayerServiceConnection();
private boolean bound;
@Nullable private PlayerService playerService;
- @Nullable private Player player;
+
+ private Optional getPlayer() {
+ return Optional.ofNullable(playerService)
+ .flatMap(s -> Optional.ofNullable(s.getPlayer()));
+ }
+
+ private Optional getPlayQueue() {
+ // player play queue might be null e.g. while player is starting
+ return getPlayer().flatMap(p -> Optional.ofNullable(p.getPlayQueue()));
+ }
/**
* Returns the current {@link PlayerType} of the {@link PlayerService} service,
@@ -54,21 +67,15 @@ public final class PlayerHolder {
*/
@Nullable
public PlayerType getType() {
- if (player == null) {
- return null;
- }
- return player.getPlayerType();
+ return getPlayer().map(Player::getPlayerType).orElse(null);
}
public boolean isPlaying() {
- if (player == null) {
- return false;
- }
- return player.isPlaying();
+ return getPlayer().map(Player::isPlaying).orElse(false);
}
public boolean isPlayerOpen() {
- return player != null;
+ return getPlayer().isPresent();
}
/**
@@ -77,7 +84,7 @@ public final class PlayerHolder {
* @return true only if the player is open and its play queue is ready (i.e. it is not null)
*/
public boolean isPlayQueueReady() {
- return player != null && player.getPlayQueue() != null;
+ return getPlayQueue().isPresent();
}
public boolean isBound() {
@@ -85,18 +92,11 @@ public final class PlayerHolder {
}
public int getQueueSize() {
- if (player == null || player.getPlayQueue() == null) {
- // player play queue might be null e.g. while player is starting
- return 0;
- }
- return player.getPlayQueue().size();
+ return getPlayQueue().map(PlayQueue::size).orElse(0);
}
public int getQueuePosition() {
- if (player == null || player.getPlayQueue() == null) {
- return 0;
- }
- return player.getPlayQueue().getIndex();
+ return getPlayQueue().map(PlayQueue::getIndex).orElse(0);
}
public void setListener(@Nullable final PlayerServiceExtendedEventListener newListener) {
@@ -107,9 +107,10 @@ public final class PlayerHolder {
}
// Force reload data from service
- if (player != null) {
- listener.onServiceConnected(player, playerService, false);
+ if (playerService != null) {
+ listener.onServiceConnected(playerService);
startPlayerListener();
+ // ^ will call listener.onPlayerConnected() down the line if there is an active player
}
}
@@ -121,6 +122,9 @@ public final class PlayerHolder {
public void startService(final boolean playAfterConnect,
final PlayerServiceExtendedEventListener newListener) {
+ if (DEBUG) {
+ Log.d(TAG, "startService() called with playAfterConnect=" + playAfterConnect);
+ }
final Context context = getCommonContext();
setListener(newListener);
if (bound) {
@@ -130,14 +134,24 @@ public final class PlayerHolder {
// and NullPointerExceptions inside the service because the service will be
// bound twice. Prevent it with unbinding first
unbind(context);
- ContextCompat.startForegroundService(context, new Intent(context, PlayerService.class));
+ final Intent intent = new Intent(context, PlayerService.class);
+ intent.putExtra(PlayerService.SHOULD_START_FOREGROUND_EXTRA, true);
+ ContextCompat.startForegroundService(context, intent);
serviceConnection.doPlayAfterConnect(playAfterConnect);
bind(context);
}
public void stopService() {
+ if (DEBUG) {
+ Log.d(TAG, "stopService() called");
+ }
+ if (playerService != null) {
+ playerService.destroyPlayerAndStopService();
+ }
final Context context = getCommonContext();
unbind(context);
+ // destroyPlayerAndStopService() already runs the next line of code, but run it again just
+ // to make sure to stop the service even if playerService is null by any chance.
context.stopService(new Intent(context, PlayerService.class));
}
@@ -167,11 +181,16 @@ public final class PlayerHolder {
final PlayerService.LocalBinder localBinder = (PlayerService.LocalBinder) service;
playerService = localBinder.getService();
- player = localBinder.getPlayer();
if (listener != null) {
- listener.onServiceConnected(player, playerService, playAfterConnect);
+ listener.onServiceConnected(playerService);
+ getPlayer().ifPresent(p -> listener.onPlayerConnected(p, playAfterConnect));
}
startPlayerListener();
+ // ^ will call listener.onPlayerConnected() down the line if there is an active player
+
+ // notify the main activity that binding the service has completed, so that it can
+ // open the bottom mini-player
+ NavigationHelper.sendPlayerStartedEvent(localBinder.getService());
}
}
@@ -179,15 +198,28 @@ public final class PlayerHolder {
if (DEBUG) {
Log.d(TAG, "bind() called");
}
-
- final Intent serviceIntent = new Intent(context, PlayerService.class);
- bound = context.bindService(serviceIntent, serviceConnection,
- Context.BIND_AUTO_CREATE);
+ // BIND_AUTO_CREATE starts the service if it's not already running
+ bound = bind(context, Context.BIND_AUTO_CREATE);
if (!bound) {
context.unbindService(serviceConnection);
}
}
+ public void tryBindIfNeeded(final Context context) {
+ if (!bound) {
+ // flags=0 means the service will not be started if it does not already exist. In this
+ // case the return value is not useful, as a value of "true" does not really indicate
+ // that the service is going to be bound.
+ bind(context, 0);
+ }
+ }
+
+ private boolean bind(final Context context, final int flags) {
+ final Intent serviceIntent = new Intent(context, PlayerService.class);
+ serviceIntent.setAction(PlayerService.BIND_PLAYER_HOLDER_ACTION);
+ return context.bindService(serviceIntent, serviceConnection, flags);
+ }
+
private void unbind(final Context context) {
if (DEBUG) {
Log.d(TAG, "unbind() called");
@@ -198,25 +230,32 @@ public final class PlayerHolder {
bound = false;
stopPlayerListener();
playerService = null;
- player = null;
if (listener != null) {
+ listener.onPlayerDisconnected();
listener.onServiceDisconnected();
}
}
}
private void startPlayerListener() {
- if (player != null) {
- player.setFragmentListener(internalListener);
+ if (playerService != null) {
+ // setting the player listener will take care of calling relevant callbacks if the
+ // player in the service is (not) already active, also see playerStateListener below
+ playerService.setPlayerListener(playerStateListener);
}
+ getPlayer().ifPresent(p -> p.setFragmentListener(internalListener));
}
private void stopPlayerListener() {
- if (player != null) {
- player.removeFragmentListener(internalListener);
+ if (playerService != null) {
+ playerService.setPlayerListener(null);
}
+ getPlayer().ifPresent(p -> p.removeFragmentListener(internalListener));
}
+ /**
+ * This listener will be held by the players created by {@link PlayerService}.
+ */
private final PlayerServiceEventListener internalListener =
new PlayerServiceEventListener() {
@Override
@@ -303,4 +342,23 @@ public final class PlayerHolder {
unbind(getCommonContext());
}
};
+
+ /**
+ * This listener will be held by bound {@link PlayerService}s to notify of the player starting
+ * or stopping. This is necessary since the service outlives the player e.g. to answer Android
+ * Auto media browser queries.
+ */
+ private final Consumer playerStateListener = (@Nullable final Player player) -> {
+ if (listener != null) {
+ if (player == null) {
+ // player.fragmentListener=null is already done by player.stopActivityBinding(),
+ // which is called by player.destroy(), which is in turn called by PlayerService
+ // before setting its player to null
+ listener.onPlayerDisconnected();
+ } else {
+ listener.onPlayerConnected(player, serviceConnection.playAfterConnect);
+ player.setFragmentListener(internalListener);
+ }
+ }
+ };
}
diff --git a/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserCommon.kt b/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserCommon.kt
new file mode 100644
index 000000000..12d69a163
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserCommon.kt
@@ -0,0 +1,40 @@
+package org.schabi.newpipe.player.mediabrowser
+
+import org.schabi.newpipe.BuildConfig
+import org.schabi.newpipe.extractor.InfoItem.InfoType
+import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException
+
+internal const val ID_AUTHORITY = BuildConfig.APPLICATION_ID
+internal const val ID_ROOT = "//$ID_AUTHORITY"
+internal const val ID_BOOKMARKS = "playlists"
+internal const val ID_HISTORY = "history"
+internal const val ID_INFO_ITEM = "item"
+
+internal const val ID_LOCAL = "local"
+internal const val ID_REMOTE = "remote"
+internal const val ID_URL = "url"
+internal const val ID_STREAM = "stream"
+internal const val ID_PLAYLIST = "playlist"
+internal const val ID_CHANNEL = "channel"
+
+internal 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")
+ }
+}
+
+internal 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")
+ }
+}
+
+internal fun parseError(mediaId: String): ContentNotAvailableException {
+ return ContentNotAvailableException("Failed to parse media ID $mediaId")
+}
diff --git a/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserImpl.kt b/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserImpl.kt
new file mode 100644
index 000000000..3108da80f
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserImpl.kt
@@ -0,0 +1,399 @@
+package org.schabi.newpipe.player.mediabrowser
+
+import android.content.ContentResolver
+import android.content.Context
+import android.net.Uri
+import android.os.Bundle
+import android.support.v4.media.MediaBrowserCompat
+import android.support.v4.media.MediaDescriptionCompat
+import android.util.Log
+import androidx.annotation.DrawableRes
+import androidx.media.MediaBrowserServiceCompat
+import androidx.media.MediaBrowserServiceCompat.Result
+import androidx.media.utils.MediaConstants
+import io.reactivex.rxjava3.core.Flowable
+import io.reactivex.rxjava3.core.Single
+import io.reactivex.rxjava3.disposables.CompositeDisposable
+import io.reactivex.rxjava3.schedulers.Schedulers
+import org.schabi.newpipe.MainActivity.DEBUG
+import org.schabi.newpipe.NewPipeDatabase
+import org.schabi.newpipe.R
+import org.schabi.newpipe.database.history.model.StreamHistoryEntry
+import org.schabi.newpipe.database.playlist.PlaylistLocalItem
+import org.schabi.newpipe.database.playlist.PlaylistStreamEntry
+import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity
+import org.schabi.newpipe.extractor.InfoItem
+import org.schabi.newpipe.extractor.InfoItem.InfoType
+import org.schabi.newpipe.extractor.channel.ChannelInfoItem
+import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException
+import org.schabi.newpipe.extractor.playlist.PlaylistInfoItem
+import org.schabi.newpipe.extractor.search.SearchInfo
+import org.schabi.newpipe.extractor.stream.StreamInfoItem
+import org.schabi.newpipe.local.bookmark.MergedPlaylistManager
+import org.schabi.newpipe.local.playlist.LocalPlaylistManager
+import org.schabi.newpipe.local.playlist.RemotePlaylistManager
+import org.schabi.newpipe.util.ExtractorHelper
+import org.schabi.newpipe.util.ServiceHelper
+import org.schabi.newpipe.util.image.ImageStrategy
+import java.util.function.Consumer
+
+/**
+ * This class is used to cleanly separate the Service implementation (in
+ * [org.schabi.newpipe.player.PlayerService]) and the media browser implementation (in this file).
+ *
+ * @param notifyChildrenChanged takes the parent id of the children that changed
+ */
+class MediaBrowserImpl(
+ private val context: Context,
+ notifyChildrenChanged: Consumer, // parentId
+) {
+ private val database = NewPipeDatabase.getInstance(context)
+ private var disposables = CompositeDisposable()
+
+ init {
+ // this will listen to changes in the bookmarks until this MediaBrowserImpl is dispose()d
+ disposables.add(
+ getMergedPlaylists().subscribe { notifyChildrenChanged.accept(ID_BOOKMARKS) }
+ )
+ }
+
+ //region Cleanup
+ fun dispose() {
+ disposables.dispose()
+ }
+ //endregion
+
+ //region onGetRoot
+ fun onGetRoot(
+ clientPackageName: String,
+ clientUid: Int,
+ rootHints: Bundle?
+ ): MediaBrowserServiceCompat.BrowserRoot {
+ if (DEBUG) {
+ Log.d(TAG, "onGetRoot($clientPackageName, $clientUid, $rootHints)")
+ }
+
+ val extras = Bundle()
+ extras.putBoolean(
+ MediaConstants.BROWSER_SERVICE_EXTRAS_KEY_SEARCH_SUPPORTED, true
+ )
+ return MediaBrowserServiceCompat.BrowserRoot(ID_ROOT, extras)
+ }
+ //endregion
+
+ //region onLoadChildren
+ fun onLoadChildren(parentId: String, result: Result>) {
+ if (DEBUG) {
+ Log.d(TAG, "onLoadChildren($parentId)")
+ }
+
+ result.detach() // allows sendResult() to happen later
+ disposables.add(
+ onLoadChildren(parentId)
+ .subscribe(
+ { result.sendResult(it) },
+ { throwable ->
+ // null indicates an error, see the docs of MediaSessionCompat.onSearch()
+ result.sendResult(null)
+ Log.e(TAG, "onLoadChildren error for parentId=$parentId: $throwable")
+ }
+ )
+ )
+ }
+
+ private fun onLoadChildren(parentId: String): Single> {
+ try {
+ val parentIdUri = Uri.parse(parentId)
+ val path = ArrayList(parentIdUri.pathSegments)
+
+ if (path.isEmpty()) {
+ return Single.just(
+ listOf(
+ createRootMediaItem(
+ ID_BOOKMARKS,
+ context.resources.getString(R.string.tab_bookmarks_short),
+ R.drawable.ic_bookmark_white
+ ),
+ createRootMediaItem(
+ ID_HISTORY,
+ context.resources.getString(R.string.action_history),
+ R.drawable.ic_history_white
+ )
+ )
+ )
+ }
+
+ when (/*val uriType = */path.removeAt(0)) {
+ ID_BOOKMARKS -> {
+ if (path.isEmpty()) {
+ return populateBookmarks()
+ }
+ if (path.size == 2) {
+ val localOrRemote = path[0]
+ val playlistId = path[1].toLong()
+ if (localOrRemote == ID_LOCAL) {
+ return populateLocalPlaylist(playlistId)
+ } else if (localOrRemote == ID_REMOTE) {
+ return populateRemotePlaylist(playlistId)
+ }
+ }
+ Log.w(TAG, "Unknown playlist URI: $parentId")
+ throw parseError(parentId)
+ }
+
+ ID_HISTORY -> return populateHistory()
+
+ else -> throw parseError(parentId)
+ }
+ } catch (e: ContentNotAvailableException) {
+ return Single.error(e)
+ }
+ }
+
+ private fun createRootMediaItem(
+ mediaId: String?,
+ folderName: String?,
+ @DrawableRes iconResId: Int
+ ): MediaBrowserCompat.MediaItem {
+ val builder = MediaDescriptionCompat.Builder()
+ builder.setMediaId(mediaId)
+ builder.setTitle(folderName)
+ val resources = context.resources
+ builder.setIconUri(
+ Uri.Builder()
+ .scheme(ContentResolver.SCHEME_ANDROID_RESOURCE)
+ .authority(resources.getResourcePackageName(iconResId))
+ .appendPath(resources.getResourceTypeName(iconResId))
+ .appendPath(resources.getResourceEntryName(iconResId))
+ .build()
+ )
+
+ val extras = Bundle()
+ extras.putString(
+ MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE,
+ context.getString(R.string.app_name)
+ )
+ builder.setExtras(extras)
+ return MediaBrowserCompat.MediaItem(
+ builder.build(),
+ MediaBrowserCompat.MediaItem.FLAG_BROWSABLE
+ )
+ }
+
+ private fun createPlaylistMediaItem(playlist: PlaylistLocalItem): MediaBrowserCompat.MediaItem {
+ val builder = MediaDescriptionCompat.Builder()
+ builder
+ .setMediaId(createMediaIdForInfoItem(playlist is PlaylistRemoteEntity, playlist.uid))
+ .setTitle(playlist.orderingName)
+ .setIconUri(playlist.thumbnailUrl?.let { Uri.parse(it) })
+
+ val extras = Bundle()
+ extras.putString(
+ MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE,
+ context.resources.getString(R.string.tab_bookmarks),
+ )
+ builder.setExtras(extras)
+ return MediaBrowserCompat.MediaItem(
+ builder.build(),
+ MediaBrowserCompat.MediaItem.FLAG_BROWSABLE,
+ )
+ }
+
+ private fun createInfoItemMediaItem(item: InfoItem): MediaBrowserCompat.MediaItem? {
+ val builder = MediaDescriptionCompat.Builder()
+ builder.setMediaId(createMediaIdForInfoItem(item))
+ .setTitle(item.name)
+
+ when (item.infoType) {
+ InfoType.STREAM -> builder.setSubtitle((item as StreamInfoItem).uploaderName)
+ InfoType.PLAYLIST -> builder.setSubtitle((item as PlaylistInfoItem).uploaderName)
+ InfoType.CHANNEL -> builder.setSubtitle((item as ChannelInfoItem).description)
+ else -> return null
+ }
+
+ ImageStrategy.choosePreferredImage(item.thumbnails)?.let {
+ builder.setIconUri(Uri.parse(it))
+ }
+
+ return MediaBrowserCompat.MediaItem(
+ builder.build(),
+ MediaBrowserCompat.MediaItem.FLAG_PLAYABLE
+ )
+ }
+
+ private fun buildMediaId(): Uri.Builder {
+ return Uri.Builder().authority(ID_AUTHORITY)
+ }
+
+ private fun buildPlaylistMediaId(playlistType: String?): Uri.Builder {
+ return buildMediaId()
+ .appendPath(ID_BOOKMARKS)
+ .appendPath(playlistType)
+ }
+
+ private fun buildLocalPlaylistItemMediaId(isRemote: Boolean, playlistId: Long): Uri.Builder {
+ return buildPlaylistMediaId(if (isRemote) ID_REMOTE else ID_LOCAL)
+ .appendPath(playlistId.toString())
+ }
+
+ private fun buildInfoItemMediaId(item: InfoItem): Uri.Builder {
+ return buildMediaId()
+ .appendPath(ID_INFO_ITEM)
+ .appendPath(infoItemTypeToString(item.infoType))
+ .appendPath(item.serviceId.toString())
+ .appendQueryParameter(ID_URL, item.url)
+ }
+
+ private fun createMediaIdForInfoItem(isRemote: Boolean, playlistId: Long): String {
+ return buildLocalPlaylistItemMediaId(isRemote, playlistId)
+ .build().toString()
+ }
+
+ private fun createLocalPlaylistStreamMediaItem(
+ playlistId: Long,
+ item: PlaylistStreamEntry,
+ index: Int,
+ ): MediaBrowserCompat.MediaItem {
+ val builder = MediaDescriptionCompat.Builder()
+ builder.setMediaId(createMediaIdForPlaylistIndex(false, playlistId, index))
+ .setTitle(item.streamEntity.title)
+ .setSubtitle(item.streamEntity.uploader)
+ .setIconUri(Uri.parse(item.streamEntity.thumbnailUrl))
+
+ return MediaBrowserCompat.MediaItem(
+ builder.build(),
+ MediaBrowserCompat.MediaItem.FLAG_PLAYABLE
+ )
+ }
+
+ private fun createRemotePlaylistStreamMediaItem(
+ playlistId: Long,
+ item: StreamInfoItem,
+ index: Int,
+ ): MediaBrowserCompat.MediaItem {
+ val builder = MediaDescriptionCompat.Builder()
+ builder.setMediaId(createMediaIdForPlaylistIndex(true, playlistId, index))
+ .setTitle(item.name)
+ .setSubtitle(item.uploaderName)
+
+ ImageStrategy.choosePreferredImage(item.thumbnails)?.let {
+ builder.setIconUri(Uri.parse(it))
+ }
+
+ return MediaBrowserCompat.MediaItem(
+ builder.build(),
+ MediaBrowserCompat.MediaItem.FLAG_PLAYABLE
+ )
+ }
+
+ private fun createMediaIdForPlaylistIndex(
+ isRemote: Boolean,
+ playlistId: Long,
+ index: Int,
+ ): String {
+ return buildLocalPlaylistItemMediaId(isRemote, playlistId)
+ .appendPath(index.toString())
+ .build().toString()
+ }
+
+ private fun createMediaIdForInfoItem(item: InfoItem): String {
+ return buildInfoItemMediaId(item).build().toString()
+ }
+
+ private fun populateHistory(): Single> {
+ val history = database.streamHistoryDAO().getHistory().firstOrError()
+ return history.map { items ->
+ items.map { this.createHistoryMediaItem(it) }
+ }
+ }
+
+ private fun createHistoryMediaItem(streamHistoryEntry: StreamHistoryEntry): MediaBrowserCompat.MediaItem {
+ val builder = MediaDescriptionCompat.Builder()
+ val mediaId = buildMediaId()
+ .appendPath(ID_HISTORY)
+ .appendPath(streamHistoryEntry.streamId.toString())
+ .build().toString()
+ builder.setMediaId(mediaId)
+ .setTitle(streamHistoryEntry.streamEntity.title)
+ .setSubtitle(streamHistoryEntry.streamEntity.uploader)
+ .setIconUri(Uri.parse(streamHistoryEntry.streamEntity.thumbnailUrl))
+
+ return MediaBrowserCompat.MediaItem(
+ builder.build(),
+ MediaBrowserCompat.MediaItem.FLAG_PLAYABLE
+ )
+ }
+
+ private fun getMergedPlaylists(): Flowable> {
+ return MergedPlaylistManager.getMergedOrderedPlaylists(
+ LocalPlaylistManager(database),
+ RemotePlaylistManager(database)
+ )
+ }
+
+ private fun populateBookmarks(): Single> {
+ val playlists = getMergedPlaylists().firstOrError()
+ return playlists.map { playlist ->
+ playlist.map { this.createPlaylistMediaItem(it) }
+ }
+ }
+
+ private fun populateLocalPlaylist(playlistId: Long): Single> {
+ val playlist = LocalPlaylistManager(database).getPlaylistStreams(playlistId).firstOrError()
+ return playlist.map { items ->
+ items.mapIndexed { index, item ->
+ createLocalPlaylistStreamMediaItem(playlistId, item, index)
+ }
+ }
+ }
+
+ private fun populateRemotePlaylist(playlistId: Long): Single> {
+ return RemotePlaylistManager(database).getPlaylist(playlistId).firstOrError()
+ .flatMap { ExtractorHelper.getPlaylistInfo(it.serviceId, it.url, false) }
+ .map {
+ // ignore it.errors, i.e. ignore errors about specific items, since there would
+ // be no way to show the error properly in Android Auto anyway
+ it.relatedItems.mapIndexed { index, item ->
+ createRemotePlaylistStreamMediaItem(playlistId, item, index)
+ }
+ }
+ }
+ //endregion
+
+ //region Search
+ fun onSearch(
+ query: String,
+ result: Result>
+ ) {
+ if (DEBUG) {
+ Log.d(TAG, "onSearch($query)")
+ }
+
+ result.detach() // allows sendResult() to happen later
+ disposables.add(
+ searchMusicBySongTitle(query)
+ // ignore it.errors, i.e. ignore errors about specific items, since there would
+ // be no way to show the error properly in Android Auto anyway
+ .map { it.relatedItems.mapNotNull(this::createInfoItemMediaItem) }
+ .subscribeOn(Schedulers.io())
+ .subscribe(
+ { result.sendResult(it) },
+ { throwable ->
+ // null indicates an error, see the docs of MediaSessionCompat.onSearch()
+ result.sendResult(null)
+ Log.e(TAG, "Search error for query=\"$query\": $throwable")
+ }
+ )
+ )
+ }
+
+ private fun searchMusicBySongTitle(query: String?): Single {
+ val serviceId = ServiceHelper.getSelectedServiceId(context)
+ return ExtractorHelper.searchFor(serviceId, query, listOf(), "")
+ }
+ //endregion
+
+ companion object {
+ private val TAG: String = MediaBrowserImpl::class.java.getSimpleName()
+ }
+}
diff --git a/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserPlaybackPreparer.kt b/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserPlaybackPreparer.kt
new file mode 100644
index 000000000..f34677a29
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserPlaybackPreparer.kt
@@ -0,0 +1,259 @@
+package org.schabi.newpipe.player.mediabrowser
+
+import android.content.Context
+import android.net.Uri
+import android.os.Bundle
+import android.os.ResultReceiver
+import android.support.v4.media.session.PlaybackStateCompat
+import android.util.Log
+import com.google.android.exoplayer2.Player
+import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector.PlaybackPreparer
+import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
+import io.reactivex.rxjava3.core.Single
+import io.reactivex.rxjava3.disposables.Disposable
+import io.reactivex.rxjava3.schedulers.Schedulers
+import org.schabi.newpipe.MainActivity
+import org.schabi.newpipe.NewPipeDatabase
+import org.schabi.newpipe.R
+import org.schabi.newpipe.extractor.InfoItem.InfoType
+import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException
+import org.schabi.newpipe.extractor.linkhandler.ListLinkHandler
+import org.schabi.newpipe.local.playlist.LocalPlaylistManager
+import org.schabi.newpipe.local.playlist.RemotePlaylistManager
+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 java.util.function.BiConsumer
+import java.util.function.Consumer
+
+/**
+ * This class is used to cleanly separate the Service implementation (in
+ * [org.schabi.newpipe.player.PlayerService]) and the playback preparer implementation (in this
+ * file). We currently use the playback preparer only in conjunction with the media browser: the
+ * playback preparer will receive the media URLs generated by [MediaBrowserImpl] and will start
+ * playback of the corresponding streams or playlists.
+ *
+ * @param setMediaSessionError takes an error String and an error code from [PlaybackStateCompat],
+ * calls `sessionConnector.setCustomErrorMessage(errorString, errorCode)`
+ * @param clearMediaSessionError calls `sessionConnector.setCustomErrorMessage(null)`
+ * @param onPrepare takes playWhenReady, calls `player.prepare()`; this is needed because
+ * `MediaSessionConnector`'s `onPlay()` method calls this class' [onPrepare] instead of
+ * `player.prepare()` if the playback preparer is not null, but we want the original behavior
+ */
+class MediaBrowserPlaybackPreparer(
+ private val context: Context,
+ private val setMediaSessionError: BiConsumer, // error string, error code
+ private val clearMediaSessionError: Runnable,
+ private val onPrepare: Consumer,
+) : PlaybackPreparer {
+ private val database = NewPipeDatabase.getInstance(context)
+ private var disposable: Disposable? = null
+
+ fun dispose() {
+ disposable?.dispose()
+ }
+
+ //region Overrides
+ override fun getSupportedPrepareActions(): Long {
+ return PlaybackStateCompat.ACTION_PLAY_FROM_MEDIA_ID
+ }
+
+ override fun onPrepare(playWhenReady: Boolean) {
+ onPrepare.accept(playWhenReady)
+ }
+
+ override fun onPrepareFromMediaId(mediaId: String, playWhenReady: Boolean, extras: Bundle?) {
+ if (MainActivity.DEBUG) {
+ Log.d(TAG, "onPrepareFromMediaId($mediaId, $playWhenReady, $extras)")
+ }
+
+ disposable?.dispose()
+ disposable = extractPlayQueueFromMediaId(mediaId)
+ .subscribeOn(Schedulers.io())
+ .observeOn(AndroidSchedulers.mainThread())
+ .subscribe(
+ { playQueue ->
+ clearMediaSessionError.run()
+ NavigationHelper.playOnBackgroundPlayer(context, playQueue, playWhenReady)
+ },
+ { throwable ->
+ Log.e(TAG, "Failed to start playback of media ID [$mediaId]", throwable)
+ onPrepareError()
+ }
+ )
+ }
+
+ override fun onPrepareFromSearch(query: String, playWhenReady: Boolean, extras: Bundle?) {
+ onUnsupportedError()
+ }
+
+ override fun onPrepareFromUri(uri: Uri, playWhenReady: Boolean, extras: Bundle?) {
+ onUnsupportedError()
+ }
+
+ override fun onCommand(
+ player: Player,
+ command: String,
+ extras: Bundle?,
+ cb: ResultReceiver?
+ ): Boolean {
+ return false
+ }
+ //endregion
+
+ //region Errors
+ private fun onUnsupportedError() {
+ setMediaSessionError.accept(
+ context.getString(R.string.content_not_supported),
+ PlaybackStateCompat.ERROR_CODE_NOT_SUPPORTED
+ )
+ }
+
+ private fun onPrepareError() {
+ setMediaSessionError.accept(
+ context.getString(R.string.error_snackbar_message),
+ PlaybackStateCompat.ERROR_CODE_APP_ERROR
+ )
+ }
+ //endregion
+
+ //region Building play queues from playlists and history
+ private fun extractLocalPlayQueue(playlistId: Long, index: Int): Single {
+ return LocalPlaylistManager(database).getPlaylistStreams(playlistId).firstOrError()
+ .map { items -> SinglePlayQueue(items.map { it.toStreamInfoItem() }, index) }
+ }
+
+ private fun extractRemotePlayQueue(playlistId: Long, index: Int): Single {
+ return RemotePlaylistManager(database).getPlaylist(playlistId).firstOrError()
+ .flatMap { ExtractorHelper.getPlaylistInfo(it.serviceId, it.url, false) }
+ // ignore info.errors, i.e. ignore errors about specific items, since there would
+ // be no way to show the error properly in Android Auto anyway
+ .map { info -> PlaylistPlayQueue(info, index) }
+ }
+
+ private fun extractPlayQueueFromMediaId(mediaId: String): Single {
+ try {
+ val mediaIdUri = Uri.parse(mediaId)
+ val path = ArrayList(mediaIdUri.pathSegments)
+ if (path.isEmpty()) {
+ throw parseError(mediaId)
+ }
+
+ return when (/*val uriType = */path.removeAt(0)) {
+ ID_BOOKMARKS -> extractPlayQueueFromPlaylistMediaId(
+ mediaId,
+ path,
+ mediaIdUri.getQueryParameter(ID_URL)
+ )
+
+ ID_HISTORY -> extractPlayQueueFromHistoryMediaId(mediaId, path)
+
+ ID_INFO_ITEM -> extractPlayQueueFromInfoItemMediaId(
+ mediaId,
+ path,
+ mediaIdUri.getQueryParameter(ID_URL) ?: throw parseError(mediaId)
+ )
+
+ else -> throw parseError(mediaId)
+ }
+ } catch (e: ContentNotAvailableException) {
+ return Single.error(e)
+ }
+ }
+
+ @Throws(ContentNotAvailableException::class)
+ private fun extractPlayQueueFromPlaylistMediaId(
+ mediaId: String,
+ path: MutableList,
+ url: String?,
+ ): Single {
+ if (path.isEmpty()) {
+ throw parseError(mediaId)
+ }
+
+ when (val playlistType = path.removeAt(0)) {
+ ID_LOCAL, ID_REMOTE -> {
+ if (path.size != 2) {
+ throw parseError(mediaId)
+ }
+ val playlistId = path[0].toLong()
+ val index = path[1].toInt()
+ return if (playlistType == ID_LOCAL)
+ extractLocalPlayQueue(playlistId, index)
+ else
+ extractRemotePlayQueue(playlistId, index)
+ }
+
+ ID_URL -> {
+ if (path.size != 1 || url == null) {
+ throw parseError(mediaId)
+ }
+
+ val serviceId = path[0].toInt()
+ return ExtractorHelper.getPlaylistInfo(serviceId, url, false)
+ .map { PlaylistPlayQueue(it) }
+ }
+
+ 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().getHistory()
+ .firstOrError()
+ .map { items ->
+ val infoItems = items
+ .filter { it.streamId == streamId }
+ .map { it.toStreamInfoItem() }
+ SinglePlayQueue(infoItems, 0)
+ }
+ }
+
+ @Throws(ContentNotAvailableException::class)
+ private fun extractPlayQueueFromInfoItemMediaId(
+ mediaId: String,
+ path: List,
+ url: String,
+ ): Single {
+ if (path.size != 2) {
+ throw parseError(mediaId)
+ }
+
+ val serviceId = path[1].toInt()
+ return when (/*val infoItemType = */infoItemTypeFromString(path[0])) {
+ InfoType.STREAM -> ExtractorHelper.getStreamInfo(serviceId, url, false)
+ .map { SinglePlayQueue(it) }
+
+ InfoType.PLAYLIST -> ExtractorHelper.getPlaylistInfo(serviceId, url, false)
+ .map { PlaylistPlayQueue(it) }
+
+ InfoType.CHANNEL -> ExtractorHelper.getChannelInfo(serviceId, url, false)
+ .map { info ->
+ val playableTab = info.tabs
+ .firstOrNull { ChannelTabHelper.isStreamsTab(it) }
+ ?: throw ContentNotAvailableException("No streams tab found")
+ return@map ChannelTabPlayQueue(serviceId, ListLinkHandler(playableTab))
+ }
+
+ else -> throw parseError(mediaId)
+ }
+ }
+ //endregion
+
+ companion object {
+ private val TAG = MediaBrowserPlaybackPreparer::class.simpleName
+ }
+}
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 c673e688c..fe884834b 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
@@ -38,10 +38,10 @@ public class MediaSessionPlayerUi extends PlayerUi
implements SharedPreferences.OnSharedPreferenceChangeListener {
private static final String TAG = "MediaSessUi";
- @Nullable
- private MediaSessionCompat mediaSession;
- @Nullable
- private MediaSessionConnector sessionConnector;
+ @NonNull
+ private final MediaSessionCompat mediaSession;
+ @NonNull
+ private final MediaSessionConnector sessionConnector;
private final String ignoreHardwareMediaButtonsKey;
private boolean shouldIgnoreHardwareMediaButtons = false;
@@ -50,9 +50,13 @@ public class MediaSessionPlayerUi extends PlayerUi
private List prevNotificationActions = List.of();
- public MediaSessionPlayerUi(@NonNull final Player player) {
+ public MediaSessionPlayerUi(@NonNull final Player player,
+ @NonNull final MediaSessionCompat mediaSession,
+ @NonNull final MediaSessionConnector sessionConnector) {
super(player);
- ignoreHardwareMediaButtonsKey =
+ this.mediaSession = mediaSession;
+ this.sessionConnector = sessionConnector;
+ this.ignoreHardwareMediaButtonsKey =
context.getString(R.string.ignore_hardware_media_buttons_key);
}
@@ -61,10 +65,8 @@ public class MediaSessionPlayerUi extends PlayerUi
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());
@@ -89,27 +91,18 @@ public class MediaSessionPlayerUi extends PlayerUi
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);
+ 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();
}
@@ -200,8 +193,8 @@ public class MediaSessionPlayerUi extends PlayerUi
return;
}
- if (sessionConnector == null) {
- // sessionConnector will be null after destroyPlayer is called
+ if (!mediaSession.isActive()) {
+ // mediaSession will be inactive after destroyPlayer is called
return;
}
diff --git a/app/src/main/java/org/schabi/newpipe/player/playqueue/AbstractInfoPlayQueue.java b/app/src/main/java/org/schabi/newpipe/player/playqueue/AbstractInfoPlayQueue.java
index 33ec390a5..dbfac5cca 100644
--- a/app/src/main/java/org/schabi/newpipe/player/playqueue/AbstractInfoPlayQueue.java
+++ b/app/src/main/java/org/schabi/newpipe/player/playqueue/AbstractInfoPlayQueue.java
@@ -28,13 +28,17 @@ abstract class AbstractInfoPlayQueue>
private transient Disposable fetchReactor;
protected AbstractInfoPlayQueue(final T info) {
+ this(info, 0);
+ }
+
+ protected AbstractInfoPlayQueue(final T info, final int index) {
this(info.getServiceId(), info.getUrl(), info.getNextPage(),
info.getRelatedItems()
.stream()
.filter(StreamInfoItem.class::isInstance)
.map(StreamInfoItem.class::cast)
.collect(Collectors.toList()),
- 0);
+ index);
}
protected AbstractInfoPlayQueue(final int serviceId,
diff --git a/app/src/main/java/org/schabi/newpipe/player/playqueue/PlaylistPlayQueue.java b/app/src/main/java/org/schabi/newpipe/player/playqueue/PlaylistPlayQueue.java
index 01883d7d9..32316f393 100644
--- a/app/src/main/java/org/schabi/newpipe/player/playqueue/PlaylistPlayQueue.java
+++ b/app/src/main/java/org/schabi/newpipe/player/playqueue/PlaylistPlayQueue.java
@@ -16,6 +16,10 @@ public final class PlaylistPlayQueue extends AbstractInfoPlayQueue
super(info);
}
+ public PlaylistPlayQueue(final PlaylistInfo info, final int index) {
+ super(info, index);
+ }
+
public PlaylistPlayQueue(final int serviceId,
final String url,
final Page nextPage,
diff --git a/app/src/main/java/org/schabi/newpipe/player/ui/PopupPlayerUi.java b/app/src/main/java/org/schabi/newpipe/player/ui/PopupPlayerUi.java
index 02f7c07b0..6c98ab0fa 100644
--- a/app/src/main/java/org/schabi/newpipe/player/ui/PopupPlayerUi.java
+++ b/app/src/main/java/org/schabi/newpipe/player/ui/PopupPlayerUi.java
@@ -382,7 +382,7 @@ public final class PopupPlayerUi extends VideoPlayerUi {
private void end() {
windowManager.removeView(closeOverlayBinding.getRoot());
closeOverlayBinding = null;
- player.getService().stopService();
+ player.getService().destroyPlayerAndStopService();
}
}).start();
}
diff --git a/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java b/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java
index e4cb46f94..e1d296297 100644
--- a/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java
+++ b/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java
@@ -96,6 +96,7 @@ public final class NavigationHelper {
}
intent.putExtra(Player.PLAYER_TYPE, PlayerType.MAIN.valueForIntent());
intent.putExtra(Player.RESUME_PLAYBACK, resumePlayback);
+ intent.putExtra(PlayerService.SHOULD_START_FOREGROUND_EXTRA, true);
return intent;
}
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 000000000..a04ed256e
--- /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 000000000..585285b89
--- /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 729dae48c..2232ddaff 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 000000000..90e6f30ef
--- /dev/null
+++ b/app/src/main/res/xml/automotive_app_desc.xml
@@ -0,0 +1,3 @@
+
+
+