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 @@ + + +