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/MainActivity.java b/app/src/main/java/org/schabi/newpipe/MainActivity.java
index b9592085b8c..c4937431f96 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 @@ private void openMiniPlayerUponPlayerStarted() {
@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 void onReceive(final Context context, final Intent intent) {
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 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
index 072c49e2c07..91f4622e99a 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 03a1e1e308a..8fbadb02052 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_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 long getUid() {
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 8ab8a2afd33..ef77d5ade73 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 60027a057f2..0b0e3605ed3 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 @@
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 void setName(final String name) {
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 40a22103b0b..083d1fe0508 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 @@ && isAutoplayEnabled()
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 void onPlayerError(final PlaybackException error, final boolean isCatchab
@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 61721d5467c..e3237696011 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 4cc51f7525e..08b203a7e32 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..49aff657ac2 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 @@ protected void onDestroy() {
////////////////////////////////////////////////////////////////////////////
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 void onServiceConnected(final ComponentName name, final IBinder service)
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 920435a7e3b..040f0dc99dc 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.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.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 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, mediaSession, sessionConnector),
new NotificationPlayerUi(this)
);
}
@@ -646,7 +657,7 @@ public void onPlaybackShutdown() {
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 @@ private void onBroadcastReceived(final Intent intent) {
pause();
break;
case ACTION_CLOSE:
- service.stopService();
+ service.destroyPlayerAndStopService();
break;
case ACTION_PLAY_PAUSE:
playPause();
@@ -1375,6 +1386,19 @@ public void onRenderedFirstFrame() {
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 e7abf4320d5..1888bce019c 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 @@
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 int onStartCommand(final Intent intent, final int flags, final int startI
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 void onDestroy() {
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 static class LocalBinder extends Binder {
public PlayerService getService() {
return playerService.get();
}
+ }
+
+ /**
+ * @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;
+ }
- public Player getPlayer() {
- return playerService.get().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 8effe2f0e93..549abc9523d 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 b55a6547ab7..20a0f37666c 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.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 static synchronized PlayerHolder getInstance() {
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 static synchronized PlayerHolder getInstance() {
*/
@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 boolean isPlayerOpen() {
* @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 boolean isBound() {
}
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 void setListener(@Nullable final PlayerServiceExtendedEventListener newLi
}
// 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 @@ private Context getCommonContext() {
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 void startService(final boolean playAfterConnect,
// 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 void onServiceConnected(final ComponentName compName, final IBinder servi
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 @@ private void bind(final Context context) {
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 @@ private void unbind(final Context context) {
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 void onServiceStopped() {
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 00000000000..12d69a163a0
--- /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 00000000000..3108da80f1c
--- /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 00000000000..f34677a2984
--- /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 c673e688c47..fe884834bc3 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 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());
@@ -89,27 +91,18 @@ 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);
+ 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 @@ private void updateMediaSessionActions() {
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 33ec390a567..dbfac5cca1c 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 01883d7d982..32316f3936d 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 PlaylistPlayQueue(final PlaylistInfo info) {
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 02f7c07b0db..6c98ab0fa0f 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 void onAnimationEnd(final Animator animation) {
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 e4cb46f94e7..e1d2962974e 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 static Intent getPlayerIntent(@NonNull final Context context,
}
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/java/us/shandian/giga/ui/adapter/MissionAdapter.java b/app/src/main/java/us/shandian/giga/ui/adapter/MissionAdapter.java
index 77d270c0552..9722a9a1ffc 100644
--- a/app/src/main/java/us/shandian/giga/ui/adapter/MissionAdapter.java
+++ b/app/src/main/java/us/shandian/giga/ui/adapter/MissionAdapter.java
@@ -71,6 +71,9 @@
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Iterator;
+import java.util.Date;
+import java.util.Locale;
+import java.text.DateFormat;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.core.Observable;
@@ -208,11 +211,17 @@ public void onBindViewHolder(@NonNull ViewHolder view, @SuppressLint("RecyclerVi
h.pause.setTitle(mission.unknownLength ? R.string.stop : R.string.pause);
updateProgress(h);
mPendingDownloadsItems.add(h);
+
+ h.date.setText("");
} else {
h.progress.setMarquee(false);
h.status.setText("100%");
h.progress.setProgress(1.0f);
h.size.setText(Utility.formatBytes(item.mission.length));
+
+ DateFormat dateFormat = DateFormat.getDateInstance(DateFormat.MEDIUM, Locale.getDefault());
+ Date date = new Date(item.mission.timestamp);
+ h.date.setText(dateFormat.format(date));
}
}
@@ -832,6 +841,7 @@ class ViewHolderItem extends RecyclerView.ViewHolder {
ImageView icon;
TextView name;
TextView size;
+ TextView date;
ProgressDrawable progress;
PopupMenu popupMenu;
@@ -862,6 +872,7 @@ class ViewHolderItem extends RecyclerView.ViewHolder {
name = itemView.findViewById(R.id.item_name);
icon = itemView.findViewById(R.id.item_icon);
size = itemView.findViewById(R.id.item_size);
+ date = itemView.findViewById(R.id.item_date);
name.setSelected(true);
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/layout/mission_item.xml b/app/src/main/res/layout/mission_item.xml
index 5338949aa4a..c864f60f010 100644
--- a/app/src/main/res/layout/mission_item.xml
+++ b/app/src/main/res/layout/mission_item.xml
@@ -82,6 +82,18 @@
android:textColor="@color/white"
android:textSize="12sp" />
+
+
diff --git a/app/src/main/res/layout/mission_item_linear.xml b/app/src/main/res/layout/mission_item_linear.xml
index ce2d1af4be9..6288e47591f 100644
--- a/app/src/main/res/layout/mission_item_linear.xml
+++ b/app/src/main/res/layout/mission_item_linear.xml
@@ -62,6 +62,18 @@
android:textSize="12sp"
android:textStyle="bold" />
+
+
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 @@
+
+
+