From c73763d2c3cda7157628223ec3719a1d0386e195 Mon Sep 17 00:00:00 2001 From: Haggai Eran Date: Mon, 2 Jan 2023 21:43:28 +0200 Subject: [PATCH 01/26] Keep MediaSessionCompat and MediaSessionConnector in a separate class These objects need to live beyond the player for supporting MediaBrowserServiceCompat and Android Auto, so they need to move outside of the MediaSessionPlayerUi class. --- .../org/schabi/newpipe/player/Player.java | 2 +- .../schabi/newpipe/player/PlayerService.java | 46 +++++++++++++------ .../mediabrowser/MediaBrowserConnector.java | 32 +++++++++++++ .../mediasession/MediaSessionPlayerUi.java | 36 +++++++-------- 4 files changed, 81 insertions(+), 35 deletions(-) create mode 100644 app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserConnector.java 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..dcf97785a21 100644 --- a/app/src/main/java/org/schabi/newpipe/player/Player.java +++ b/app/src/main/java/org/schabi/newpipe/player/Player.java @@ -302,7 +302,7 @@ public Player(@NonNull final PlayerService service) { // notification ui in the UIs list, since the notification depends on the media session in // PlayerUi#initPlayer(), and UIs.call() guarantees UI order is preserved. UIs = new PlayerUiList( - new MediaSessionPlayerUi(this), + new MediaSessionPlayerUi(this, service.getSessionConnector()), new NotificationPlayerUi(this) ); } 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..e460f608fad 100644 --- a/app/src/main/java/org/schabi/newpipe/player/PlayerService.java +++ b/app/src/main/java/org/schabi/newpipe/player/PlayerService.java @@ -28,6 +28,9 @@ import android.os.IBinder; import android.util.Log; +import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector; + +import org.schabi.newpipe.player.mediabrowser.MediaBrowserConnector; import org.schabi.newpipe.player.mediasession.MediaSessionPlayerUi; import org.schabi.newpipe.player.notification.NotificationPlayerUi; import org.schabi.newpipe.util.ThemeHelper; @@ -47,6 +50,9 @@ public final class PlayerService extends Service { private final IBinder mBinder = new PlayerService.LocalBinder(this); + private MediaBrowserConnector mediaBrowserConnector; + + /*////////////////////////////////////////////////////////////////////////// // Service's LifeCycle //////////////////////////////////////////////////////////////////////////*/ @@ -59,15 +65,21 @@ public void onCreate() { 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); + mediaBrowserConnector = new MediaBrowserConnector(this); + } + + private void initializePlayer() { + if (player == null) { + player = new Player(this); + /* + Create the player notification and start immediately the service in foreground, + otherwise if nothing is played or initializing the player and its components (especially + loading stream metadata) takes a lot of time, the app would crash on Android 8+ as the + service would never be put in the foreground while we said to the system we would do so + */ + player.UIs().get(NotificationPlayerUi.class) + .ifPresent(NotificationPlayerUi::createNotificationAndStartForeground); + } } @Override @@ -104,11 +116,10 @@ public int onStartCommand(final Intent intent, final int flags, final int startI return START_NOT_STICKY; } - if (player != null) { - player.handleIntent(intent); - player.UIs().get(MediaSessionPlayerUi.class) - .ifPresent(ui -> ui.handleMediaButtonIntent(intent)); - } + initializePlayer(); + player.handleIntent(intent); + player.UIs().get(MediaSessionPlayerUi.class) + .ifPresent(ui -> ui.handleMediaButtonIntent(intent)); return START_NOT_STICKY; } @@ -143,6 +154,10 @@ public void onDestroy() { Log.d(TAG, "destroy() called"); } cleanup(); + if (mediaBrowserConnector != null) { + mediaBrowserConnector.release(); + mediaBrowserConnector = null; + } } private void cleanup() { @@ -167,6 +182,9 @@ public IBinder onBind(final Intent intent) { return mBinder; } + public MediaSessionConnector getSessionConnector() { + return mediaBrowserConnector.getSessionConnector(); + } public static class LocalBinder extends Binder { private final WeakReference playerService; diff --git a/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserConnector.java b/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserConnector.java new file mode 100644 index 00000000000..6fc61c3d748 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserConnector.java @@ -0,0 +1,32 @@ +package org.schabi.newpipe.player.mediabrowser; + +import android.support.v4.media.session.MediaSessionCompat; + +import androidx.annotation.NonNull; + +import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector; + +import org.schabi.newpipe.player.PlayerService; + +public class MediaBrowserConnector { + private static final String TAG = MediaBrowserConnector.class.getSimpleName(); + + private final PlayerService playerService; + private final @NonNull MediaSessionConnector sessionConnector; + private final @NonNull MediaSessionCompat mediaSession; + + public MediaBrowserConnector(@NonNull final PlayerService playerService) { + this.playerService = playerService; + mediaSession = new MediaSessionCompat(playerService, TAG); + sessionConnector = new MediaSessionConnector(mediaSession); + sessionConnector.setMetadataDeduplicationEnabled(true); + } + + public @NonNull MediaSessionConnector getSessionConnector() { + return sessionConnector; + } + + public void release() { + mediaSession.release(); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/mediasession/MediaSessionPlayerUi.java b/app/src/main/java/org/schabi/newpipe/player/mediasession/MediaSessionPlayerUi.java index c673e688c47..8b69db82fa9 100644 --- a/app/src/main/java/org/schabi/newpipe/player/mediasession/MediaSessionPlayerUi.java +++ b/app/src/main/java/org/schabi/newpipe/player/mediasession/MediaSessionPlayerUi.java @@ -50,8 +50,11 @@ public class MediaSessionPlayerUi extends PlayerUi private List prevNotificationActions = List.of(); - public MediaSessionPlayerUi(@NonNull final Player player) { + public MediaSessionPlayerUi(@NonNull final Player player, + @NonNull final MediaSessionConnector sessionConnector) { super(player); + this.mediaSession = sessionConnector.mediaSession; + this.sessionConnector = sessionConnector; ignoreHardwareMediaButtonsKey = context.getString(R.string.ignore_hardware_media_buttons_key); } @@ -61,10 +64,8 @@ public void initPlayer() { super.initPlayer(); destroyPlayer(); // release previously used resources - mediaSession = new MediaSessionCompat(context, TAG); mediaSession.setActive(true); - sessionConnector = new MediaSessionConnector(mediaSession); sessionConnector.setQueueNavigator(new PlayQueueNavigator(mediaSession, player)); sessionConnector.setPlayer(getForwardingPlayer()); @@ -77,7 +78,6 @@ public void initPlayer() { updateShouldIgnoreHardwareMediaButtons(player.getPrefs()); player.getPrefs().registerOnSharedPreferenceChangeListener(this); - sessionConnector.setMetadataDeduplicationEnabled(true); sessionConnector.setMediaMetadataProvider(exoPlayer -> buildMediaMetadata()); // force updating media session actions by resetting the previous ones @@ -89,27 +89,23 @@ public void initPlayer() { public void destroyPlayer() { super.destroyPlayer(); player.getPrefs().unregisterOnSharedPreferenceChangeListener(this); - if (sessionConnector != null) { - sessionConnector.setMediaButtonEventHandler(null); - sessionConnector.setPlayer(null); - sessionConnector.setQueueNavigator(null); - sessionConnector = null; - } - if (mediaSession != null) { - mediaSession.setActive(false); - mediaSession.release(); - mediaSession = null; - } + + sessionConnector.setMediaButtonEventHandler(null); + sessionConnector.setPlayer(null); + sessionConnector.setQueueNavigator(null); + sessionConnector.setMediaMetadataProvider(null); + + mediaSession.setActive(false); + prevNotificationActions = List.of(); } @Override public void onThumbnailLoaded(@Nullable final Bitmap bitmap) { super.onThumbnailLoaded(bitmap); - if (sessionConnector != null) { - // the thumbnail is now loaded: invalidate the metadata to trigger a metadata update - sessionConnector.invalidateMediaSessionMetadata(); - } + + // the thumbnail is now loaded: invalidate the metadata to trigger a metadata update + sessionConnector.invalidateMediaSessionMetadata(); } @@ -132,7 +128,7 @@ public void handleMediaButtonIntent(final Intent intent) { } public Optional getSessionToken() { - return Optional.ofNullable(mediaSession).map(MediaSessionCompat::getSessionToken); + return Optional.of(mediaSession.getSessionToken()); } From 34a4a27d4669668560ac98acb265a95c00434c13 Mon Sep 17 00:00:00 2001 From: Haggai Eran Date: Sun, 25 Dec 2022 09:16:43 +0200 Subject: [PATCH 02/26] Simple playback status and controls in Android Auto Expose a MediaBrowserService from within the existing PlayerService, and use the existing MediaSession for Auto. Empty media browser for now. To test, one needs to enable "Unknown sources" in Android Auto's developer settings. Issue: #1758 --- app/src/main/AndroidManifest.xml | 3 ++ .../schabi/newpipe/player/PlayerService.java | 36 ++++++++++++++++- .../mediabrowser/MediaBrowserConnector.java | 39 +++++++++++++++++++ 3 files changed, 76 insertions(+), 2 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index d11de9f478d..1950031cd09 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -64,6 +64,9 @@ + + + > result) { + result.detach(); + final var disposable = mediaBrowserConnector.onLoadChildren(parentId) + .subscribe(result::sendResult); + compositeDisposableLoadChildren.add(disposable); + } } diff --git a/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserConnector.java b/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserConnector.java index 6fc61c3d748..3c05b211983 100644 --- a/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserConnector.java +++ b/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserConnector.java @@ -1,13 +1,26 @@ package org.schabi.newpipe.player.mediabrowser; +import static org.schabi.newpipe.MainActivity.DEBUG; + +import android.os.Bundle; +import android.support.v4.media.MediaBrowserCompat; +import android.support.v4.media.MediaBrowserCompat.MediaItem; import android.support.v4.media.session.MediaSessionCompat; +import android.util.Log; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.media.MediaBrowserServiceCompat; import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector; import org.schabi.newpipe.player.PlayerService; +import java.util.ArrayList; +import java.util.List; + +import io.reactivex.rxjava3.core.Single; + public class MediaBrowserConnector { private static final String TAG = MediaBrowserConnector.class.getSimpleName(); @@ -20,6 +33,7 @@ public MediaBrowserConnector(@NonNull final PlayerService playerService) { mediaSession = new MediaSessionCompat(playerService, TAG); sessionConnector = new MediaSessionConnector(mediaSession); sessionConnector.setMetadataDeduplicationEnabled(true); + playerService.setSessionToken(mediaSession.getSessionToken()); } public @NonNull MediaSessionConnector getSessionConnector() { @@ -29,4 +43,29 @@ public MediaBrowserConnector(@NonNull final PlayerService playerService) { public void release() { mediaSession.release(); } + + @NonNull + private static final String MY_MEDIA_ROOT_ID = "media_root_id"; + + @Nullable + public MediaBrowserServiceCompat.BrowserRoot onGetRoot(@NonNull final String clientPackageName, + final int clientUid, + @Nullable final Bundle rootHints) { + if (DEBUG) { + Log.d(TAG, String.format("MediaBrowserService.onGetRoot(%s, %s, %s)", + clientPackageName, clientUid, rootHints)); + } + + return new MediaBrowserServiceCompat.BrowserRoot(MY_MEDIA_ROOT_ID, null); + } + + public Single> onLoadChildren(@NonNull final String parentId) { + if (DEBUG) { + Log.d(TAG, String.format("MediaBrowserService.onLoadChildren(%s)", parentId)); + } + + final List mediaItems = new ArrayList<>(); + + return Single.just(mediaItems); + } } From 4e64b875dc37bf53cdadc9487ae0461b54fcf925 Mon Sep 17 00:00:00 2001 From: Haggai Eran Date: Sun, 25 Dec 2022 09:15:51 +0200 Subject: [PATCH 03/26] Manifest and metadata for Android Auto Add icon for Auto to manifest, and describe NewPipe as a media app. --- app/src/main/AndroidManifest.xml | 5 +++++ app/src/main/res/xml/automotive_app_desc.xml | 3 +++ 2 files changed, 8 insertions(+) create mode 100644 app/src/main/res/xml/automotive_app_desc.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 1950031cd09..9683ffcc485 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -427,5 +427,10 @@ + + + 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 @@ + + + From 22f2351ea0e4f0f2df892c1d1df023b00879d0f6 Mon Sep 17 00:00:00 2001 From: Haggai Eran Date: Sat, 4 Feb 2023 18:05:28 +0200 Subject: [PATCH 04/26] player: seek to new index when given a new playqueue with a different index This happens in the MediaBrowserServiceCompat flow (playing a playlist). --- app/src/main/java/org/schabi/newpipe/player/Player.java | 4 ++++ 1 file changed, 4 insertions(+) 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 dcf97785a21..7f5710b7e9a 100644 --- a/app/src/main/java/org/schabi/newpipe/player/Player.java +++ b/app/src/main/java/org/schabi/newpipe/player/Player.java @@ -415,6 +415,10 @@ public void handleIntent(@NonNull final Intent intent) { == com.google.android.exoplayer2.Player.STATE_IDLE) { simpleExoPlayer.prepare(); } + if (playQueue.getIndex() != newQueue.getIndex()) { + simpleExoPlayer.seekTo(newQueue.getIndex(), + newQueue.getItem().getRecoveryPosition()); + } simpleExoPlayer.setPlayWhenReady(playWhenReady); } else if (intent.getBooleanExtra(RESUME_PLAYBACK, false) From a43980d1056a803a41c70f8acb6bfacc8b543261 Mon Sep 17 00:00:00 2001 From: Haggai Eran Date: Sun, 15 Jan 2023 01:11:34 +0200 Subject: [PATCH 05/26] Media browser interface to show playlists on Android Auto --- .../mediabrowser/MediaBrowserConnector.java | 217 +++++++++++++++++- 1 file changed, 212 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserConnector.java b/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserConnector.java index 3c05b211983..b057b3f3507 100644 --- a/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserConnector.java +++ b/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserConnector.java @@ -2,37 +2,60 @@ import static org.schabi.newpipe.MainActivity.DEBUG; +import android.net.Uri; import android.os.Bundle; -import android.support.v4.media.MediaBrowserCompat; +import android.os.ResultReceiver; import android.support.v4.media.MediaBrowserCompat.MediaItem; +import android.support.v4.media.MediaDescriptionCompat; import android.support.v4.media.session.MediaSessionCompat; +import android.support.v4.media.session.PlaybackStateCompat; import android.util.Log; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.annotation.StringRes; import androidx.media.MediaBrowserServiceCompat; +import androidx.media.utils.MediaConstants; +import com.google.android.exoplayer2.Player; import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector; +import org.schabi.newpipe.NewPipeDatabase; +import org.schabi.newpipe.R; +import org.schabi.newpipe.database.AppDatabase; +import org.schabi.newpipe.database.playlist.PlaylistMetadataEntry; +import org.schabi.newpipe.database.playlist.PlaylistStreamEntry; +import org.schabi.newpipe.local.playlist.LocalPlaylistManager; import org.schabi.newpipe.player.PlayerService; +import org.schabi.newpipe.player.playqueue.PlayQueue; +import org.schabi.newpipe.player.playqueue.SinglePlayQueue; +import org.schabi.newpipe.util.NavigationHelper; import java.util.ArrayList; import java.util.List; +import java.util.stream.Collectors; +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; import io.reactivex.rxjava3.core.Single; +import io.reactivex.rxjava3.disposables.Disposable; -public class MediaBrowserConnector { +public class MediaBrowserConnector implements MediaSessionConnector.PlaybackPreparer { private static final String TAG = MediaBrowserConnector.class.getSimpleName(); private final PlayerService playerService; private final @NonNull MediaSessionConnector sessionConnector; private final @NonNull MediaSessionCompat mediaSession; + private AppDatabase database; + private LocalPlaylistManager localPlaylistManager; + private Disposable prepareOrPlayDisposable; + public MediaBrowserConnector(@NonNull final PlayerService playerService) { this.playerService = playerService; mediaSession = new MediaSessionCompat(playerService, TAG); sessionConnector = new MediaSessionConnector(mediaSession); sessionConnector.setMetadataDeduplicationEnabled(true); + sessionConnector.setPlaybackPreparer(this); playerService.setSessionToken(mediaSession.getSessionToken()); } @@ -41,11 +64,58 @@ public MediaBrowserConnector(@NonNull final PlayerService playerService) { } public void release() { + disposePrepareOrPlayCommands(); mediaSession.release(); } @NonNull - private static final String MY_MEDIA_ROOT_ID = "media_root_id"; + private static final String ID_ROOT = "//${BuildConfig.APPLICATION_ID}/r"; + @NonNull + private static final String ID_BOOKMARKS = ID_ROOT + "/playlists"; + + private MediaItem createRootMediaItem(final String mediaId, final String folderName) { + final var builder = new MediaDescriptionCompat.Builder(); + builder.setMediaId(mediaId); + builder.setTitle(folderName); + + final var extras = new Bundle(); + extras.putString(MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE, + playerService.getString(R.string.app_name)); + builder.setExtras(extras); + return new MediaItem(builder.build(), MediaItem.FLAG_BROWSABLE); + } + + private MediaItem createPlaylistMediaItem(final PlaylistMetadataEntry playlist) { + final var builder = new MediaDescriptionCompat.Builder(); + builder.setMediaId(createMediaIdForPlaylist(playlist.getUid())) + .setTitle(playlist.name) + .setIconUri(Uri.parse(playlist.thumbnailUrl)); + + final var extras = new Bundle(); + extras.putString(MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE, + playerService.getResources().getString(R.string.tab_bookmarks)); + builder.setExtras(extras); + return new MediaItem(builder.build(), MediaItem.FLAG_BROWSABLE); + } + + private String createMediaIdForPlaylist(final long playlistId) { + return ID_BOOKMARKS + '/' + playlistId; + } + + private MediaItem createPlaylistStreamMediaItem(final long playlistId, + final PlaylistStreamEntry item, + final int index) { + final var builder = new MediaDescriptionCompat.Builder(); + builder.setMediaId(createMediaIdForPlaylistIndex(playlistId, index)) + .setTitle(item.getStreamEntity().getTitle()) + .setIconUri(Uri.parse(item.getStreamEntity().getThumbnailUrl())); + + return new MediaItem(builder.build(), MediaItem.FLAG_PLAYABLE); + } + + private String createMediaIdForPlaylistIndex(final long playlistId, final int index) { + return createMediaIdForPlaylist(playlistId) + '/' + index; + } @Nullable public MediaBrowserServiceCompat.BrowserRoot onGetRoot(@NonNull final String clientPackageName, @@ -56,7 +126,7 @@ public MediaBrowserServiceCompat.BrowserRoot onGetRoot(@NonNull final String cli clientPackageName, clientUid, rootHints)); } - return new MediaBrowserServiceCompat.BrowserRoot(MY_MEDIA_ROOT_ID, null); + return new MediaBrowserServiceCompat.BrowserRoot(ID_ROOT, null); } public Single> onLoadChildren(@NonNull final String parentId) { @@ -64,8 +134,145 @@ public Single> onLoadChildren(@NonNull final String parentId) { Log.d(TAG, String.format("MediaBrowserService.onLoadChildren(%s)", parentId)); } - final List mediaItems = new ArrayList<>(); + final List mediaItems = new ArrayList<>(); + final var parentIdUri = Uri.parse(parentId); + if (parentId.equals(ID_ROOT)) { + mediaItems.add( + createRootMediaItem(ID_BOOKMARKS, + playerService.getResources().getString(R.string.tab_bookmarks))); + + } else if (parentId.startsWith(ID_BOOKMARKS)) { + final var path = parentIdUri.getPathSegments(); + if (path.size() == 2) { + return populateBookmarks(); + } else if (path.size() == 3) { + final var playlistId = Long.parseLong(path.get(2)); + return populatePlaylist(playlistId); + } else { + Log.w(TAG, "Unknown playlist uri " + parentId); + } + } return Single.just(mediaItems); } + + private LocalPlaylistManager getPlaylistManager() { + if (database == null) { + database = NewPipeDatabase.getInstance(playerService); + } + if (localPlaylistManager == null) { + localPlaylistManager = new LocalPlaylistManager(database); + } + return localPlaylistManager; + } + + private Single> populateBookmarks() { + final var playlists = getPlaylistManager().getPlaylists().firstOrError(); + return playlists.map(playlist -> + playlist.stream().map(this::createPlaylistMediaItem).collect(Collectors.toList())); + } + + private Single> populatePlaylist(final long playlistId) { + final var playlist = getPlaylistManager().getPlaylistStreams(playlistId).firstOrError(); + return playlist.map(items -> { + final List results = new ArrayList<>(); + int index = 0; + for (final var item : items) { + results.add(createPlaylistStreamMediaItem(playlistId, item, index)); + ++index; + } + return results; + }); + } + + private void playbackError(@StringRes final int resId, final int code) { + playerService.stopForImmediateReusing(); + sessionConnector.setCustomErrorMessage(playerService.getString(resId), code); + } + + private Single extractPlayQueueFromMediaId(final String mediaId) { + final Uri mediaIdUri = Uri.parse(mediaId); + if (mediaIdUri == null) { + return Single.error(new NullPointerException()); + } + if (mediaId.startsWith(ID_BOOKMARKS)) { + final var path = mediaIdUri.getPathSegments(); + if (path.size() == 4) { + final long playlistId = Long.parseLong(path.get(2)); + final int index = Integer.parseInt(path.get(3)); + + return getPlaylistManager() + .getPlaylistStreams(playlistId) + .firstOrError() + .map(items -> { + final var infoItems = items.stream() + .map(PlaylistStreamEntry::toStreamInfoItem) + .collect(Collectors.toList()); + return new SinglePlayQueue(infoItems, index); + }); + } + } + + return Single.error(new NullPointerException()); + } + + @Override + public long getSupportedPrepareActions() { + return PlaybackStateCompat.ACTION_PLAY_FROM_MEDIA_ID; + } + + private void disposePrepareOrPlayCommands() { + if (prepareOrPlayDisposable != null) { + prepareOrPlayDisposable.dispose(); + prepareOrPlayDisposable = null; + } + } + + @Override + public void onPrepare(final boolean playWhenReady) { + disposePrepareOrPlayCommands(); + // No need to prepare + } + + @Override + public void onPrepareFromMediaId(@NonNull final String mediaId, final boolean playWhenReady, + @Nullable final Bundle extras) { + if (DEBUG) { + Log.d(TAG, String.format("MediaBrowserConnector.onPrepareFromMediaId(%s, %s, %s)", + mediaId, playWhenReady, extras)); + } + + disposePrepareOrPlayCommands(); + prepareOrPlayDisposable = extractPlayQueueFromMediaId(mediaId) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + playQueue -> { + sessionConnector.setCustomErrorMessage(null); + NavigationHelper.playOnBackgroundPlayer(playerService, playQueue, + playWhenReady); + }, + throwable -> playbackError(R.string.error_http_not_found, + PlaybackStateCompat.ERROR_CODE_NOT_SUPPORTED) + ); + } + + @Override + public void onPrepareFromSearch(@NonNull final String query, final boolean playWhenReady, + @Nullable final Bundle extras) { + disposePrepareOrPlayCommands(); + playbackError(R.string.content_not_supported, PlaybackStateCompat.ERROR_CODE_NOT_SUPPORTED); + } + + @Override + public void onPrepareFromUri(@NonNull final Uri uri, final boolean playWhenReady, + @Nullable final Bundle extras) { + disposePrepareOrPlayCommands(); + playbackError(R.string.content_not_supported, PlaybackStateCompat.ERROR_CODE_NOT_SUPPORTED); + } + + @Override + public boolean onCommand(@NonNull final Player player, @NonNull final String command, + @Nullable final Bundle extras, @Nullable final ResultReceiver cb) { + return false; + } } From 2d6a99cab3e1de43461f9f67ac3906b6fd7b9b33 Mon Sep 17 00:00:00 2001 From: Haggai Eran Date: Sat, 4 Feb 2023 17:53:28 +0200 Subject: [PATCH 06/26] StreamHistoryEntry: convert to StreamInfoItem Can be used to play history items. --- .../database/history/model/StreamHistoryEntry.kt | 12 ++++++++++++ 1 file changed, 12 insertions(+) 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..7bc0592490f 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,14 @@ data class StreamHistoryEntry( return this.streamEntity.uid == other.streamEntity.uid && streamId == other.streamId && accessDate.isEqual(other.accessDate) } + + fun toStreamInfoItem(): StreamInfoItem { + val item = StreamInfoItem(streamEntity.serviceId, streamEntity.url, streamEntity.title, streamEntity.streamType) + item.duration = streamEntity.duration + item.uploaderName = streamEntity.uploader + item.uploaderUrl = streamEntity.uploaderUrl + item.thumbnails = ImageStrategy.dbUrlToImageList(streamEntity.thumbnailUrl) + + return item + } } From b4ce7028d19ce4d18c431bd0a437127df81d8496 Mon Sep 17 00:00:00 2001 From: Haggai Eran Date: Sat, 4 Feb 2023 18:09:52 +0200 Subject: [PATCH 07/26] MediaBrowser: expose search history --- .../mediabrowser/MediaBrowserConnector.java | 49 +++++++++++++++++-- 1 file changed, 46 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserConnector.java b/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserConnector.java index b057b3f3507..083aafa57dc 100644 --- a/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserConnector.java +++ b/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserConnector.java @@ -23,6 +23,7 @@ import org.schabi.newpipe.NewPipeDatabase; import org.schabi.newpipe.R; import org.schabi.newpipe.database.AppDatabase; +import org.schabi.newpipe.database.history.model.StreamHistoryEntry; import org.schabi.newpipe.database.playlist.PlaylistMetadataEntry; import org.schabi.newpipe.database.playlist.PlaylistStreamEntry; import org.schabi.newpipe.local.playlist.LocalPlaylistManager; @@ -72,6 +73,10 @@ public void release() { private static final String ID_ROOT = "//${BuildConfig.APPLICATION_ID}/r"; @NonNull private static final String ID_BOOKMARKS = ID_ROOT + "/playlists"; + @NonNull + private static final String ID_HISTORY = ID_ROOT + "/history"; + @NonNull + private static final String ID_STREAM = ID_ROOT + "/stream"; private MediaItem createRootMediaItem(final String mediaId, final String folderName) { final var builder = new MediaDescriptionCompat.Builder(); @@ -141,7 +146,9 @@ public Single> onLoadChildren(@NonNull final String parentId) { mediaItems.add( createRootMediaItem(ID_BOOKMARKS, playerService.getResources().getString(R.string.tab_bookmarks))); - + mediaItems.add( + createRootMediaItem(ID_HISTORY, + playerService.getResources().getString(R.string.action_history))); } else if (parentId.startsWith(ID_BOOKMARKS)) { final var path = parentIdUri.getPathSegments(); if (path.size() == 2) { @@ -152,16 +159,38 @@ public Single> onLoadChildren(@NonNull final String parentId) { } else { Log.w(TAG, "Unknown playlist uri " + parentId); } + } else if (parentId.equals(ID_HISTORY)) { + return populateHistory(); } return Single.just(mediaItems); } - private LocalPlaylistManager getPlaylistManager() { + private Single> populateHistory() { + final var streamHistory = getDatabase().streamHistoryDAO(); + final var history = streamHistory.getHistory().firstOrError(); + return history.map(items -> + items.stream().map(this::createHistoryMediaItem).collect(Collectors.toList())); + } + + private MediaItem createHistoryMediaItem(@NonNull final StreamHistoryEntry streamHistoryEntry) { + final var builder = new MediaDescriptionCompat.Builder(); + builder.setMediaId(ID_STREAM + '/' + streamHistoryEntry.getStreamId()) + .setTitle(streamHistoryEntry.getStreamEntity().getTitle()) + .setIconUri(Uri.parse(streamHistoryEntry.getStreamEntity().getThumbnailUrl())); + + return new MediaItem(builder.build(), MediaItem.FLAG_PLAYABLE); + } + + private AppDatabase getDatabase() { if (database == null) { database = NewPipeDatabase.getInstance(playerService); } + return database; + } + + private LocalPlaylistManager getPlaylistManager() { if (localPlaylistManager == null) { - localPlaylistManager = new LocalPlaylistManager(database); + localPlaylistManager = new LocalPlaylistManager(getDatabase()); } return localPlaylistManager; } @@ -211,6 +240,20 @@ private Single extractPlayQueueFromMediaId(final String mediaId) { return new SinglePlayQueue(infoItems, index); }); } + } else if (mediaId.startsWith(ID_STREAM)) { + final var path = mediaIdUri.getPathSegments(); + if (path.size() == 3) { + final long streamId = Long.parseLong(path.get(2)); + return getDatabase().streamHistoryDAO().getHistory() + .firstOrError() + .map(items -> { + final var infoItems = items.stream() + .filter(it -> it.getStreamId() == streamId) + .map(StreamHistoryEntry::toStreamInfoItem) + .collect(Collectors.toList()); + return new SinglePlayQueue(infoItems, 0); + }); + } } return Single.error(new NullPointerException()); From fd0ca907aa15cacc9b4ed65da63c5f92eb6cee96 Mon Sep 17 00:00:00 2001 From: Haggai Eran Date: Fri, 2 Jun 2023 11:46:12 +0300 Subject: [PATCH 08/26] Pass media browser error as ErrorInfo --- .../mediabrowser/MediaBrowserConnector.java | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserConnector.java b/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserConnector.java index 083aafa57dc..df5b6d1db38 100644 --- a/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserConnector.java +++ b/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserConnector.java @@ -26,6 +26,9 @@ import org.schabi.newpipe.database.history.model.StreamHistoryEntry; import org.schabi.newpipe.database.playlist.PlaylistMetadataEntry; import org.schabi.newpipe.database.playlist.PlaylistStreamEntry; +import org.schabi.newpipe.error.ErrorInfo; +import org.schabi.newpipe.error.UserAction; +import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException; import org.schabi.newpipe.local.playlist.LocalPlaylistManager; import org.schabi.newpipe.player.PlayerService; import org.schabi.newpipe.player.playqueue.PlayQueue; @@ -219,10 +222,14 @@ private void playbackError(@StringRes final int resId, final int code) { sessionConnector.setCustomErrorMessage(playerService.getString(resId), code); } + private void playbackError(@NonNull final ErrorInfo errorInfo) { + playbackError(errorInfo.getMessageStringId(), PlaybackStateCompat.ERROR_CODE_APP_ERROR); + } + private Single extractPlayQueueFromMediaId(final String mediaId) { final Uri mediaIdUri = Uri.parse(mediaId); if (mediaIdUri == null) { - return Single.error(new NullPointerException()); + return Single.error(new ContentNotAvailableException("Media ID cannot be parsed")); } if (mediaId.startsWith(ID_BOOKMARKS)) { final var path = mediaIdUri.getPathSegments(); @@ -256,7 +263,7 @@ private Single extractPlayQueueFromMediaId(final String mediaId) { } } - return Single.error(new NullPointerException()); + return Single.error(new ContentNotAvailableException("Media ID cannot be parsed")); } @Override @@ -294,8 +301,8 @@ public void onPrepareFromMediaId(@NonNull final String mediaId, final boolean pl NavigationHelper.playOnBackgroundPlayer(playerService, playQueue, playWhenReady); }, - throwable -> playbackError(R.string.error_http_not_found, - PlaybackStateCompat.ERROR_CODE_NOT_SUPPORTED) + throwable -> playbackError(new ErrorInfo(throwable, UserAction.PLAY_STREAM, + "Failed playback of media ID [" + mediaId + "]: ")) ); } From f4e592015601e8ce75ad9225c7a78536d29e890c Mon Sep 17 00:00:00 2001 From: AudricV <74829229+AudricV@users.noreply.github.com> Date: Fri, 11 Aug 2023 13:22:43 +0200 Subject: [PATCH 09/26] Improve code formatting, annotate more fields and methods Also simplify logic in MediaBrowserConnector.extractPlayQueueFromMediaId, suppress some Sonar warnings as they could not be solved, and use explicit types in some variables. --- app/src/main/AndroidManifest.xml | 2 +- .../history/model/StreamHistoryEntry.kt | 7 +- .../schabi/newpipe/player/PlayerService.java | 56 +++++--- .../mediabrowser/MediaBrowserConnector.java | 131 ++++++++++-------- 4 files changed, 117 insertions(+), 79 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 9683ffcc485..e52dded5e1a 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -431,6 +431,6 @@ + android:resource="@mipmap/ic_launcher" /> 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 7bc0592490f..1eb299890e6 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 @@ -31,7 +31,12 @@ data class StreamHistoryEntry( } fun toStreamInfoItem(): StreamInfoItem { - val item = StreamInfoItem(streamEntity.serviceId, streamEntity.url, streamEntity.title, streamEntity.streamType) + val item = StreamInfoItem( + streamEntity.serviceId, + streamEntity.url, + streamEntity.title, + streamEntity.streamType + ) item.duration = streamEntity.duration item.uploaderName = streamEntity.uploader item.uploaderUrl = streamEntity.uploaderUrl 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 c070c0d88c9..55fdfd3a8ac 100644 --- a/app/src/main/java/org/schabi/newpipe/player/PlayerService.java +++ b/app/src/main/java/org/schabi/newpipe/player/PlayerService.java @@ -43,8 +43,10 @@ import java.lang.ref.WeakReference; import java.util.List; +import java.util.Objects; import io.reactivex.rxjava3.disposables.CompositeDisposable; +import io.reactivex.rxjava3.disposables.Disposable; /** * One service for all players. @@ -53,6 +55,7 @@ public final class PlayerService extends MediaBrowserServiceCompat { private static final String TAG = PlayerService.class.getSimpleName(); private static final boolean DEBUG = Player.DEBUG; + @Nullable private Player player; private final IBinder mBinder = new PlayerService.LocalBinder(this); @@ -79,7 +82,7 @@ public void onCreate() { mediaBrowserConnector = new MediaBrowserConnector(this); } - private void initializePlayer() { + private void initializePlayerIfNeeded() { if (player == null) { player = new Player(this); /* @@ -93,6 +96,9 @@ otherwise if nothing is played or initializing the player and its components (es } } + // Suppress Sonar warning to not always return the same value, as we need to do some actions + // before returning + @SuppressWarnings("squid:S3516") @Override public int onStartCommand(final Intent intent, final int flags, final int startId) { if (DEBUG) { @@ -127,8 +133,8 @@ public int onStartCommand(final Intent intent, final int flags, final int startI return START_NOT_STICKY; } - initializePlayer(); - player.handleIntent(intent); + initializePlayerIfNeeded(); + Objects.requireNonNull(player).handleIntent(intent); player.UIs().get(MediaSessionPlayerUi.class) .ifPresent(ui -> ui.handleMediaButtonIntent(intent)); @@ -164,11 +170,14 @@ public void onDestroy() { if (DEBUG) { Log.d(TAG, "destroy() called"); } + cleanup(); + if (mediaBrowserConnector != null) { mediaBrowserConnector.release(); mediaBrowserConnector = null; } + compositeDisposableLoadChildren.clear(); } @@ -190,36 +199,23 @@ protected void attachBaseContext(final Context base) { } @Override - public IBinder onBind(final Intent intent) { + public IBinder onBind(@NonNull final Intent intent) { if (SERVICE_INTERFACE.equals(intent.getAction())) { return super.onBind(intent); } return mBinder; } + @NonNull public MediaSessionConnector getSessionConnector() { return mediaBrowserConnector.getSessionConnector(); } - public static class LocalBinder extends Binder { - private final WeakReference playerService; - - LocalBinder(final PlayerService playerService) { - this.playerService = new WeakReference<>(playerService); - } - - public PlayerService getService() { - return playerService.get(); - } - - public Player getPlayer() { - return playerService.get().player; - } - } // MediaBrowserServiceCompat methods @Nullable @Override - public BrowserRoot onGetRoot(@NonNull final String clientPackageName, final int clientUid, + public BrowserRoot onGetRoot(@NonNull final String clientPackageName, + final int clientUid, @Nullable final Bundle rootHints) { return mediaBrowserConnector.onGetRoot(clientPackageName, clientUid, rootHints); } @@ -228,8 +224,26 @@ public BrowserRoot onGetRoot(@NonNull final String clientPackageName, final int public void onLoadChildren(@NonNull final String parentId, @NonNull final Result> result) { result.detach(); - final var disposable = mediaBrowserConnector.onLoadChildren(parentId) + final Disposable disposable = mediaBrowserConnector.onLoadChildren(parentId) .subscribe(result::sendResult); compositeDisposableLoadChildren.add(disposable); } + + public static final class LocalBinder extends Binder { + private final WeakReference playerService; + + LocalBinder(final PlayerService playerService) { + this.playerService = new WeakReference<>(playerService); + } + + @Nullable + public PlayerService getService() { + return playerService.get(); + } + + @Nullable + public Player getPlayer() { + return playerService.get().player; + } + } } diff --git a/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserConnector.java b/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserConnector.java index df5b6d1db38..476f7be1b24 100644 --- a/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserConnector.java +++ b/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserConnector.java @@ -23,12 +23,14 @@ import org.schabi.newpipe.NewPipeDatabase; import org.schabi.newpipe.R; import org.schabi.newpipe.database.AppDatabase; +import org.schabi.newpipe.database.history.dao.StreamHistoryDAO; import org.schabi.newpipe.database.history.model.StreamHistoryEntry; import org.schabi.newpipe.database.playlist.PlaylistMetadataEntry; import org.schabi.newpipe.database.playlist.PlaylistStreamEntry; import org.schabi.newpipe.error.ErrorInfo; import org.schabi.newpipe.error.UserAction; import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException; +import org.schabi.newpipe.extractor.stream.StreamInfoItem; import org.schabi.newpipe.local.playlist.LocalPlaylistManager; import org.schabi.newpipe.player.PlayerService; import org.schabi.newpipe.player.playqueue.PlayQueue; @@ -44,11 +46,15 @@ import io.reactivex.rxjava3.disposables.Disposable; public class MediaBrowserConnector implements MediaSessionConnector.PlaybackPreparer { + private static final String TAG = MediaBrowserConnector.class.getSimpleName(); + @NonNull private final PlayerService playerService; - private final @NonNull MediaSessionConnector sessionConnector; - private final @NonNull MediaSessionCompat mediaSession; + @NonNull + private final MediaSessionConnector sessionConnector; + @NonNull + private final MediaSessionCompat mediaSession; private AppDatabase database; private LocalPlaylistManager localPlaylistManager; @@ -63,7 +69,8 @@ public MediaBrowserConnector(@NonNull final PlayerService playerService) { playerService.setSessionToken(mediaSession.getSessionToken()); } - public @NonNull MediaSessionConnector getSessionConnector() { + @NonNull + public MediaSessionConnector getSessionConnector() { return sessionConnector; } @@ -86,32 +93,35 @@ private MediaItem createRootMediaItem(final String mediaId, final String folderN builder.setMediaId(mediaId); builder.setTitle(folderName); - final var extras = new Bundle(); + final Bundle extras = new Bundle(); extras.putString(MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE, playerService.getString(R.string.app_name)); builder.setExtras(extras); return new MediaItem(builder.build(), MediaItem.FLAG_BROWSABLE); } - private MediaItem createPlaylistMediaItem(final PlaylistMetadataEntry playlist) { + @NonNull + private MediaItem createPlaylistMediaItem(@NonNull final PlaylistMetadataEntry playlist) { final var builder = new MediaDescriptionCompat.Builder(); builder.setMediaId(createMediaIdForPlaylist(playlist.getUid())) - .setTitle(playlist.name) - .setIconUri(Uri.parse(playlist.thumbnailUrl)); + .setTitle(playlist.name) + .setIconUri(Uri.parse(playlist.thumbnailUrl)); - final var extras = new Bundle(); + final Bundle extras = new Bundle(); extras.putString(MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE, playerService.getResources().getString(R.string.tab_bookmarks)); builder.setExtras(extras); return new MediaItem(builder.build(), MediaItem.FLAG_BROWSABLE); } + @NonNull private String createMediaIdForPlaylist(final long playlistId) { return ID_BOOKMARKS + '/' + playlistId; } + @NonNull private MediaItem createPlaylistStreamMediaItem(final long playlistId, - final PlaylistStreamEntry item, + @NonNull final PlaylistStreamEntry item, final int index) { final var builder = new MediaDescriptionCompat.Builder(); builder.setMediaId(createMediaIdForPlaylistIndex(playlistId, index)) @@ -121,6 +131,7 @@ private MediaItem createPlaylistStreamMediaItem(final long playlistId, return new MediaItem(builder.build(), MediaItem.FLAG_PLAYABLE); } + @NonNull private String createMediaIdForPlaylistIndex(final long playlistId, final int index) { return createMediaIdForPlaylist(playlistId) + '/' + index; } @@ -143,7 +154,6 @@ public Single> onLoadChildren(@NonNull final String parentId) { } final List mediaItems = new ArrayList<>(); - final var parentIdUri = Uri.parse(parentId); if (parentId.equals(ID_ROOT)) { mediaItems.add( @@ -153,14 +163,15 @@ public Single> onLoadChildren(@NonNull final String parentId) { createRootMediaItem(ID_HISTORY, playerService.getResources().getString(R.string.action_history))); } else if (parentId.startsWith(ID_BOOKMARKS)) { - final var path = parentIdUri.getPathSegments(); + final Uri parentIdUri = Uri.parse(parentId); + final List path = parentIdUri.getPathSegments(); if (path.size() == 2) { return populateBookmarks(); } else if (path.size() == 3) { - final var playlistId = Long.parseLong(path.get(2)); + final long playlistId = Long.parseLong(path.get(2)); return populatePlaylist(playlistId); } else { - Log.w(TAG, "Unknown playlist uri " + parentId); + Log.w(TAG, "Unknown playlist URI: " + parentId); } } else if (parentId.equals(ID_HISTORY)) { return populateHistory(); @@ -169,17 +180,19 @@ public Single> onLoadChildren(@NonNull final String parentId) { } private Single> populateHistory() { - final var streamHistory = getDatabase().streamHistoryDAO(); + final StreamHistoryDAO streamHistory = getDatabase().streamHistoryDAO(); final var history = streamHistory.getHistory().firstOrError(); - return history.map(items -> - items.stream().map(this::createHistoryMediaItem).collect(Collectors.toList())); + return history.map(items -> items.stream() + .map(this::createHistoryMediaItem) + .collect(Collectors.toList())); } + @NonNull private MediaItem createHistoryMediaItem(@NonNull final StreamHistoryEntry streamHistoryEntry) { final var builder = new MediaDescriptionCompat.Builder(); builder.setMediaId(ID_STREAM + '/' + streamHistoryEntry.getStreamId()) - .setTitle(streamHistoryEntry.getStreamEntity().getTitle()) - .setIconUri(Uri.parse(streamHistoryEntry.getStreamEntity().getThumbnailUrl())); + .setTitle(streamHistoryEntry.getStreamEntity().getTitle()) + .setIconUri(Uri.parse(streamHistoryEntry.getStreamEntity().getThumbnailUrl())); return new MediaItem(builder.build(), MediaItem.FLAG_PLAYABLE); } @@ -198,10 +211,14 @@ private LocalPlaylistManager getPlaylistManager() { return localPlaylistManager; } + // Suppress Sonar warning replace list collection by Stream.toList call, as this method is only + // available in Android API 34 and not currently available with desugaring + @SuppressWarnings("squid:S6204") private Single> populateBookmarks() { final var playlists = getPlaylistManager().getPlaylists().firstOrError(); - return playlists.map(playlist -> - playlist.stream().map(this::createPlaylistMediaItem).collect(Collectors.toList())); + return playlists.map(playlist -> playlist.stream() + .map(this::createPlaylistMediaItem) + .collect(Collectors.toList())); } private Single> populatePlaylist(final long playlistId) { @@ -209,7 +226,7 @@ private Single> populatePlaylist(final long playlistId) { return playlist.map(items -> { final List results = new ArrayList<>(); int index = 0; - for (final var item : items) { + for (final PlaylistStreamEntry item : items) { results.add(createPlaylistStreamMediaItem(playlistId, item, index)); ++index; } @@ -231,36 +248,33 @@ private Single extractPlayQueueFromMediaId(final String mediaId) { if (mediaIdUri == null) { return Single.error(new ContentNotAvailableException("Media ID cannot be parsed")); } - if (mediaId.startsWith(ID_BOOKMARKS)) { - final var path = mediaIdUri.getPathSegments(); - if (path.size() == 4) { - final long playlistId = Long.parseLong(path.get(2)); - final int index = Integer.parseInt(path.get(3)); - - return getPlaylistManager() - .getPlaylistStreams(playlistId) - .firstOrError() - .map(items -> { - final var infoItems = items.stream() - .map(PlaylistStreamEntry::toStreamInfoItem) - .collect(Collectors.toList()); - return new SinglePlayQueue(infoItems, index); - }); - } - } else if (mediaId.startsWith(ID_STREAM)) { - final var path = mediaIdUri.getPathSegments(); - if (path.size() == 3) { - final long streamId = Long.parseLong(path.get(2)); - return getDatabase().streamHistoryDAO().getHistory() - .firstOrError() - .map(items -> { - final var infoItems = items.stream() - .filter(it -> it.getStreamId() == streamId) - .map(StreamHistoryEntry::toStreamInfoItem) - .collect(Collectors.toList()); - return new SinglePlayQueue(infoItems, 0); - }); - } + + final List path = mediaIdUri.getPathSegments(); + + if (mediaId.startsWith(ID_BOOKMARKS) && path.size() == 4) { + final long playlistId = Long.parseLong(path.get(2)); + final int index = Integer.parseInt(path.get(3)); + + return getPlaylistManager() + .getPlaylistStreams(playlistId) + .firstOrError() + .map(items -> { + final List infoItems = items.stream() + .map(PlaylistStreamEntry::toStreamInfoItem) + .collect(Collectors.toList()); + return new SinglePlayQueue(infoItems, index); + }); + } else if (mediaId.startsWith(ID_STREAM) && path.size() == 3) { + final long streamId = Long.parseLong(path.get(2)); + return getDatabase().streamHistoryDAO().getHistory() + .firstOrError() + .map(items -> { + final List infoItems = items.stream() + .filter(it -> it.getStreamId() == streamId) + .map(StreamHistoryEntry::toStreamInfoItem) + .collect(Collectors.toList()); + return new SinglePlayQueue(infoItems, 0); + }); } return Single.error(new ContentNotAvailableException("Media ID cannot be parsed")); @@ -285,7 +299,8 @@ public void onPrepare(final boolean playWhenReady) { } @Override - public void onPrepareFromMediaId(@NonNull final String mediaId, final boolean playWhenReady, + public void onPrepareFromMediaId(@NonNull final String mediaId, + final boolean playWhenReady, @Nullable final Bundle extras) { if (DEBUG) { Log.d(TAG, String.format("MediaBrowserConnector.onPrepareFromMediaId(%s, %s, %s)", @@ -307,22 +322,26 @@ public void onPrepareFromMediaId(@NonNull final String mediaId, final boolean pl } @Override - public void onPrepareFromSearch(@NonNull final String query, final boolean playWhenReady, + public void onPrepareFromSearch(@NonNull final String query, + final boolean playWhenReady, @Nullable final Bundle extras) { disposePrepareOrPlayCommands(); playbackError(R.string.content_not_supported, PlaybackStateCompat.ERROR_CODE_NOT_SUPPORTED); } @Override - public void onPrepareFromUri(@NonNull final Uri uri, final boolean playWhenReady, + public void onPrepareFromUri(@NonNull final Uri uri, + final boolean playWhenReady, @Nullable final Bundle extras) { disposePrepareOrPlayCommands(); playbackError(R.string.content_not_supported, PlaybackStateCompat.ERROR_CODE_NOT_SUPPORTED); } @Override - public boolean onCommand(@NonNull final Player player, @NonNull final String command, - @Nullable final Bundle extras, @Nullable final ResultReceiver cb) { + public boolean onCommand(@NonNull final Player player, + @NonNull final String command, + @Nullable final Bundle extras, + @Nullable final ResultReceiver cb) { return false; } } From 4fc92cbaa48569ce3b2485be51197581d4cd3a52 Mon Sep 17 00:00:00 2001 From: AudricV <74829229+AudricV@users.noreply.github.com> Date: Fri, 11 Aug 2023 13:32:06 +0200 Subject: [PATCH 10/26] Add icons to root media items --- .../mediabrowser/MediaBrowserConnector.java | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserConnector.java b/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserConnector.java index 476f7be1b24..df85d0e407d 100644 --- a/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserConnector.java +++ b/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserConnector.java @@ -2,6 +2,8 @@ import static org.schabi.newpipe.MainActivity.DEBUG; +import android.content.ContentResolver; +import android.content.res.Resources; import android.net.Uri; import android.os.Bundle; import android.os.ResultReceiver; @@ -11,6 +13,7 @@ import android.support.v4.media.session.PlaybackStateCompat; import android.util.Log; +import androidx.annotation.DrawableRes; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.StringRes; @@ -88,10 +91,20 @@ public void release() { @NonNull private static final String ID_STREAM = ID_ROOT + "/stream"; - private MediaItem createRootMediaItem(final String mediaId, final String folderName) { + @NonNull + private MediaItem createRootMediaItem(@Nullable final String mediaId, + final String folderName, + @DrawableRes final int iconResId) { final var builder = new MediaDescriptionCompat.Builder(); builder.setMediaId(mediaId); builder.setTitle(folderName); + final Resources resources = playerService.getResources(); + builder.setIconUri(new Uri.Builder() + .scheme(ContentResolver.SCHEME_ANDROID_RESOURCE) + .authority(resources.getResourcePackageName(iconResId)) + .appendPath(resources.getResourceTypeName(iconResId)) + .appendPath(resources.getResourceEntryName(iconResId)) + .build()); final Bundle extras = new Bundle(); extras.putString(MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE, @@ -158,10 +171,12 @@ public Single> onLoadChildren(@NonNull final String parentId) { if (parentId.equals(ID_ROOT)) { mediaItems.add( createRootMediaItem(ID_BOOKMARKS, - playerService.getResources().getString(R.string.tab_bookmarks))); + playerService.getResources().getString(R.string.tab_bookmarks), + R.drawable.ic_bookmark)); mediaItems.add( createRootMediaItem(ID_HISTORY, - playerService.getResources().getString(R.string.action_history))); + playerService.getResources().getString(R.string.action_history), + R.drawable.ic_history)); } else if (parentId.startsWith(ID_BOOKMARKS)) { final Uri parentIdUri = Uri.parse(parentId); final List path = parentIdUri.getPathSegments(); From c916608c989b62f291e7d21f5861e9016d15fff0 Mon Sep 17 00:00:00 2001 From: AudricV <74829229+AudricV@users.noreply.github.com> Date: Sun, 20 Aug 2023 17:44:44 +0200 Subject: [PATCH 11/26] Add uploader name of streams as subtitle of MediaItems They should be displayed only by Android Auto if they are known, i.e. when they are not empty. --- .../newpipe/player/mediabrowser/MediaBrowserConnector.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserConnector.java b/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserConnector.java index df85d0e407d..7a383e09616 100644 --- a/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserConnector.java +++ b/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserConnector.java @@ -139,6 +139,7 @@ private MediaItem createPlaylistStreamMediaItem(final long playlistId, final var builder = new MediaDescriptionCompat.Builder(); builder.setMediaId(createMediaIdForPlaylistIndex(playlistId, index)) .setTitle(item.getStreamEntity().getTitle()) + .setSubtitle(item.getStreamEntity().getUploader()) .setIconUri(Uri.parse(item.getStreamEntity().getThumbnailUrl())); return new MediaItem(builder.build(), MediaItem.FLAG_PLAYABLE); @@ -207,6 +208,7 @@ private MediaItem createHistoryMediaItem(@NonNull final StreamHistoryEntry strea final var builder = new MediaDescriptionCompat.Builder(); builder.setMediaId(ID_STREAM + '/' + streamHistoryEntry.getStreamId()) .setTitle(streamHistoryEntry.getStreamEntity().getTitle()) + .setSubtitle(streamHistoryEntry.getStreamEntity().getUploader()) .setIconUri(Uri.parse(streamHistoryEntry.getStreamEntity().getThumbnailUrl())); return new MediaItem(builder.build(), MediaItem.FLAG_PLAYABLE); From 0af4c34318ee034e4357e5666f5c838df1ab5e14 Mon Sep 17 00:00:00 2001 From: Haggai Eran Date: Tue, 22 Aug 2023 18:32:59 +0300 Subject: [PATCH 12/26] Update media browsers when the list of local playlist changes --- .../mediabrowser/MediaBrowserConnector.java | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserConnector.java b/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserConnector.java index 7a383e09616..3593155efbd 100644 --- a/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserConnector.java +++ b/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserConnector.java @@ -70,6 +70,8 @@ public MediaBrowserConnector(@NonNull final PlayerService playerService) { sessionConnector.setMetadataDeduplicationEnabled(true); sessionConnector.setPlaybackPreparer(this); playerService.setSessionToken(mediaSession.getSessionToken()); + + setupBookmarksNotifications(); } @NonNull @@ -79,6 +81,7 @@ public MediaSessionConnector getSessionConnector() { public void release() { disposePrepareOrPlayCommands(); + disposeBookmarksNotifications(); mediaSession.release(); } @@ -228,6 +231,20 @@ private LocalPlaylistManager getPlaylistManager() { return localPlaylistManager; } + @Nullable Disposable bookmarksNotificationsDisposable; + + private void setupBookmarksNotifications() { + bookmarksNotificationsDisposable = getPlaylistManager().getPlaylists().subscribe( + playlistMetadataEntries -> playerService.notifyChildrenChanged(ID_BOOKMARKS)); + } + + private void disposeBookmarksNotifications() { + if (bookmarksNotificationsDisposable != null) { + bookmarksNotificationsDisposable.dispose(); + bookmarksNotificationsDisposable = null; + } + } + // Suppress Sonar warning replace list collection by Stream.toList call, as this method is only // available in Android API 34 and not currently available with desugaring @SuppressWarnings("squid:S6204") From a1a288541db9017279f5dd103bab2cd26136ad7c Mon Sep 17 00:00:00 2001 From: Haggai Eran Date: Wed, 24 Jul 2024 20:38:09 +0300 Subject: [PATCH 13/26] android auto: fix navigation tab colors and cut text --- .../player/mediabrowser/MediaBrowserConnector.java | 6 +++--- app/src/main/res/drawable/ic_bookmark_white.xml | 10 ++++++++++ app/src/main/res/drawable/ic_history_white.xml | 10 ++++++++++ app/src/main/res/values/strings.xml | 1 + 4 files changed, 24 insertions(+), 3 deletions(-) create mode 100644 app/src/main/res/drawable/ic_bookmark_white.xml create mode 100644 app/src/main/res/drawable/ic_history_white.xml diff --git a/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserConnector.java b/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserConnector.java index 3593155efbd..7d67e3865e2 100644 --- a/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserConnector.java +++ b/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserConnector.java @@ -175,12 +175,12 @@ public Single> onLoadChildren(@NonNull final String parentId) { if (parentId.equals(ID_ROOT)) { mediaItems.add( createRootMediaItem(ID_BOOKMARKS, - playerService.getResources().getString(R.string.tab_bookmarks), - R.drawable.ic_bookmark)); + playerService.getResources().getString(R.string.tab_bookmarks_short), + R.drawable.ic_bookmark_white)); mediaItems.add( createRootMediaItem(ID_HISTORY, playerService.getResources().getString(R.string.action_history), - R.drawable.ic_history)); + R.drawable.ic_history_white)); } else if (parentId.startsWith(ID_BOOKMARKS)) { final Uri parentIdUri = Uri.parse(parentId); final List path = parentIdUri.getPathSegments(); diff --git a/app/src/main/res/drawable/ic_bookmark_white.xml b/app/src/main/res/drawable/ic_bookmark_white.xml new file mode 100644 index 00000000000..a04ed256e9d --- /dev/null +++ b/app/src/main/res/drawable/ic_bookmark_white.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_history_white.xml b/app/src/main/res/drawable/ic_history_white.xml new file mode 100644 index 00000000000..585285b890c --- /dev/null +++ b/app/src/main/res/drawable/ic_history_white.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 766491d538a..cd67b7e6ca7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -33,6 +33,7 @@ Show info Subscriptions Bookmarked Playlists + Playlists Choose Tab Background Popup From f6dd49b71d80a112f9492f6eec7da227f63a3eae Mon Sep 17 00:00:00 2001 From: Haggai Eran Date: Fri, 2 Aug 2024 13:42:49 +0300 Subject: [PATCH 14/26] PlaylistMetadataEntry: add interface method to get the thumbnail Url --- .../schabi/newpipe/database/playlist/PlaylistLocalItem.java | 2 ++ .../newpipe/database/playlist/PlaylistMetadataEntry.java | 5 +++++ 2 files changed, 7 insertions(+) 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..a974a09d0da 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 @@ -10,4 +10,6 @@ public interface PlaylistLocalItem extends LocalItem { long getUid(); void setDisplayIndex(long displayIndex); + + 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..4b0338b390e 100644 --- a/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistMetadataEntry.java +++ b/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistMetadataEntry.java @@ -71,4 +71,9 @@ public long getUid() { public void setDisplayIndex(final long displayIndex) { this.displayIndex = displayIndex; } + + @Override + public String getThumbnailUrl() { + return thumbnailUrl; + } } From f53ee4b65e4ff5a531db37b5090ec08818521b19 Mon Sep 17 00:00:00 2001 From: Haggai Eran Date: Fri, 2 Aug 2024 13:43:25 +0300 Subject: [PATCH 15/26] RemotePlaylistManager: add helper method to get a playlist by its uid --- .../schabi/newpipe/local/playlist/RemotePlaylistManager.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/src/main/java/org/schabi/newpipe/local/playlist/RemotePlaylistManager.java b/app/src/main/java/org/schabi/newpipe/local/playlist/RemotePlaylistManager.java index 4cc51f7525e..7104f59629a 100644 --- a/app/src/main/java/org/schabi/newpipe/local/playlist/RemotePlaylistManager.java +++ b/app/src/main/java/org/schabi/newpipe/local/playlist/RemotePlaylistManager.java @@ -26,6 +26,10 @@ public Flowable> getPlaylists() { return playlistRemoteTable.getPlaylists().subscribeOn(Schedulers.io()); } + public Flowable> getPlaylist(final long playlistId) { + return playlistRemoteTable.getPlaylist(playlistId).subscribeOn(Schedulers.io()); + } + public Flowable> getPlaylist(final PlaylistInfo info) { return playlistRemoteTable.getPlaylist(info.getServiceId(), info.getUrl()) .subscribeOn(Schedulers.io()); From 0c6387a92fe7661a0bb0e1ff17f4160bee30db0e Mon Sep 17 00:00:00 2001 From: Haggai Eran Date: Fri, 2 Aug 2024 13:44:27 +0300 Subject: [PATCH 16/26] media browser: expose remote playlists together with local playlists This is similar to how they are shown in the app UI. --- .../mediabrowser/MediaBrowserConnector.java | 163 ++++++++++++++---- 1 file changed, 126 insertions(+), 37 deletions(-) diff --git a/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserConnector.java b/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserConnector.java index 7d67e3865e2..99099e30837 100644 --- a/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserConnector.java +++ b/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserConnector.java @@ -12,6 +12,7 @@ import android.support.v4.media.session.MediaSessionCompat; import android.support.v4.media.session.PlaybackStateCompat; import android.util.Log; +import android.util.Pair; import androidx.annotation.DrawableRes; import androidx.annotation.NonNull; @@ -28,23 +29,30 @@ import org.schabi.newpipe.database.AppDatabase; import org.schabi.newpipe.database.history.dao.StreamHistoryDAO; import org.schabi.newpipe.database.history.model.StreamHistoryEntry; -import org.schabi.newpipe.database.playlist.PlaylistMetadataEntry; +import org.schabi.newpipe.database.playlist.PlaylistLocalItem; import org.schabi.newpipe.database.playlist.PlaylistStreamEntry; +import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity; import org.schabi.newpipe.error.ErrorInfo; import org.schabi.newpipe.error.UserAction; import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException; +import org.schabi.newpipe.extractor.exceptions.ContentNotSupportedException; import org.schabi.newpipe.extractor.stream.StreamInfoItem; +import org.schabi.newpipe.local.bookmark.MergedPlaylistManager; import org.schabi.newpipe.local.playlist.LocalPlaylistManager; +import org.schabi.newpipe.local.playlist.RemotePlaylistManager; import org.schabi.newpipe.player.PlayerService; import org.schabi.newpipe.player.playqueue.PlayQueue; import org.schabi.newpipe.player.playqueue.SinglePlayQueue; +import org.schabi.newpipe.util.ExtractorHelper; import org.schabi.newpipe.util.NavigationHelper; import java.util.ArrayList; import java.util.List; import java.util.stream.Collectors; +import java.util.stream.IntStream; import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; +import io.reactivex.rxjava3.core.Flowable; import io.reactivex.rxjava3.core.Single; import io.reactivex.rxjava3.disposables.Disposable; @@ -61,6 +69,7 @@ public class MediaBrowserConnector implements MediaSessionConnector.PlaybackPrep private AppDatabase database; private LocalPlaylistManager localPlaylistManager; + private RemotePlaylistManager remotePlaylistManager; private Disposable prepareOrPlayDisposable; public MediaBrowserConnector(@NonNull final PlayerService playerService) { @@ -94,6 +103,11 @@ public void release() { @NonNull private static final String ID_STREAM = ID_ROOT + "/stream"; + @NonNull + private static final String ID_LOCAL = "local"; + @NonNull + private static final String ID_REMOTE = "remote"; + @NonNull private MediaItem createRootMediaItem(@Nullable final String mediaId, final String folderName, @@ -117,11 +131,12 @@ private MediaItem createRootMediaItem(@Nullable final String mediaId, } @NonNull - private MediaItem createPlaylistMediaItem(@NonNull final PlaylistMetadataEntry playlist) { + private MediaItem createPlaylistMediaItem(@NonNull final PlaylistLocalItem playlist) { final var builder = new MediaDescriptionCompat.Builder(); - builder.setMediaId(createMediaIdForPlaylist(playlist.getUid())) - .setTitle(playlist.name) - .setIconUri(Uri.parse(playlist.thumbnailUrl)); + final boolean remote = playlist instanceof PlaylistRemoteEntity; + builder.setMediaId(createMediaIdForPlaylist(remote, playlist.getUid())) + .setTitle(playlist.getOrderingName()) + .setIconUri(Uri.parse(playlist.getThumbnailUrl())); final Bundle extras = new Bundle(); extras.putString(MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE, @@ -131,16 +146,16 @@ private MediaItem createPlaylistMediaItem(@NonNull final PlaylistMetadataEntry p } @NonNull - private String createMediaIdForPlaylist(final long playlistId) { - return ID_BOOKMARKS + '/' + playlistId; + private String createMediaIdForPlaylist(final boolean remote, final long playlistId) { + return ID_BOOKMARKS + '/' + (remote ? ID_REMOTE : ID_LOCAL) + '/' + playlistId; } @NonNull - private MediaItem createPlaylistStreamMediaItem(final long playlistId, - @NonNull final PlaylistStreamEntry item, - final int index) { + private MediaItem createLocalPlaylistStreamMediaItem(final long playlistId, + @NonNull final PlaylistStreamEntry item, + final int index) { final var builder = new MediaDescriptionCompat.Builder(); - builder.setMediaId(createMediaIdForPlaylistIndex(playlistId, index)) + builder.setMediaId(createMediaIdForPlaylistIndex(false, playlistId, index)) .setTitle(item.getStreamEntity().getTitle()) .setSubtitle(item.getStreamEntity().getUploader()) .setIconUri(Uri.parse(item.getStreamEntity().getThumbnailUrl())); @@ -149,8 +164,25 @@ private MediaItem createPlaylistStreamMediaItem(final long playlistId, } @NonNull - private String createMediaIdForPlaylistIndex(final long playlistId, final int index) { - return createMediaIdForPlaylist(playlistId) + '/' + index; + private MediaItem createRemotePlaylistStreamMediaItem(final long playlistId, + @NonNull final StreamInfoItem item, + final int index) { + final var builder = new MediaDescriptionCompat.Builder(); + builder.setMediaId(createMediaIdForPlaylistIndex(true, playlistId, index)) + .setTitle(item.getName()) + .setSubtitle(item.getUploaderName()); + final var thumbnails = item.getThumbnails(); + if (!thumbnails.isEmpty()) { + builder.setIconUri(Uri.parse(thumbnails.get(0).getUrl())); + } + + return new MediaItem(builder.build(), MediaItem.FLAG_PLAYABLE); + } + + @NonNull + private String createMediaIdForPlaylistIndex(final boolean remote, final long playlistId, + final int index) { + return createMediaIdForPlaylist(remote, playlistId) + '/' + index; } @Nullable @@ -186,12 +218,16 @@ public Single> onLoadChildren(@NonNull final String parentId) { final List path = parentIdUri.getPathSegments(); if (path.size() == 2) { return populateBookmarks(); - } else if (path.size() == 3) { - final long playlistId = Long.parseLong(path.get(2)); - return populatePlaylist(playlistId); - } else { - Log.w(TAG, "Unknown playlist URI: " + parentId); + } else if (path.size() == 4) { + final String localOrRemote = path.get(2); + final long playlistId = Long.parseLong(path.get(3)); + if (localOrRemote.equals(ID_LOCAL)) { + return populateLocalPlaylist(playlistId); + } else if (localOrRemote.equals(ID_REMOTE)) { + return populateRemotePlaylist(playlistId); + } } + Log.w(TAG, "Unknown playlist URI: " + parentId); } else if (parentId.equals(ID_HISTORY)) { return populateHistory(); } @@ -224,17 +260,21 @@ private AppDatabase getDatabase() { return database; } - private LocalPlaylistManager getPlaylistManager() { + private Flowable> getPlaylists() { if (localPlaylistManager == null) { localPlaylistManager = new LocalPlaylistManager(getDatabase()); } - return localPlaylistManager; + if (remotePlaylistManager == null) { + remotePlaylistManager = new RemotePlaylistManager(getDatabase()); + } + return MergedPlaylistManager.getMergedOrderedPlaylists(localPlaylistManager, + remotePlaylistManager); } @Nullable Disposable bookmarksNotificationsDisposable; private void setupBookmarksNotifications() { - bookmarksNotificationsDisposable = getPlaylistManager().getPlaylists().subscribe( + bookmarksNotificationsDisposable = getPlaylists().subscribe( playlistMetadataEntries -> playerService.notifyChildrenChanged(ID_BOOKMARKS)); } @@ -249,25 +289,59 @@ private void disposeBookmarksNotifications() { // available in Android API 34 and not currently available with desugaring @SuppressWarnings("squid:S6204") private Single> populateBookmarks() { - final var playlists = getPlaylistManager().getPlaylists().firstOrError(); + final var playlists = getPlaylists().firstOrError(); return playlists.map(playlist -> playlist.stream() .map(this::createPlaylistMediaItem) .collect(Collectors.toList())); } - private Single> populatePlaylist(final long playlistId) { - final var playlist = getPlaylistManager().getPlaylistStreams(playlistId).firstOrError(); + private Single> populateLocalPlaylist(final long playlistId) { + final var playlist = localPlaylistManager.getPlaylistStreams(playlistId).firstOrError(); return playlist.map(items -> { final List results = new ArrayList<>(); int index = 0; for (final PlaylistStreamEntry item : items) { - results.add(createPlaylistStreamMediaItem(playlistId, item, index)); + results.add(createLocalPlaylistStreamMediaItem(playlistId, item, index)); ++index; } return results; }); } + private Single>> getRemotePlaylist(final long playlistId) { + final var playlistFlow = remotePlaylistManager.getPlaylist(playlistId).firstOrError(); + return playlistFlow.flatMap(item -> { + final var playlist = item.get(0); + final var playlistInfo = ExtractorHelper.getPlaylistInfo(playlist.getServiceId(), + playlist.getUrl(), false); + return playlistInfo.flatMap(info -> { + final var infoItemsPage = info.getRelatedItems(); + + if (!info.getErrors().isEmpty()) { + final List errors = new ArrayList<>(info.getErrors()); + + errors.removeIf(ContentNotSupportedException.class::isInstance); + + if (!errors.isEmpty()) { + return Single.error(errors.get(0)); + } + } + + return Single.just(IntStream.range(0, infoItemsPage.size()) + .mapToObj(i -> Pair.create(infoItemsPage.get(i), i)) + .toList()); + }); + }); + } + + private Single> populateRemotePlaylist(final long playlistId) { + return getRemotePlaylist(playlistId).map(items -> + items.stream().map(pair -> + createRemotePlaylistStreamMediaItem(playlistId, pair.first, pair.second) + ).toList() + ); + } + private void playbackError(@StringRes final int resId, final int code) { playerService.stopForImmediateReusing(); sessionConnector.setCustomErrorMessage(playerService.getString(resId), code); @@ -277,6 +351,24 @@ private void playbackError(@NonNull final ErrorInfo errorInfo) { playbackError(errorInfo.getMessageStringId(), PlaybackStateCompat.ERROR_CODE_APP_ERROR); } + private Single extractLocalPlayQueue(final long playlistId, final int index) { + return localPlaylistManager.getPlaylistStreams(playlistId) + .firstOrError() + .map(items -> { + final List infoItems = items.stream() + .map(PlaylistStreamEntry::toStreamInfoItem) + .collect(Collectors.toList()); + return new SinglePlayQueue(infoItems, index); + }); + } + + private Single extractRemotePlayQueue(final long playlistId, final int index) { + return getRemotePlaylist(playlistId).map(items -> { + final var infoItems = items.stream().map(pair -> pair.first).toList(); + return new SinglePlayQueue(infoItems, index); + }); + } + private Single extractPlayQueueFromMediaId(final String mediaId) { final Uri mediaIdUri = Uri.parse(mediaId); if (mediaIdUri == null) { @@ -285,19 +377,16 @@ private Single extractPlayQueueFromMediaId(final String mediaId) { final List path = mediaIdUri.getPathSegments(); - if (mediaId.startsWith(ID_BOOKMARKS) && path.size() == 4) { - final long playlistId = Long.parseLong(path.get(2)); - final int index = Integer.parseInt(path.get(3)); + if (mediaId.startsWith(ID_BOOKMARKS) && path.size() == 5) { + final String localOrRemote = path.get(2); + final long playlistId = Long.parseLong(path.get(3)); + final int index = Integer.parseInt(path.get(4)); - return getPlaylistManager() - .getPlaylistStreams(playlistId) - .firstOrError() - .map(items -> { - final List infoItems = items.stream() - .map(PlaylistStreamEntry::toStreamInfoItem) - .collect(Collectors.toList()); - return new SinglePlayQueue(infoItems, index); - }); + if (localOrRemote.equals(ID_LOCAL)) { + return extractLocalPlayQueue(playlistId, index); + } else { + return extractRemotePlayQueue(playlistId, index); + } } else if (mediaId.startsWith(ID_STREAM) && path.size() == 3) { final long streamId = Long.parseLong(path.get(2)); return getDatabase().streamHistoryDAO().getHistory() From 189c70f9d31f24ff94cad468a7d620e064e7b6e6 Mon Sep 17 00:00:00 2001 From: Haggai Eran Date: Sun, 4 Aug 2024 09:01:46 +0300 Subject: [PATCH 17/26] media browser: support searching Also improve parser code to simplify passing URLs within a media ID. --- .../schabi/newpipe/player/PlayerService.java | 7 + .../mediabrowser/MediaBrowserConnector.java | 392 +++++++++++++++--- 2 files changed, 337 insertions(+), 62 deletions(-) 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 55fdfd3a8ac..e088290c921 100644 --- a/app/src/main/java/org/schabi/newpipe/player/PlayerService.java +++ b/app/src/main/java/org/schabi/newpipe/player/PlayerService.java @@ -229,6 +229,13 @@ public void onLoadChildren(@NonNull final String parentId, compositeDisposableLoadChildren.add(disposable); } + @Override + public void onSearch(@NonNull final String query, + final Bundle extras, + @NonNull final Result> result) { + mediaBrowserConnector.onSearch(query, result); + } + public static final class LocalBinder extends Binder { private final WeakReference playerService; diff --git a/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserConnector.java b/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserConnector.java index 99099e30837..18133311e8c 100644 --- a/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserConnector.java +++ b/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserConnector.java @@ -24,6 +24,7 @@ import com.google.android.exoplayer2.Player; import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector; +import org.schabi.newpipe.BuildConfig; import org.schabi.newpipe.NewPipeDatabase; import org.schabi.newpipe.R; import org.schabi.newpipe.database.AppDatabase; @@ -34,20 +35,32 @@ import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity; import org.schabi.newpipe.error.ErrorInfo; import org.schabi.newpipe.error.UserAction; +import org.schabi.newpipe.extractor.InfoItem; +import org.schabi.newpipe.extractor.ListInfo; +import org.schabi.newpipe.extractor.channel.ChannelInfoItem; import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException; import org.schabi.newpipe.extractor.exceptions.ContentNotSupportedException; +import org.schabi.newpipe.extractor.linkhandler.ListLinkHandler; +import org.schabi.newpipe.extractor.playlist.PlaylistInfoItem; import org.schabi.newpipe.extractor.stream.StreamInfoItem; import org.schabi.newpipe.local.bookmark.MergedPlaylistManager; +import org.schabi.newpipe.extractor.search.SearchExtractor; +import org.schabi.newpipe.extractor.search.SearchInfo; import org.schabi.newpipe.local.playlist.LocalPlaylistManager; import org.schabi.newpipe.local.playlist.RemotePlaylistManager; import org.schabi.newpipe.player.PlayerService; +import org.schabi.newpipe.player.playqueue.ChannelTabPlayQueue; import org.schabi.newpipe.player.playqueue.PlayQueue; +import org.schabi.newpipe.player.playqueue.PlaylistPlayQueue; import org.schabi.newpipe.player.playqueue.SinglePlayQueue; +import org.schabi.newpipe.util.ChannelTabHelper; import org.schabi.newpipe.util.ExtractorHelper; import org.schabi.newpipe.util.NavigationHelper; +import org.schabi.newpipe.util.ServiceHelper; import java.util.ArrayList; import java.util.List; +import java.util.Optional; import java.util.stream.Collectors; import java.util.stream.IntStream; @@ -56,6 +69,9 @@ import io.reactivex.rxjava3.core.Single; import io.reactivex.rxjava3.disposables.Disposable; +import io.reactivex.rxjava3.core.SingleSource; +import io.reactivex.rxjava3.schedulers.Schedulers; + public class MediaBrowserConnector implements MediaSessionConnector.PlaybackPreparer { private static final String TAG = MediaBrowserConnector.class.getSimpleName(); @@ -71,6 +87,7 @@ public class MediaBrowserConnector implements MediaSessionConnector.PlaybackPrep private LocalPlaylistManager localPlaylistManager; private RemotePlaylistManager remotePlaylistManager; private Disposable prepareOrPlayDisposable; + private Disposable searchDisposable; public MediaBrowserConnector(@NonNull final PlayerService playerService) { this.playerService = playerService; @@ -95,18 +112,28 @@ public void release() { } @NonNull - private static final String ID_ROOT = "//${BuildConfig.APPLICATION_ID}/r"; + private static final String ID_AUTHORITY = BuildConfig.APPLICATION_ID; + @NonNull + private static final String ID_ROOT = "//" + ID_AUTHORITY; @NonNull - private static final String ID_BOOKMARKS = ID_ROOT + "/playlists"; + private static final String ID_BOOKMARKS = "playlists"; @NonNull - private static final String ID_HISTORY = ID_ROOT + "/history"; + private static final String ID_HISTORY = "history"; @NonNull - private static final String ID_STREAM = ID_ROOT + "/stream"; + private static final String ID_INFO_ITEM = "item"; @NonNull private static final String ID_LOCAL = "local"; @NonNull private static final String ID_REMOTE = "remote"; + @NonNull + private static final String ID_URL = "url"; + @NonNull + private static final String ID_STREAM = "stream"; + @NonNull + private static final String ID_PLAYLIST = "playlist"; + @NonNull + private static final String ID_CHANNEL = "channel"; @NonNull private MediaItem createRootMediaItem(@Nullable final String mediaId, @@ -134,7 +161,7 @@ private MediaItem createRootMediaItem(@Nullable final String mediaId, private MediaItem createPlaylistMediaItem(@NonNull final PlaylistLocalItem playlist) { final var builder = new MediaDescriptionCompat.Builder(); final boolean remote = playlist instanceof PlaylistRemoteEntity; - builder.setMediaId(createMediaIdForPlaylist(remote, playlist.getUid())) + builder.setMediaId(createMediaIdForInfoItem(remote, playlist.getUid())) .setTitle(playlist.getOrderingName()) .setIconUri(Uri.parse(playlist.getThumbnailUrl())); @@ -145,9 +172,82 @@ private MediaItem createPlaylistMediaItem(@NonNull final PlaylistLocalItem playl return new MediaItem(builder.build(), MediaItem.FLAG_BROWSABLE); } + private MediaItem createInfoItemMediaItem(@NonNull final InfoItem item) { + final var builder = new MediaDescriptionCompat.Builder(); + builder.setMediaId(createMediaIdForInfoItem(item)) + .setTitle(item.getName()); + + switch (item.getInfoType()) { + case STREAM: + builder.setSubtitle(((StreamInfoItem) item).getUploaderName()); + break; + case PLAYLIST: + builder.setSubtitle(((PlaylistInfoItem) item).getUploaderName()); + break; + case CHANNEL: + builder.setSubtitle(((ChannelInfoItem) item).getDescription()); + break; + default: + break; + } + final var thumbnails = item.getThumbnails(); + if (!thumbnails.isEmpty()) { + builder.setIconUri(Uri.parse(thumbnails.get(0).getUrl())); + } + return new MediaItem(builder.build(), MediaItem.FLAG_PLAYABLE); + } + + @NonNull + private Uri.Builder buildMediaId() { + return new Uri.Builder().authority(ID_AUTHORITY); + } + + @NonNull + private Uri.Builder buildPlaylistMediaId(final String playlistType) { + return buildMediaId() + .appendPath(ID_BOOKMARKS) + .appendPath(playlistType); + } + @NonNull - private String createMediaIdForPlaylist(final boolean remote, final long playlistId) { - return ID_BOOKMARKS + '/' + (remote ? ID_REMOTE : ID_LOCAL) + '/' + playlistId; + private Uri.Builder buildLocalPlaylistItemMediaId(final boolean remote, final long playlistId) { + return buildPlaylistMediaId(remote ? ID_REMOTE : ID_LOCAL) + .appendPath(Long.toString(playlistId)); + } + + private static String infoItemTypeToString(final InfoItem.InfoType type) { + return switch (type) { + case STREAM -> ID_STREAM; + case PLAYLIST -> ID_PLAYLIST; + case CHANNEL -> ID_CHANNEL; + default -> + throw new IllegalStateException("Unexpected value: " + type); + }; + } + + private static InfoItem.InfoType infoItemTypeFromString(final String type) { + return switch (type) { + case ID_STREAM -> InfoItem.InfoType.STREAM; + case ID_PLAYLIST -> InfoItem.InfoType.PLAYLIST; + case ID_CHANNEL -> InfoItem.InfoType.CHANNEL; + default -> + throw new IllegalStateException("Unexpected value: " + type); + }; + } + + @NonNull + private Uri.Builder buildInfoItemMediaId(@NonNull final InfoItem item) { + return buildMediaId() + .appendPath(ID_INFO_ITEM) + .appendPath(infoItemTypeToString(item.getInfoType())) + .appendPath(Integer.toString(item.getServiceId())) + .appendQueryParameter(ID_URL, item.getUrl()); + } + + @NonNull + private String createMediaIdForInfoItem(final boolean remote, final long playlistId) { + return buildLocalPlaylistItemMediaId(remote, playlistId) + .build().toString(); } @NonNull @@ -182,7 +282,14 @@ private MediaItem createRemotePlaylistStreamMediaItem(final long playlistId, @NonNull private String createMediaIdForPlaylistIndex(final boolean remote, final long playlistId, final int index) { - return createMediaIdForPlaylist(remote, playlistId) + '/' + index; + return buildLocalPlaylistItemMediaId(remote, playlistId) + .appendPath(Integer.toString(index)) + .build().toString(); + } + + @NonNull + private String createMediaIdForInfoItem(@NonNull final InfoItem item) { + return buildInfoItemMediaId(item).build().toString(); } @Nullable @@ -194,7 +301,10 @@ public MediaBrowserServiceCompat.BrowserRoot onGetRoot(@NonNull final String cli clientPackageName, clientUid, rootHints)); } - return new MediaBrowserServiceCompat.BrowserRoot(ID_ROOT, null); + final Bundle extras = new Bundle(); + extras.putBoolean( + MediaConstants.BROWSER_SERVICE_EXTRAS_KEY_SEARCH_SUPPORTED, true); + return new MediaBrowserServiceCompat.BrowserRoot(ID_ROOT, extras); } public Single> onLoadChildren(@NonNull final String parentId) { @@ -202,36 +312,56 @@ public Single> onLoadChildren(@NonNull final String parentId) { Log.d(TAG, String.format("MediaBrowserService.onLoadChildren(%s)", parentId)); } - final List mediaItems = new ArrayList<>(); - - if (parentId.equals(ID_ROOT)) { - mediaItems.add( - createRootMediaItem(ID_BOOKMARKS, - playerService.getResources().getString(R.string.tab_bookmarks_short), - R.drawable.ic_bookmark_white)); - mediaItems.add( - createRootMediaItem(ID_HISTORY, - playerService.getResources().getString(R.string.action_history), - R.drawable.ic_history_white)); - } else if (parentId.startsWith(ID_BOOKMARKS)) { + + try { final Uri parentIdUri = Uri.parse(parentId); - final List path = parentIdUri.getPathSegments(); - if (path.size() == 2) { - return populateBookmarks(); - } else if (path.size() == 4) { - final String localOrRemote = path.get(2); - final long playlistId = Long.parseLong(path.get(3)); - if (localOrRemote.equals(ID_LOCAL)) { - return populateLocalPlaylist(playlistId); - } else if (localOrRemote.equals(ID_REMOTE)) { - return populateRemotePlaylist(playlistId); - } + if (parentIdUri == null) { + throw parseError(); } - Log.w(TAG, "Unknown playlist URI: " + parentId); - } else if (parentId.equals(ID_HISTORY)) { - return populateHistory(); + + final List path = new ArrayList<>(parentIdUri.getPathSegments()); + + if (path.isEmpty()) { + final List mediaItems = new ArrayList<>(); + mediaItems.add( + createRootMediaItem(ID_BOOKMARKS, + playerService.getResources().getString( + R.string.tab_bookmarks_short), + R.drawable.ic_bookmark_white)); + mediaItems.add( + createRootMediaItem(ID_HISTORY, + playerService.getResources().getString(R.string.action_history), + R.drawable.ic_history_white)); + return Single.just(mediaItems); + } + + final String uriType = path.get(0); + path.remove(0); + + switch (uriType) { + case ID_BOOKMARKS: + if (path.isEmpty()) { + return populateBookmarks(); + } + if (path.size() == 2) { + final String localOrRemote = path.get(0); + final long playlistId = Long.parseLong(path.get(1)); + if (localOrRemote.equals(ID_LOCAL)) { + return populateLocalPlaylist(playlistId); + } else if (localOrRemote.equals(ID_REMOTE)) { + return populateRemotePlaylist(playlistId); + } + } + Log.w(TAG, "Unknown playlist URI: " + parentId); + throw parseError(); + case ID_HISTORY: + return populateHistory(); + default: + throw parseError(); + } + } catch (final ContentNotAvailableException e) { + return Single.error(e); } - return Single.just(mediaItems); } private Single> populateHistory() { @@ -245,7 +375,11 @@ private Single> populateHistory() { @NonNull private MediaItem createHistoryMediaItem(@NonNull final StreamHistoryEntry streamHistoryEntry) { final var builder = new MediaDescriptionCompat.Builder(); - builder.setMediaId(ID_STREAM + '/' + streamHistoryEntry.getStreamId()) + final var mediaId = buildMediaId() + .appendPath(ID_HISTORY) + .appendPath(Long.toString(streamHistoryEntry.getStreamId())) + .build().toString(); + builder.setMediaId(mediaId) .setTitle(streamHistoryEntry.getStreamEntity().getTitle()) .setSubtitle(streamHistoryEntry.getStreamEntity().getUploader()) .setIconUri(Uri.parse(streamHistoryEntry.getStreamEntity().getThumbnailUrl())); @@ -369,38 +503,119 @@ private Single extractRemotePlayQueue(final long playlistId, final in }); } + private static ContentNotAvailableException parseError() { + return new ContentNotAvailableException("Failed to parse media ID"); + } + private Single extractPlayQueueFromMediaId(final String mediaId) { - final Uri mediaIdUri = Uri.parse(mediaId); - if (mediaIdUri == null) { - return Single.error(new ContentNotAvailableException("Media ID cannot be parsed")); + try { + final Uri mediaIdUri = Uri.parse(mediaId); + if (mediaIdUri == null) { + throw parseError(); + } + + final List path = new ArrayList<>(mediaIdUri.getPathSegments()); + + if (path.isEmpty()) { + throw parseError(); + } + + final String uriType = path.get(0); + path.remove(0); + + return switch (uriType) { + case ID_BOOKMARKS -> extractPlayQueueFromPlaylistMediaId(path, + mediaIdUri.getQueryParameter(ID_URL)); + case ID_HISTORY -> extractPlayQueueFromHistoryMediaId(path); + case ID_INFO_ITEM -> extractPlayQueueFromInfoItemMediaId(path, + mediaIdUri.getQueryParameter(ID_URL)); + default -> throw parseError(); + }; + } catch (final ContentNotAvailableException e) { + return Single.error(e); } + } - final List path = mediaIdUri.getPathSegments(); + private Single + extractPlayQueueFromPlaylistMediaId( + @NonNull final List path, + @Nullable final String url) throws ContentNotAvailableException { + if (path.isEmpty()) { + throw parseError(); + } - if (mediaId.startsWith(ID_BOOKMARKS) && path.size() == 5) { - final String localOrRemote = path.get(2); - final long playlistId = Long.parseLong(path.get(3)); - final int index = Integer.parseInt(path.get(4)); + final String playlistType = path.get(0); + path.remove(0); - if (localOrRemote.equals(ID_LOCAL)) { - return extractLocalPlayQueue(playlistId, index); - } else { - return extractRemotePlayQueue(playlistId, index); - } - } else if (mediaId.startsWith(ID_STREAM) && path.size() == 3) { - final long streamId = Long.parseLong(path.get(2)); - return getDatabase().streamHistoryDAO().getHistory() - .firstOrError() - .map(items -> { - final List infoItems = items.stream() - .filter(it -> it.getStreamId() == streamId) - .map(StreamHistoryEntry::toStreamInfoItem) - .collect(Collectors.toList()); - return new SinglePlayQueue(infoItems, 0); - }); + switch (playlistType) { + case ID_LOCAL, ID_REMOTE: + if (path.size() != 2) { + throw parseError(); + } + final long playlistId = Long.parseLong(path.get(0)); + final int index = Integer.parseInt(path.get(1)); + return playlistType.equals(ID_LOCAL) + ? extractLocalPlayQueue(playlistId, index) + : extractRemotePlayQueue(playlistId, index); + case ID_URL: + if (path.size() != 1) { + throw parseError(); + } + + final int serviceId = Integer.parseInt(path.get(0)); + return ExtractorHelper.getPlaylistInfo(serviceId, url, false) + .map(PlaylistPlayQueue::new); + default: + throw parseError(); + } + } + + private Single extractPlayQueueFromHistoryMediaId( + final List path) throws ContentNotAvailableException { + if (path.size() != 1) { + throw parseError(); } - return Single.error(new ContentNotAvailableException("Media ID cannot be parsed")); + final long streamId = Long.parseLong(path.get(0)); + return getDatabase().streamHistoryDAO().getHistory() + .firstOrError() + .map(items -> { + final List infoItems = items.stream() + .filter(it -> it.getStreamId() == streamId) + .map(StreamHistoryEntry::toStreamInfoItem) + .collect(Collectors.toList()); + return new SinglePlayQueue(infoItems, 0); + }); + } + + private static Single extractPlayQueueFromInfoItemMediaId( + final List path, final String url) throws ContentNotAvailableException { + if (path.size() != 2) { + throw parseError(); + } + final var infoItemType = infoItemTypeFromString(path.get(0)); + final int serviceId = Integer.parseInt(path.get(1)); + return switch (infoItemType) { + case STREAM -> ExtractorHelper.getStreamInfo(serviceId, url, false) + .map(SinglePlayQueue::new); + case PLAYLIST -> ExtractorHelper.getPlaylistInfo(serviceId, url, false) + .map(PlaylistPlayQueue::new); + case CHANNEL -> ExtractorHelper.getChannelInfo(serviceId, url, false) + .map(info -> { + final Optional playableTab = info.getTabs() + .stream() + .filter(ChannelTabHelper::isStreamsTab) + .findFirst(); + + if (playableTab.isPresent()) { + return new ChannelTabPlayQueue(serviceId, + new ListLinkHandler(playableTab.get())); + } else { + throw new ContentNotAvailableException("No streams tab found"); + } + }); + default -> throw parseError(); + }; } @Override @@ -432,6 +647,7 @@ public void onPrepareFromMediaId(@NonNull final String mediaId, disposePrepareOrPlayCommands(); prepareOrPlayDisposable = extractPlayQueueFromMediaId(mediaId) + .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe( playQueue -> { @@ -448,6 +664,45 @@ public void onPrepareFromMediaId(@NonNull final String mediaId, public void onPrepareFromSearch(@NonNull final String query, final boolean playWhenReady, @Nullable final Bundle extras) { + disposePrepareOrPlayCommands(); + playbackError(R.string.content_not_supported, + PlaybackStateCompat.ERROR_CODE_NOT_SUPPORTED); + } + + private @NonNull Single searchMusicBySongTitle(final String query) { + final var serviceId = ServiceHelper.getSelectedServiceId(playerService); + return ExtractorHelper.searchFor(serviceId, query, + new ArrayList<>(), ""); + } + + private @NonNull SingleSource> + mediaItemsFromInfoItemList(final ListInfo result) { + final List exceptions = result.getErrors(); + if (!exceptions.isEmpty() + && !(exceptions.size() == 1 + && exceptions.get(0) instanceof SearchExtractor.NothingFoundException)) { + return Single.error(exceptions.get(0)); + } + + final List items = result.getRelatedItems(); + if (items.isEmpty()) { + return Single.error(new NullPointerException("Got no search results.")); + } + try { + final List results = items.stream() + .filter(item -> + item.getInfoType() == InfoItem.InfoType.STREAM + || item.getInfoType() == InfoItem.InfoType.PLAYLIST + || item.getInfoType() == InfoItem.InfoType.CHANNEL) + .map(this::createInfoItemMediaItem).toList(); + return Single.just(results); + } catch (final Exception e) { + return Single.error(e); + } + } + + private void handleSearchError(final Throwable throwable) { + Log.e(TAG, "Search error: " + throwable); disposePrepareOrPlayCommands(); playbackError(R.string.content_not_supported, PlaybackStateCompat.ERROR_CODE_NOT_SUPPORTED); } @@ -467,4 +722,17 @@ public boolean onCommand(@NonNull final Player player, @Nullable final ResultReceiver cb) { return false; } + + public void onSearch(@NonNull final String query, + @NonNull final MediaBrowserServiceCompat.Result> result) { + result.detach(); + if (searchDisposable != null) { + searchDisposable.dispose(); + } + searchDisposable = searchMusicBySongTitle(query) + .flatMap(this::mediaItemsFromInfoItemList) + .subscribeOn(Schedulers.io()) + .subscribe(result::sendResult, + this::handleSearchError); + } } From 89bdfef4b9114131cfed8e859fa42e1688fc67aa Mon Sep 17 00:00:00 2001 From: Haggai Eran Date: Fri, 16 Aug 2024 17:44:20 +0300 Subject: [PATCH 18/26] media browser: clean up Uri.parse() null checks --- .../player/mediabrowser/MediaBrowserConnector.java | 8 -------- 1 file changed, 8 deletions(-) diff --git a/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserConnector.java b/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserConnector.java index 18133311e8c..e7856db067f 100644 --- a/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserConnector.java +++ b/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserConnector.java @@ -315,10 +315,6 @@ public Single> onLoadChildren(@NonNull final String parentId) { try { final Uri parentIdUri = Uri.parse(parentId); - if (parentIdUri == null) { - throw parseError(); - } - final List path = new ArrayList<>(parentIdUri.getPathSegments()); if (path.isEmpty()) { @@ -510,10 +506,6 @@ private static ContentNotAvailableException parseError() { private Single extractPlayQueueFromMediaId(final String mediaId) { try { final Uri mediaIdUri = Uri.parse(mediaId); - if (mediaIdUri == null) { - throw parseError(); - } - final List path = new ArrayList<>(mediaIdUri.getPathSegments()); if (path.isEmpty()) { From bf59f1e09d2900b017c292a0a3053aa2045836eb Mon Sep 17 00:00:00 2001 From: Siddhesh Naik Date: Tue, 3 Sep 2024 05:29:57 +0530 Subject: [PATCH 19/26] Convert new and important files to Kotlin and optimize --- .../schabi/newpipe/database/LocalItem.java | 13 - .../org/schabi/newpipe/database/LocalItem.kt | 18 + .../history/model/StreamHistoryEntry.kt | 20 +- .../database/playlist/PlaylistLocalItem.java | 15 - .../database/playlist/PlaylistLocalItem.kt | 28 + .../database/playlist/PlaylistStreamEntry.kt | 29 +- .../database/stream/StreamStatisticsEntry.kt | 28 +- .../org/schabi/newpipe/player/Player.java | 3 +- .../schabi/newpipe/player/PlayerService.java | 256 ------ .../schabi/newpipe/player/PlayerService.kt | 236 ++++++ .../mediabrowser/MediaBrowserConnector.java | 730 ----------------- .../mediabrowser/MediaBrowserConnector.kt | 735 ++++++++++++++++++ 12 files changed, 1058 insertions(+), 1053 deletions(-) delete mode 100644 app/src/main/java/org/schabi/newpipe/database/LocalItem.java create mode 100644 app/src/main/java/org/schabi/newpipe/database/LocalItem.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistLocalItem.java create mode 100644 app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistLocalItem.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/player/PlayerService.java create mode 100644 app/src/main/java/org/schabi/newpipe/player/PlayerService.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserConnector.java create mode 100644 app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserConnector.kt diff --git a/app/src/main/java/org/schabi/newpipe/database/LocalItem.java b/app/src/main/java/org/schabi/newpipe/database/LocalItem.java deleted file mode 100644 index 54b856b0653..00000000000 --- a/app/src/main/java/org/schabi/newpipe/database/LocalItem.java +++ /dev/null @@ -1,13 +0,0 @@ -package org.schabi.newpipe.database; - -public interface LocalItem { - LocalItemType getLocalItemType(); - - enum LocalItemType { - PLAYLIST_LOCAL_ITEM, - PLAYLIST_REMOTE_ITEM, - - PLAYLIST_STREAM_ITEM, - STATISTIC_STREAM_ITEM, - } -} diff --git a/app/src/main/java/org/schabi/newpipe/database/LocalItem.kt b/app/src/main/java/org/schabi/newpipe/database/LocalItem.kt new file mode 100644 index 00000000000..87084cd51d3 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/database/LocalItem.kt @@ -0,0 +1,18 @@ +package org.schabi.newpipe.database + +/** + * Represents a generic item that can be stored locally. This can be a playlist, a stream, etc. + */ +interface LocalItem { + /** + * The type of local item. Can be null if the type is unknown or not applicable. + */ + val localItemType: LocalItemType? + + enum class LocalItemType { + PLAYLIST_LOCAL_ITEM, + PLAYLIST_REMOTE_ITEM, + PLAYLIST_STREAM_ITEM, + STATISTIC_STREAM_ITEM, + } +} diff --git a/app/src/main/java/org/schabi/newpipe/database/history/model/StreamHistoryEntry.kt b/app/src/main/java/org/schabi/newpipe/database/history/model/StreamHistoryEntry.kt index 1eb299890e6..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 @@ -30,18 +30,16 @@ data class StreamHistoryEntry( accessDate.isEqual(other.accessDate) } - fun toStreamInfoItem(): StreamInfoItem { - val item = StreamInfoItem( + fun toStreamInfoItem(): StreamInfoItem = + StreamInfoItem( streamEntity.serviceId, streamEntity.url, streamEntity.title, - streamEntity.streamType - ) - item.duration = streamEntity.duration - item.uploaderName = streamEntity.uploader - item.uploaderUrl = streamEntity.uploaderUrl - item.thumbnails = ImageStrategy.dbUrlToImageList(streamEntity.thumbnailUrl) - - return item - } + streamEntity.streamType, + ).apply { + duration = streamEntity.duration + uploaderName = streamEntity.uploader + uploaderUrl = streamEntity.uploaderUrl + thumbnails = ImageStrategy.dbUrlToImageList(streamEntity.thumbnailUrl) + } } diff --git a/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistLocalItem.java b/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistLocalItem.java deleted file mode 100644 index a974a09d0da..00000000000 --- a/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistLocalItem.java +++ /dev/null @@ -1,15 +0,0 @@ -package org.schabi.newpipe.database.playlist; - -import org.schabi.newpipe.database.LocalItem; - -public interface PlaylistLocalItem extends LocalItem { - String getOrderingName(); - - long getDisplayIndex(); - - long getUid(); - - void setDisplayIndex(long displayIndex); - - String getThumbnailUrl(); -} diff --git a/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistLocalItem.kt b/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistLocalItem.kt new file mode 100644 index 00000000000..22d57572c24 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistLocalItem.kt @@ -0,0 +1,28 @@ +package org.schabi.newpipe.database.playlist + +import org.schabi.newpipe.database.LocalItem + +/** + * Represents a playlist item stored locally. + */ +interface PlaylistLocalItem : LocalItem { + /** + * The name used for ordering this item within the playlist. Can be null. + */ + val orderingName: String? + + /** + * The index used to display this item within the playlist. + */ + var displayIndex: Long + + /** + * The unique identifier for this playlist item. + */ + val uid: Long + + /** + * The URL of the thumbnail image for this playlist item. Can be null. + */ + val thumbnailUrl: String? +} diff --git a/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistStreamEntry.kt b/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistStreamEntry.kt index 1d74c6d31dc..1b40d223fa1 100644 --- a/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistStreamEntry.kt +++ b/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistStreamEntry.kt @@ -22,19 +22,20 @@ data class PlaylistStreamEntry( @ColumnInfo(name = PlaylistStreamEntity.JOIN_INDEX) val joinIndex: Int ) : LocalItem { - @Throws(IllegalArgumentException::class) - fun toStreamInfoItem(): StreamInfoItem { - val item = StreamInfoItem(streamEntity.serviceId, streamEntity.url, streamEntity.title, streamEntity.streamType) - item.duration = streamEntity.duration - item.uploaderName = streamEntity.uploader - item.uploaderUrl = streamEntity.uploaderUrl - item.thumbnails = ImageStrategy.dbUrlToImageList(streamEntity.thumbnailUrl) - - return item - } - - override fun getLocalItemType(): LocalItem.LocalItemType { - return LocalItem.LocalItemType.PLAYLIST_STREAM_ITEM - } + fun toStreamInfoItem() = + StreamInfoItem( + streamEntity.serviceId, + streamEntity.url, + streamEntity.title, + streamEntity.streamType, + ).apply { + duration = streamEntity.duration + uploaderName = streamEntity.uploader + uploaderUrl = streamEntity.uploaderUrl + thumbnails = ImageStrategy.dbUrlToImageList(streamEntity.thumbnailUrl) + } + + override val localItemType: LocalItem.LocalItemType + get() = LocalItem.LocalItemType.PLAYLIST_STREAM_ITEM } diff --git a/app/src/main/java/org/schabi/newpipe/database/stream/StreamStatisticsEntry.kt b/app/src/main/java/org/schabi/newpipe/database/stream/StreamStatisticsEntry.kt index 1f3654e7ae4..60c913d1190 100644 --- a/app/src/main/java/org/schabi/newpipe/database/stream/StreamStatisticsEntry.kt +++ b/app/src/main/java/org/schabi/newpipe/database/stream/StreamStatisticsEntry.kt @@ -26,19 +26,21 @@ class StreamStatisticsEntry( @ColumnInfo(name = STREAM_WATCH_COUNT) val watchCount: Long ) : LocalItem { - fun toStreamInfoItem(): StreamInfoItem { - val item = StreamInfoItem(streamEntity.serviceId, streamEntity.url, streamEntity.title, streamEntity.streamType) - item.duration = streamEntity.duration - item.uploaderName = streamEntity.uploader - item.uploaderUrl = streamEntity.uploaderUrl - item.thumbnails = ImageStrategy.dbUrlToImageList(streamEntity.thumbnailUrl) - - return item - } - - override fun getLocalItemType(): LocalItem.LocalItemType { - return LocalItem.LocalItemType.STATISTIC_STREAM_ITEM - } + fun toStreamInfoItem() = + StreamInfoItem( + streamEntity.serviceId, + streamEntity.url, + streamEntity.title, + streamEntity.streamType, + ).apply { + duration = streamEntity.duration + uploaderName = streamEntity.uploader + uploaderUrl = streamEntity.uploaderUrl + thumbnails = ImageStrategy.dbUrlToImageList(streamEntity.thumbnailUrl) + } + + override val localItemType: LocalItem.LocalItemType + get() = LocalItem.LocalItemType.STATISTIC_STREAM_ITEM companion object { const val STREAM_LATEST_DATE = "latestAccess" diff --git a/app/src/main/java/org/schabi/newpipe/player/Player.java b/app/src/main/java/org/schabi/newpipe/player/Player.java index 7f5710b7e9a..95692cbed6c 100644 --- a/app/src/main/java/org/schabi/newpipe/player/Player.java +++ b/app/src/main/java/org/schabi/newpipe/player/Player.java @@ -45,6 +45,7 @@ import static org.schabi.newpipe.util.ListHelper.getPopupResolutionIndex; import static org.schabi.newpipe.util.ListHelper.getResolutionIndex; import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage; +import static java.util.Objects.requireNonNull; import static java.util.concurrent.TimeUnit.MILLISECONDS; import android.content.BroadcastReceiver; @@ -417,7 +418,7 @@ public void handleIntent(@NonNull final Intent intent) { } if (playQueue.getIndex() != newQueue.getIndex()) { simpleExoPlayer.seekTo(newQueue.getIndex(), - newQueue.getItem().getRecoveryPosition()); + requireNonNull(newQueue.getItem()).getRecoveryPosition()); } simpleExoPlayer.setPlayWhenReady(playWhenReady); diff --git a/app/src/main/java/org/schabi/newpipe/player/PlayerService.java b/app/src/main/java/org/schabi/newpipe/player/PlayerService.java deleted file mode 100644 index e088290c921..00000000000 --- a/app/src/main/java/org/schabi/newpipe/player/PlayerService.java +++ /dev/null @@ -1,256 +0,0 @@ -/* - * Copyright 2017 Mauricio Colli - * Part of NewPipe - * - * License: GPL-3.0+ - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package org.schabi.newpipe.player; - -import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage; - -import android.content.Context; -import android.content.Intent; -import android.os.Binder; -import android.os.Bundle; -import android.os.IBinder; -import android.support.v4.media.MediaBrowserCompat.MediaItem; -import android.util.Log; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.media.MediaBrowserServiceCompat; - -import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector; - -import org.schabi.newpipe.player.mediabrowser.MediaBrowserConnector; -import org.schabi.newpipe.player.mediasession.MediaSessionPlayerUi; -import org.schabi.newpipe.player.notification.NotificationPlayerUi; -import org.schabi.newpipe.util.ThemeHelper; - -import java.lang.ref.WeakReference; - -import java.util.List; -import java.util.Objects; - -import io.reactivex.rxjava3.disposables.CompositeDisposable; -import io.reactivex.rxjava3.disposables.Disposable; - -/** - * One service for all players. - */ -public final class PlayerService extends MediaBrowserServiceCompat { - private static final String TAG = PlayerService.class.getSimpleName(); - private static final boolean DEBUG = Player.DEBUG; - - @Nullable - private Player player; - - private final IBinder mBinder = new PlayerService.LocalBinder(this); - - - private MediaBrowserConnector mediaBrowserConnector; - private final CompositeDisposable compositeDisposableLoadChildren = new CompositeDisposable(); - - - /*////////////////////////////////////////////////////////////////////////// - // Service's LifeCycle - //////////////////////////////////////////////////////////////////////////*/ - - @Override - public void onCreate() { - super.onCreate(); - - if (DEBUG) { - Log.d(TAG, "onCreate() called"); - } - assureCorrectAppLanguage(this); - ThemeHelper.setTheme(this); - - mediaBrowserConnector = new MediaBrowserConnector(this); - } - - private void initializePlayerIfNeeded() { - if (player == null) { - player = new Player(this); - /* - Create the player notification and start immediately the service in foreground, - otherwise if nothing is played or initializing the player and its components (especially - loading stream metadata) takes a lot of time, the app would crash on Android 8+ as the - service would never be put in the foreground while we said to the system we would do so - */ - player.UIs().get(NotificationPlayerUi.class) - .ifPresent(NotificationPlayerUi::createNotificationAndStartForeground); - } - } - - // Suppress Sonar warning to not always return the same value, as we need to do some actions - // before returning - @SuppressWarnings("squid:S3516") - @Override - public int onStartCommand(final Intent intent, final int flags, final int startId) { - if (DEBUG) { - Log.d(TAG, "onStartCommand() called with: intent = [" + intent - + "], flags = [" + flags + "], startId = [" + startId + "]"); - } - - /* - Be sure that the player notification is set and the service is started in foreground, - otherwise, the app may crash on Android 8+ as the service would never be put in the - foreground while we said to the system we would do so - The service is always requested to be started in foreground, so always creating a - notification if there is no one already and starting the service in foreground should - not create any issues - If the service is already started in foreground, requesting it to be started shouldn't - do anything - */ - if (player != null) { - player.UIs().get(NotificationPlayerUi.class) - .ifPresent(NotificationPlayerUi::createNotificationAndStartForeground); - } - - if (Intent.ACTION_MEDIA_BUTTON.equals(intent.getAction()) - && (player == null || player.getPlayQueue() == null)) { - /* - No need to process media button's actions if the player is not working, otherwise - the player service would strangely start with nothing to play - Stop the service in this case, which will be removed from the foreground and its - notification cancelled in its destruction - */ - stopSelf(); - return START_NOT_STICKY; - } - - initializePlayerIfNeeded(); - Objects.requireNonNull(player).handleIntent(intent); - player.UIs().get(MediaSessionPlayerUi.class) - .ifPresent(ui -> ui.handleMediaButtonIntent(intent)); - - return START_NOT_STICKY; - } - - public void stopForImmediateReusing() { - if (DEBUG) { - Log.d(TAG, "stopForImmediateReusing() called"); - } - - if (player != null && !player.exoPlayerIsNull()) { - // Releases wifi & cpu, disables keepScreenOn, etc. - // We can't just pause the player here because it will make transition - // from one stream to a new stream not smooth - player.smoothStopForImmediateReusing(); - } - } - - @Override - public void onTaskRemoved(final Intent rootIntent) { - super.onTaskRemoved(rootIntent); - if (player != null && !player.videoPlayerSelected()) { - return; - } - onDestroy(); - // Unload from memory completely - Runtime.getRuntime().halt(0); - } - - @Override - public void onDestroy() { - if (DEBUG) { - Log.d(TAG, "destroy() called"); - } - - cleanup(); - - if (mediaBrowserConnector != null) { - mediaBrowserConnector.release(); - mediaBrowserConnector = null; - } - - compositeDisposableLoadChildren.clear(); - } - - private void cleanup() { - if (player != null) { - player.destroy(); - player = null; - } - } - - public void stopService() { - cleanup(); - stopSelf(); - } - - @Override - protected void attachBaseContext(final Context base) { - super.attachBaseContext(AudioServiceLeakFix.preventLeakOf(base)); - } - - @Override - public IBinder onBind(@NonNull final Intent intent) { - if (SERVICE_INTERFACE.equals(intent.getAction())) { - return super.onBind(intent); - } - return mBinder; - } - - @NonNull - public MediaSessionConnector getSessionConnector() { - return mediaBrowserConnector.getSessionConnector(); - } - - // MediaBrowserServiceCompat methods - @Nullable - @Override - public BrowserRoot onGetRoot(@NonNull final String clientPackageName, - final int clientUid, - @Nullable final Bundle rootHints) { - return mediaBrowserConnector.onGetRoot(clientPackageName, clientUid, rootHints); - } - - @Override - public void onLoadChildren(@NonNull final String parentId, - @NonNull final Result> result) { - result.detach(); - final Disposable disposable = mediaBrowserConnector.onLoadChildren(parentId) - .subscribe(result::sendResult); - compositeDisposableLoadChildren.add(disposable); - } - - @Override - public void onSearch(@NonNull final String query, - final Bundle extras, - @NonNull final Result> result) { - mediaBrowserConnector.onSearch(query, result); - } - - public static final class LocalBinder extends Binder { - private final WeakReference playerService; - - LocalBinder(final PlayerService playerService) { - this.playerService = new WeakReference<>(playerService); - } - - @Nullable - public PlayerService getService() { - return playerService.get(); - } - - @Nullable - public Player getPlayer() { - return playerService.get().player; - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/PlayerService.kt b/app/src/main/java/org/schabi/newpipe/player/PlayerService.kt new file mode 100644 index 00000000000..db6ed64e574 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/PlayerService.kt @@ -0,0 +1,236 @@ +/* + * Copyright 2017 Mauricio Colli + * Part of NewPipe + * + * License: GPL-3.0+ + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.schabi.newpipe.player + +import android.content.Context +import android.content.Intent +import android.os.Binder +import android.os.Bundle +import android.os.IBinder +import android.support.v4.media.MediaBrowserCompat +import android.util.Log +import androidx.media.MediaBrowserServiceCompat +import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector +import io.reactivex.rxjava3.disposables.CompositeDisposable +import org.schabi.newpipe.player.mediabrowser.MediaBrowserConnector +import org.schabi.newpipe.player.mediasession.MediaSessionPlayerUi +import org.schabi.newpipe.player.notification.NotificationPlayerUi +import org.schabi.newpipe.util.Localization +import org.schabi.newpipe.util.ThemeHelper +import java.lang.ref.WeakReference + +/** + * One service for all players. + */ +class PlayerService : MediaBrowserServiceCompat() { + private val player: Player by lazy { + Player(this).apply { + /* + Create the player notification and start immediately the service in foreground, + otherwise if nothing is played or initializing the player and its components (especially + loading stream metadata) takes a lot of time, the app would crash on Android 8+ as the + service would never be put in the foreground while we said to the system we would do so + */ + UIs()[NotificationPlayerUi::class.java].ifPresent { + it.createNotificationAndStartForeground() + } + } + } + + private val mBinder: IBinder = LocalBinder(this) + private val compositeDisposableLoadChildren = CompositeDisposable() + private var mediaBrowserConnector: MediaBrowserConnector? = null + get() { + if (field == null) { + return MediaBrowserConnector(this) + } + return field + } + + val sessionConnector: MediaSessionConnector? + get() = mediaBrowserConnector?.sessionConnector + + /*////////////////////////////////////////////////////////////////////////// + // Service's LifeCycle + ////////////////////////////////////////////////////////////////////////// */ + override fun onCreate() { + super.onCreate() + + if (DEBUG) { + Log.d(TAG, "onCreate() called") + } + Localization.assureCorrectAppLanguage(this) + ThemeHelper.setTheme(this) + + /* + Create the player notification and start immediately the service in foreground, + otherwise if nothing is played or initializing the player and its components (especially + loading stream metadata) takes a lot of time, the app would crash on Android 8+ as the + service would never be put in the foreground while we said to the system we would do so + */ + player.UIs()[NotificationPlayerUi::class.java].ifPresent { + it.createNotificationAndStartForeground() + } + } + + // Suppress Sonar warning to not always return the same value, as we need to do some actions + // before returning + override fun onStartCommand( + intent: Intent, + flags: Int, + startId: Int, + ): Int { + if (DEBUG) { + Log.d( + TAG, + "onStartCommand() called with: intent = [" + intent + "], flags = [" + flags + + "], startId = [" + startId + "]", + ) + } + + /* + Be sure that the player notification is set and the service is started in foreground, + otherwise, the app may crash on Android 8+ as the service would never be put in the + foreground while we said to the system we would do so + The service is always requested to be started in foreground, so always creating a + notification if there is no one already and starting the service in foreground should + not create any issues + If the service is already started in foreground, requesting it to be started shouldn't + do anything + */ + player.UIs()[NotificationPlayerUi::class.java].ifPresent { + it.createNotificationAndStartForeground() + } + + if (Intent.ACTION_MEDIA_BUTTON == intent.action && (player.playQueue == null)) { + /* + No need to process media button's actions if the player is not working, otherwise + the player service would strangely start with nothing to play + Stop the service in this case, which will be removed from the foreground and its + notification cancelled in its destruction + */ + stopSelf() + return START_NOT_STICKY + } + + player.handleIntent(intent) + player.UIs()[MediaSessionPlayerUi::class.java].ifPresent { + it.handleMediaButtonIntent(intent) + } + + return START_NOT_STICKY + } + + fun stopForImmediateReusing() { + if (DEBUG) { + Log.d(TAG, "stopForImmediateReusing() called") + } + + if (!player.exoPlayerIsNull()) { + // Releases wifi & cpu, disables keepScreenOn, etc. + // We can't just pause the player here because it will make transition + // from one stream to a new stream not smooth + player.smoothStopForImmediateReusing() + } + } + + override fun onTaskRemoved(rootIntent: Intent) { + super.onTaskRemoved(rootIntent) + if (!player.videoPlayerSelected()) { + return + } + onDestroy() + // Unload from memory completely + Runtime.getRuntime().halt(0) + } + + override fun onDestroy() { + super.onDestroy() + if (DEBUG) { + Log.d(TAG, "destroy() called") + } + + cleanup() + + mediaBrowserConnector?.release() + mediaBrowserConnector = null + + compositeDisposableLoadChildren.clear() + } + + private fun cleanup() { + player.destroy() + } + + fun stopService() { + cleanup() + stopSelf() + } + + override fun attachBaseContext(base: Context) { + super.attachBaseContext(AudioServiceLeakFix.preventLeakOf(base)) + } + + override fun onBind(intent: Intent): IBinder = mBinder + + // MediaBrowserServiceCompat methods + override fun onGetRoot( + clientPackageName: String, + clientUid: Int, + rootHints: Bundle?, + ): BrowserRoot? = mediaBrowserConnector?.onGetRoot(clientPackageName, clientUid, rootHints) + + override fun onLoadChildren( + parentId: String, + result: Result>, + ) { + result.detach() + mediaBrowserConnector?.let { + val disposable = + it.onLoadChildren(parentId).subscribe { mediaItems -> + result.sendResult(mediaItems) + } + compositeDisposableLoadChildren.add(disposable) + } + } + + override fun onSearch( + query: String, + extras: Bundle, + result: Result>, + ) { + mediaBrowserConnector?.onSearch(query, result) + } + + class LocalBinder internal constructor( + playerService: PlayerService, + ) : Binder() { + private val playerService = WeakReference(playerService) + + val service: PlayerService? + get() = playerService.get() + + fun getPlayer(): Player = service?.player ?: throw Error("Player service is null") + } + + companion object { + private val TAG: String = PlayerService::class.java.simpleName + private val DEBUG = Player.DEBUG + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserConnector.java b/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserConnector.java deleted file mode 100644 index e7856db067f..00000000000 --- a/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserConnector.java +++ /dev/null @@ -1,730 +0,0 @@ -package org.schabi.newpipe.player.mediabrowser; - -import static org.schabi.newpipe.MainActivity.DEBUG; - -import android.content.ContentResolver; -import android.content.res.Resources; -import android.net.Uri; -import android.os.Bundle; -import android.os.ResultReceiver; -import android.support.v4.media.MediaBrowserCompat.MediaItem; -import android.support.v4.media.MediaDescriptionCompat; -import android.support.v4.media.session.MediaSessionCompat; -import android.support.v4.media.session.PlaybackStateCompat; -import android.util.Log; -import android.util.Pair; - -import androidx.annotation.DrawableRes; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.annotation.StringRes; -import androidx.media.MediaBrowserServiceCompat; -import androidx.media.utils.MediaConstants; - -import com.google.android.exoplayer2.Player; -import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector; - -import org.schabi.newpipe.BuildConfig; -import org.schabi.newpipe.NewPipeDatabase; -import org.schabi.newpipe.R; -import org.schabi.newpipe.database.AppDatabase; -import org.schabi.newpipe.database.history.dao.StreamHistoryDAO; -import org.schabi.newpipe.database.history.model.StreamHistoryEntry; -import org.schabi.newpipe.database.playlist.PlaylistLocalItem; -import org.schabi.newpipe.database.playlist.PlaylistStreamEntry; -import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity; -import org.schabi.newpipe.error.ErrorInfo; -import org.schabi.newpipe.error.UserAction; -import org.schabi.newpipe.extractor.InfoItem; -import org.schabi.newpipe.extractor.ListInfo; -import org.schabi.newpipe.extractor.channel.ChannelInfoItem; -import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException; -import org.schabi.newpipe.extractor.exceptions.ContentNotSupportedException; -import org.schabi.newpipe.extractor.linkhandler.ListLinkHandler; -import org.schabi.newpipe.extractor.playlist.PlaylistInfoItem; -import org.schabi.newpipe.extractor.stream.StreamInfoItem; -import org.schabi.newpipe.local.bookmark.MergedPlaylistManager; -import org.schabi.newpipe.extractor.search.SearchExtractor; -import org.schabi.newpipe.extractor.search.SearchInfo; -import org.schabi.newpipe.local.playlist.LocalPlaylistManager; -import org.schabi.newpipe.local.playlist.RemotePlaylistManager; -import org.schabi.newpipe.player.PlayerService; -import org.schabi.newpipe.player.playqueue.ChannelTabPlayQueue; -import org.schabi.newpipe.player.playqueue.PlayQueue; -import org.schabi.newpipe.player.playqueue.PlaylistPlayQueue; -import org.schabi.newpipe.player.playqueue.SinglePlayQueue; -import org.schabi.newpipe.util.ChannelTabHelper; -import org.schabi.newpipe.util.ExtractorHelper; -import org.schabi.newpipe.util.NavigationHelper; -import org.schabi.newpipe.util.ServiceHelper; - -import java.util.ArrayList; -import java.util.List; -import java.util.Optional; -import java.util.stream.Collectors; -import java.util.stream.IntStream; - -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; -import io.reactivex.rxjava3.core.Flowable; -import io.reactivex.rxjava3.core.Single; -import io.reactivex.rxjava3.disposables.Disposable; - -import io.reactivex.rxjava3.core.SingleSource; -import io.reactivex.rxjava3.schedulers.Schedulers; - -public class MediaBrowserConnector implements MediaSessionConnector.PlaybackPreparer { - - private static final String TAG = MediaBrowserConnector.class.getSimpleName(); - - @NonNull - private final PlayerService playerService; - @NonNull - private final MediaSessionConnector sessionConnector; - @NonNull - private final MediaSessionCompat mediaSession; - - private AppDatabase database; - private LocalPlaylistManager localPlaylistManager; - private RemotePlaylistManager remotePlaylistManager; - private Disposable prepareOrPlayDisposable; - private Disposable searchDisposable; - - public MediaBrowserConnector(@NonNull final PlayerService playerService) { - this.playerService = playerService; - mediaSession = new MediaSessionCompat(playerService, TAG); - sessionConnector = new MediaSessionConnector(mediaSession); - sessionConnector.setMetadataDeduplicationEnabled(true); - sessionConnector.setPlaybackPreparer(this); - playerService.setSessionToken(mediaSession.getSessionToken()); - - setupBookmarksNotifications(); - } - - @NonNull - public MediaSessionConnector getSessionConnector() { - return sessionConnector; - } - - public void release() { - disposePrepareOrPlayCommands(); - disposeBookmarksNotifications(); - mediaSession.release(); - } - - @NonNull - private static final String ID_AUTHORITY = BuildConfig.APPLICATION_ID; - @NonNull - private static final String ID_ROOT = "//" + ID_AUTHORITY; - @NonNull - private static final String ID_BOOKMARKS = "playlists"; - @NonNull - private static final String ID_HISTORY = "history"; - @NonNull - private static final String ID_INFO_ITEM = "item"; - - @NonNull - private static final String ID_LOCAL = "local"; - @NonNull - private static final String ID_REMOTE = "remote"; - @NonNull - private static final String ID_URL = "url"; - @NonNull - private static final String ID_STREAM = "stream"; - @NonNull - private static final String ID_PLAYLIST = "playlist"; - @NonNull - private static final String ID_CHANNEL = "channel"; - - @NonNull - private MediaItem createRootMediaItem(@Nullable final String mediaId, - final String folderName, - @DrawableRes final int iconResId) { - final var builder = new MediaDescriptionCompat.Builder(); - builder.setMediaId(mediaId); - builder.setTitle(folderName); - final Resources resources = playerService.getResources(); - builder.setIconUri(new Uri.Builder() - .scheme(ContentResolver.SCHEME_ANDROID_RESOURCE) - .authority(resources.getResourcePackageName(iconResId)) - .appendPath(resources.getResourceTypeName(iconResId)) - .appendPath(resources.getResourceEntryName(iconResId)) - .build()); - - final Bundle extras = new Bundle(); - extras.putString(MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE, - playerService.getString(R.string.app_name)); - builder.setExtras(extras); - return new MediaItem(builder.build(), MediaItem.FLAG_BROWSABLE); - } - - @NonNull - private MediaItem createPlaylistMediaItem(@NonNull final PlaylistLocalItem playlist) { - final var builder = new MediaDescriptionCompat.Builder(); - final boolean remote = playlist instanceof PlaylistRemoteEntity; - builder.setMediaId(createMediaIdForInfoItem(remote, playlist.getUid())) - .setTitle(playlist.getOrderingName()) - .setIconUri(Uri.parse(playlist.getThumbnailUrl())); - - final Bundle extras = new Bundle(); - extras.putString(MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE, - playerService.getResources().getString(R.string.tab_bookmarks)); - builder.setExtras(extras); - return new MediaItem(builder.build(), MediaItem.FLAG_BROWSABLE); - } - - private MediaItem createInfoItemMediaItem(@NonNull final InfoItem item) { - final var builder = new MediaDescriptionCompat.Builder(); - builder.setMediaId(createMediaIdForInfoItem(item)) - .setTitle(item.getName()); - - switch (item.getInfoType()) { - case STREAM: - builder.setSubtitle(((StreamInfoItem) item).getUploaderName()); - break; - case PLAYLIST: - builder.setSubtitle(((PlaylistInfoItem) item).getUploaderName()); - break; - case CHANNEL: - builder.setSubtitle(((ChannelInfoItem) item).getDescription()); - break; - default: - break; - } - final var thumbnails = item.getThumbnails(); - if (!thumbnails.isEmpty()) { - builder.setIconUri(Uri.parse(thumbnails.get(0).getUrl())); - } - return new MediaItem(builder.build(), MediaItem.FLAG_PLAYABLE); - } - - @NonNull - private Uri.Builder buildMediaId() { - return new Uri.Builder().authority(ID_AUTHORITY); - } - - @NonNull - private Uri.Builder buildPlaylistMediaId(final String playlistType) { - return buildMediaId() - .appendPath(ID_BOOKMARKS) - .appendPath(playlistType); - } - - @NonNull - private Uri.Builder buildLocalPlaylistItemMediaId(final boolean remote, final long playlistId) { - return buildPlaylistMediaId(remote ? ID_REMOTE : ID_LOCAL) - .appendPath(Long.toString(playlistId)); - } - - private static String infoItemTypeToString(final InfoItem.InfoType type) { - return switch (type) { - case STREAM -> ID_STREAM; - case PLAYLIST -> ID_PLAYLIST; - case CHANNEL -> ID_CHANNEL; - default -> - throw new IllegalStateException("Unexpected value: " + type); - }; - } - - private static InfoItem.InfoType infoItemTypeFromString(final String type) { - return switch (type) { - case ID_STREAM -> InfoItem.InfoType.STREAM; - case ID_PLAYLIST -> InfoItem.InfoType.PLAYLIST; - case ID_CHANNEL -> InfoItem.InfoType.CHANNEL; - default -> - throw new IllegalStateException("Unexpected value: " + type); - }; - } - - @NonNull - private Uri.Builder buildInfoItemMediaId(@NonNull final InfoItem item) { - return buildMediaId() - .appendPath(ID_INFO_ITEM) - .appendPath(infoItemTypeToString(item.getInfoType())) - .appendPath(Integer.toString(item.getServiceId())) - .appendQueryParameter(ID_URL, item.getUrl()); - } - - @NonNull - private String createMediaIdForInfoItem(final boolean remote, final long playlistId) { - return buildLocalPlaylistItemMediaId(remote, playlistId) - .build().toString(); - } - - @NonNull - private MediaItem createLocalPlaylistStreamMediaItem(final long playlistId, - @NonNull final PlaylistStreamEntry item, - final int index) { - final var builder = new MediaDescriptionCompat.Builder(); - builder.setMediaId(createMediaIdForPlaylistIndex(false, playlistId, index)) - .setTitle(item.getStreamEntity().getTitle()) - .setSubtitle(item.getStreamEntity().getUploader()) - .setIconUri(Uri.parse(item.getStreamEntity().getThumbnailUrl())); - - return new MediaItem(builder.build(), MediaItem.FLAG_PLAYABLE); - } - - @NonNull - private MediaItem createRemotePlaylistStreamMediaItem(final long playlistId, - @NonNull final StreamInfoItem item, - final int index) { - final var builder = new MediaDescriptionCompat.Builder(); - builder.setMediaId(createMediaIdForPlaylistIndex(true, playlistId, index)) - .setTitle(item.getName()) - .setSubtitle(item.getUploaderName()); - final var thumbnails = item.getThumbnails(); - if (!thumbnails.isEmpty()) { - builder.setIconUri(Uri.parse(thumbnails.get(0).getUrl())); - } - - return new MediaItem(builder.build(), MediaItem.FLAG_PLAYABLE); - } - - @NonNull - private String createMediaIdForPlaylistIndex(final boolean remote, final long playlistId, - final int index) { - return buildLocalPlaylistItemMediaId(remote, playlistId) - .appendPath(Integer.toString(index)) - .build().toString(); - } - - @NonNull - private String createMediaIdForInfoItem(@NonNull final InfoItem item) { - return buildInfoItemMediaId(item).build().toString(); - } - - @Nullable - public MediaBrowserServiceCompat.BrowserRoot onGetRoot(@NonNull final String clientPackageName, - final int clientUid, - @Nullable final Bundle rootHints) { - if (DEBUG) { - Log.d(TAG, String.format("MediaBrowserService.onGetRoot(%s, %s, %s)", - clientPackageName, clientUid, rootHints)); - } - - final Bundle extras = new Bundle(); - extras.putBoolean( - MediaConstants.BROWSER_SERVICE_EXTRAS_KEY_SEARCH_SUPPORTED, true); - return new MediaBrowserServiceCompat.BrowserRoot(ID_ROOT, extras); - } - - public Single> onLoadChildren(@NonNull final String parentId) { - if (DEBUG) { - Log.d(TAG, String.format("MediaBrowserService.onLoadChildren(%s)", parentId)); - } - - - try { - final Uri parentIdUri = Uri.parse(parentId); - final List path = new ArrayList<>(parentIdUri.getPathSegments()); - - if (path.isEmpty()) { - final List mediaItems = new ArrayList<>(); - mediaItems.add( - createRootMediaItem(ID_BOOKMARKS, - playerService.getResources().getString( - R.string.tab_bookmarks_short), - R.drawable.ic_bookmark_white)); - mediaItems.add( - createRootMediaItem(ID_HISTORY, - playerService.getResources().getString(R.string.action_history), - R.drawable.ic_history_white)); - return Single.just(mediaItems); - } - - final String uriType = path.get(0); - path.remove(0); - - switch (uriType) { - case ID_BOOKMARKS: - if (path.isEmpty()) { - return populateBookmarks(); - } - if (path.size() == 2) { - final String localOrRemote = path.get(0); - final long playlistId = Long.parseLong(path.get(1)); - if (localOrRemote.equals(ID_LOCAL)) { - return populateLocalPlaylist(playlistId); - } else if (localOrRemote.equals(ID_REMOTE)) { - return populateRemotePlaylist(playlistId); - } - } - Log.w(TAG, "Unknown playlist URI: " + parentId); - throw parseError(); - case ID_HISTORY: - return populateHistory(); - default: - throw parseError(); - } - } catch (final ContentNotAvailableException e) { - return Single.error(e); - } - } - - private Single> populateHistory() { - final StreamHistoryDAO streamHistory = getDatabase().streamHistoryDAO(); - final var history = streamHistory.getHistory().firstOrError(); - return history.map(items -> items.stream() - .map(this::createHistoryMediaItem) - .collect(Collectors.toList())); - } - - @NonNull - private MediaItem createHistoryMediaItem(@NonNull final StreamHistoryEntry streamHistoryEntry) { - final var builder = new MediaDescriptionCompat.Builder(); - final var mediaId = buildMediaId() - .appendPath(ID_HISTORY) - .appendPath(Long.toString(streamHistoryEntry.getStreamId())) - .build().toString(); - builder.setMediaId(mediaId) - .setTitle(streamHistoryEntry.getStreamEntity().getTitle()) - .setSubtitle(streamHistoryEntry.getStreamEntity().getUploader()) - .setIconUri(Uri.parse(streamHistoryEntry.getStreamEntity().getThumbnailUrl())); - - return new MediaItem(builder.build(), MediaItem.FLAG_PLAYABLE); - } - - private AppDatabase getDatabase() { - if (database == null) { - database = NewPipeDatabase.getInstance(playerService); - } - return database; - } - - private Flowable> getPlaylists() { - if (localPlaylistManager == null) { - localPlaylistManager = new LocalPlaylistManager(getDatabase()); - } - if (remotePlaylistManager == null) { - remotePlaylistManager = new RemotePlaylistManager(getDatabase()); - } - return MergedPlaylistManager.getMergedOrderedPlaylists(localPlaylistManager, - remotePlaylistManager); - } - - @Nullable Disposable bookmarksNotificationsDisposable; - - private void setupBookmarksNotifications() { - bookmarksNotificationsDisposable = getPlaylists().subscribe( - playlistMetadataEntries -> playerService.notifyChildrenChanged(ID_BOOKMARKS)); - } - - private void disposeBookmarksNotifications() { - if (bookmarksNotificationsDisposable != null) { - bookmarksNotificationsDisposable.dispose(); - bookmarksNotificationsDisposable = null; - } - } - - // Suppress Sonar warning replace list collection by Stream.toList call, as this method is only - // available in Android API 34 and not currently available with desugaring - @SuppressWarnings("squid:S6204") - private Single> populateBookmarks() { - final var playlists = getPlaylists().firstOrError(); - return playlists.map(playlist -> playlist.stream() - .map(this::createPlaylistMediaItem) - .collect(Collectors.toList())); - } - - private Single> populateLocalPlaylist(final long playlistId) { - final var playlist = localPlaylistManager.getPlaylistStreams(playlistId).firstOrError(); - return playlist.map(items -> { - final List results = new ArrayList<>(); - int index = 0; - for (final PlaylistStreamEntry item : items) { - results.add(createLocalPlaylistStreamMediaItem(playlistId, item, index)); - ++index; - } - return results; - }); - } - - private Single>> getRemotePlaylist(final long playlistId) { - final var playlistFlow = remotePlaylistManager.getPlaylist(playlistId).firstOrError(); - return playlistFlow.flatMap(item -> { - final var playlist = item.get(0); - final var playlistInfo = ExtractorHelper.getPlaylistInfo(playlist.getServiceId(), - playlist.getUrl(), false); - return playlistInfo.flatMap(info -> { - final var infoItemsPage = info.getRelatedItems(); - - if (!info.getErrors().isEmpty()) { - final List errors = new ArrayList<>(info.getErrors()); - - errors.removeIf(ContentNotSupportedException.class::isInstance); - - if (!errors.isEmpty()) { - return Single.error(errors.get(0)); - } - } - - return Single.just(IntStream.range(0, infoItemsPage.size()) - .mapToObj(i -> Pair.create(infoItemsPage.get(i), i)) - .toList()); - }); - }); - } - - private Single> populateRemotePlaylist(final long playlistId) { - return getRemotePlaylist(playlistId).map(items -> - items.stream().map(pair -> - createRemotePlaylistStreamMediaItem(playlistId, pair.first, pair.second) - ).toList() - ); - } - - private void playbackError(@StringRes final int resId, final int code) { - playerService.stopForImmediateReusing(); - sessionConnector.setCustomErrorMessage(playerService.getString(resId), code); - } - - private void playbackError(@NonNull final ErrorInfo errorInfo) { - playbackError(errorInfo.getMessageStringId(), PlaybackStateCompat.ERROR_CODE_APP_ERROR); - } - - private Single extractLocalPlayQueue(final long playlistId, final int index) { - return localPlaylistManager.getPlaylistStreams(playlistId) - .firstOrError() - .map(items -> { - final List infoItems = items.stream() - .map(PlaylistStreamEntry::toStreamInfoItem) - .collect(Collectors.toList()); - return new SinglePlayQueue(infoItems, index); - }); - } - - private Single extractRemotePlayQueue(final long playlistId, final int index) { - return getRemotePlaylist(playlistId).map(items -> { - final var infoItems = items.stream().map(pair -> pair.first).toList(); - return new SinglePlayQueue(infoItems, index); - }); - } - - private static ContentNotAvailableException parseError() { - return new ContentNotAvailableException("Failed to parse media ID"); - } - - private Single extractPlayQueueFromMediaId(final String mediaId) { - try { - final Uri mediaIdUri = Uri.parse(mediaId); - final List path = new ArrayList<>(mediaIdUri.getPathSegments()); - - if (path.isEmpty()) { - throw parseError(); - } - - final String uriType = path.get(0); - path.remove(0); - - return switch (uriType) { - case ID_BOOKMARKS -> extractPlayQueueFromPlaylistMediaId(path, - mediaIdUri.getQueryParameter(ID_URL)); - case ID_HISTORY -> extractPlayQueueFromHistoryMediaId(path); - case ID_INFO_ITEM -> extractPlayQueueFromInfoItemMediaId(path, - mediaIdUri.getQueryParameter(ID_URL)); - default -> throw parseError(); - }; - } catch (final ContentNotAvailableException e) { - return Single.error(e); - } - } - - private Single - extractPlayQueueFromPlaylistMediaId( - @NonNull final List path, - @Nullable final String url) throws ContentNotAvailableException { - if (path.isEmpty()) { - throw parseError(); - } - - final String playlistType = path.get(0); - path.remove(0); - - switch (playlistType) { - case ID_LOCAL, ID_REMOTE: - if (path.size() != 2) { - throw parseError(); - } - final long playlistId = Long.parseLong(path.get(0)); - final int index = Integer.parseInt(path.get(1)); - return playlistType.equals(ID_LOCAL) - ? extractLocalPlayQueue(playlistId, index) - : extractRemotePlayQueue(playlistId, index); - case ID_URL: - if (path.size() != 1) { - throw parseError(); - } - - final int serviceId = Integer.parseInt(path.get(0)); - return ExtractorHelper.getPlaylistInfo(serviceId, url, false) - .map(PlaylistPlayQueue::new); - default: - throw parseError(); - } - } - - private Single extractPlayQueueFromHistoryMediaId( - final List path) throws ContentNotAvailableException { - if (path.size() != 1) { - throw parseError(); - } - - final long streamId = Long.parseLong(path.get(0)); - return getDatabase().streamHistoryDAO().getHistory() - .firstOrError() - .map(items -> { - final List infoItems = items.stream() - .filter(it -> it.getStreamId() == streamId) - .map(StreamHistoryEntry::toStreamInfoItem) - .collect(Collectors.toList()); - return new SinglePlayQueue(infoItems, 0); - }); - } - - private static Single extractPlayQueueFromInfoItemMediaId( - final List path, final String url) throws ContentNotAvailableException { - if (path.size() != 2) { - throw parseError(); - } - final var infoItemType = infoItemTypeFromString(path.get(0)); - final int serviceId = Integer.parseInt(path.get(1)); - return switch (infoItemType) { - case STREAM -> ExtractorHelper.getStreamInfo(serviceId, url, false) - .map(SinglePlayQueue::new); - case PLAYLIST -> ExtractorHelper.getPlaylistInfo(serviceId, url, false) - .map(PlaylistPlayQueue::new); - case CHANNEL -> ExtractorHelper.getChannelInfo(serviceId, url, false) - .map(info -> { - final Optional playableTab = info.getTabs() - .stream() - .filter(ChannelTabHelper::isStreamsTab) - .findFirst(); - - if (playableTab.isPresent()) { - return new ChannelTabPlayQueue(serviceId, - new ListLinkHandler(playableTab.get())); - } else { - throw new ContentNotAvailableException("No streams tab found"); - } - }); - default -> throw parseError(); - }; - } - - @Override - public long getSupportedPrepareActions() { - return PlaybackStateCompat.ACTION_PLAY_FROM_MEDIA_ID; - } - - private void disposePrepareOrPlayCommands() { - if (prepareOrPlayDisposable != null) { - prepareOrPlayDisposable.dispose(); - prepareOrPlayDisposable = null; - } - } - - @Override - public void onPrepare(final boolean playWhenReady) { - disposePrepareOrPlayCommands(); - // No need to prepare - } - - @Override - public void onPrepareFromMediaId(@NonNull final String mediaId, - final boolean playWhenReady, - @Nullable final Bundle extras) { - if (DEBUG) { - Log.d(TAG, String.format("MediaBrowserConnector.onPrepareFromMediaId(%s, %s, %s)", - mediaId, playWhenReady, extras)); - } - - disposePrepareOrPlayCommands(); - prepareOrPlayDisposable = extractPlayQueueFromMediaId(mediaId) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe( - playQueue -> { - sessionConnector.setCustomErrorMessage(null); - NavigationHelper.playOnBackgroundPlayer(playerService, playQueue, - playWhenReady); - }, - throwable -> playbackError(new ErrorInfo(throwable, UserAction.PLAY_STREAM, - "Failed playback of media ID [" + mediaId + "]: ")) - ); - } - - @Override - public void onPrepareFromSearch(@NonNull final String query, - final boolean playWhenReady, - @Nullable final Bundle extras) { - disposePrepareOrPlayCommands(); - playbackError(R.string.content_not_supported, - PlaybackStateCompat.ERROR_CODE_NOT_SUPPORTED); - } - - private @NonNull Single searchMusicBySongTitle(final String query) { - final var serviceId = ServiceHelper.getSelectedServiceId(playerService); - return ExtractorHelper.searchFor(serviceId, query, - new ArrayList<>(), ""); - } - - private @NonNull SingleSource> - mediaItemsFromInfoItemList(final ListInfo result) { - final List exceptions = result.getErrors(); - if (!exceptions.isEmpty() - && !(exceptions.size() == 1 - && exceptions.get(0) instanceof SearchExtractor.NothingFoundException)) { - return Single.error(exceptions.get(0)); - } - - final List items = result.getRelatedItems(); - if (items.isEmpty()) { - return Single.error(new NullPointerException("Got no search results.")); - } - try { - final List results = items.stream() - .filter(item -> - item.getInfoType() == InfoItem.InfoType.STREAM - || item.getInfoType() == InfoItem.InfoType.PLAYLIST - || item.getInfoType() == InfoItem.InfoType.CHANNEL) - .map(this::createInfoItemMediaItem).toList(); - return Single.just(results); - } catch (final Exception e) { - return Single.error(e); - } - } - - private void handleSearchError(final Throwable throwable) { - Log.e(TAG, "Search error: " + throwable); - disposePrepareOrPlayCommands(); - playbackError(R.string.content_not_supported, PlaybackStateCompat.ERROR_CODE_NOT_SUPPORTED); - } - - @Override - public void onPrepareFromUri(@NonNull final Uri uri, - final boolean playWhenReady, - @Nullable final Bundle extras) { - disposePrepareOrPlayCommands(); - playbackError(R.string.content_not_supported, PlaybackStateCompat.ERROR_CODE_NOT_SUPPORTED); - } - - @Override - public boolean onCommand(@NonNull final Player player, - @NonNull final String command, - @Nullable final Bundle extras, - @Nullable final ResultReceiver cb) { - return false; - } - - public void onSearch(@NonNull final String query, - @NonNull final MediaBrowserServiceCompat.Result> result) { - result.detach(); - if (searchDisposable != null) { - searchDisposable.dispose(); - } - searchDisposable = searchMusicBySongTitle(query) - .flatMap(this::mediaItemsFromInfoItemList) - .subscribeOn(Schedulers.io()) - .subscribe(result::sendResult, - this::handleSearchError); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserConnector.kt b/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserConnector.kt new file mode 100644 index 00000000000..f08d8985556 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserConnector.kt @@ -0,0 +1,735 @@ +package org.schabi.newpipe.player.mediabrowser + +import android.content.ContentResolver +import android.net.Uri +import android.os.Bundle +import android.os.ResultReceiver +import android.support.v4.media.MediaBrowserCompat +import android.support.v4.media.MediaDescriptionCompat +import android.support.v4.media.session.MediaSessionCompat +import android.support.v4.media.session.PlaybackStateCompat +import android.util.Log +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.media.MediaBrowserServiceCompat +import androidx.media.utils.MediaConstants +import com.google.android.exoplayer2.Player +import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector +import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector.PlaybackPreparer +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.core.Flowable +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.core.SingleSource +import io.reactivex.rxjava3.disposables.Disposable +import io.reactivex.rxjava3.schedulers.Schedulers +import org.schabi.newpipe.BuildConfig +import org.schabi.newpipe.MainActivity +import org.schabi.newpipe.NewPipeDatabase +import org.schabi.newpipe.R +import org.schabi.newpipe.database.AppDatabase +import org.schabi.newpipe.database.history.model.StreamHistoryEntry +import org.schabi.newpipe.database.playlist.PlaylistLocalItem +import org.schabi.newpipe.database.playlist.PlaylistStreamEntry +import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity +import org.schabi.newpipe.error.ErrorInfo +import org.schabi.newpipe.error.UserAction +import org.schabi.newpipe.extractor.InfoItem +import org.schabi.newpipe.extractor.InfoItem.InfoType +import org.schabi.newpipe.extractor.ListInfo +import org.schabi.newpipe.extractor.channel.ChannelInfoItem +import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException +import org.schabi.newpipe.extractor.exceptions.ContentNotSupportedException +import org.schabi.newpipe.extractor.linkhandler.ListLinkHandler +import org.schabi.newpipe.extractor.playlist.PlaylistInfo +import org.schabi.newpipe.extractor.playlist.PlaylistInfoItem +import org.schabi.newpipe.extractor.search.SearchExtractor.NothingFoundException +import org.schabi.newpipe.extractor.search.SearchInfo +import org.schabi.newpipe.extractor.stream.StreamInfoItem +import org.schabi.newpipe.local.bookmark.MergedPlaylistManager +import org.schabi.newpipe.local.playlist.LocalPlaylistManager +import org.schabi.newpipe.local.playlist.RemotePlaylistManager +import org.schabi.newpipe.player.PlayerService +import org.schabi.newpipe.player.playqueue.ChannelTabPlayQueue +import org.schabi.newpipe.player.playqueue.PlayQueue +import org.schabi.newpipe.player.playqueue.PlaylistPlayQueue +import org.schabi.newpipe.player.playqueue.SinglePlayQueue +import org.schabi.newpipe.util.ChannelTabHelper +import org.schabi.newpipe.util.ExtractorHelper +import org.schabi.newpipe.util.NavigationHelper +import org.schabi.newpipe.util.ServiceHelper +import java.util.stream.Collectors + +class MediaBrowserConnector(private val playerService: PlayerService) : PlaybackPreparer { + private val mediaSession = MediaSessionCompat(playerService, TAG) + val sessionConnector = MediaSessionConnector(mediaSession).apply { + setMetadataDeduplicationEnabled(true) + setPlaybackPreparer(this@MediaBrowserConnector) + } + + private val database: AppDatabase by lazy { NewPipeDatabase.getInstance(playerService) } + private val localPlaylistManager: LocalPlaylistManager by lazy { LocalPlaylistManager(database) } + private val remotePlaylistManager: RemotePlaylistManager by lazy { + RemotePlaylistManager(database) + } + private val playlists: Flowable> + get() { + return MergedPlaylistManager.getMergedOrderedPlaylists(localPlaylistManager, remotePlaylistManager) + } + + private var prepareOrPlayDisposable: Disposable? = null + private var searchDisposable: Disposable? = null + private var bookmarksNotificationsDisposable: Disposable? = null + + init { + playerService.sessionToken = mediaSession.sessionToken + setupBookmarksNotifications() + } + + fun release() { + disposePrepareOrPlayCommands() + disposeBookmarksNotifications() + mediaSession.release() + } + + private fun createRootMediaItem( + mediaId: String?, + folderName: String, + @DrawableRes iconResId: Int + ): MediaBrowserCompat.MediaItem { + val builder = MediaDescriptionCompat.Builder() + builder.setMediaId(mediaId) + builder.setTitle(folderName) + val resources = playerService.resources + builder.setIconUri( + Uri.Builder() + .scheme(ContentResolver.SCHEME_ANDROID_RESOURCE) + .authority(resources.getResourcePackageName(iconResId)) + .appendPath(resources.getResourceTypeName(iconResId)) + .appendPath(resources.getResourceEntryName(iconResId)) + .build() + ) + + val extras = Bundle() + extras.putString( + MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE, + playerService.getString(R.string.app_name) + ) + builder.setExtras(extras) + return MediaBrowserCompat.MediaItem( + builder.build(), + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ) + } + + private fun createPlaylistMediaItem(playlist: PlaylistLocalItem): MediaBrowserCompat.MediaItem { + val builder = MediaDescriptionCompat.Builder() + val remote = playlist is PlaylistRemoteEntity + builder.setMediaId(createMediaIdForInfoItem(remote, playlist.uid)) + .setTitle(playlist.orderingName) + .setIconUri(Uri.parse(playlist.thumbnailUrl)) + + val extras = Bundle() + extras.putString( + MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE, + playerService.resources.getString(R.string.tab_bookmarks) + ) + builder.setExtras(extras) + return MediaBrowserCompat.MediaItem( + builder.build(), + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ) + } + + private fun createInfoItemMediaItem(item: InfoItem): MediaBrowserCompat.MediaItem { + val builder = MediaDescriptionCompat.Builder() + builder.setMediaId(createMediaIdForInfoItem(item)) + .setTitle(item.name) + + when (item.infoType) { + InfoType.STREAM -> builder.setSubtitle((item as StreamInfoItem).uploaderName) + InfoType.PLAYLIST -> builder.setSubtitle((item as PlaylistInfoItem).uploaderName) + InfoType.CHANNEL -> builder.setSubtitle((item as ChannelInfoItem).description) + else -> {} + } + item.thumbnails.firstOrNull()?.let { + builder.setIconUri(Uri.parse(it.url)) + } + return MediaBrowserCompat.MediaItem( + builder.build(), + MediaBrowserCompat.MediaItem.FLAG_PLAYABLE + ) + } + + private fun buildMediaId() = Uri.Builder().authority(ID_AUTHORITY) + + private fun buildPlaylistMediaId(playlistType: String) = + buildMediaId() + .appendPath(ID_BOOKMARKS) + .appendPath(playlistType) + + private fun buildLocalPlaylistItemMediaId( + remote: Boolean, + playlistId: Long, + ) = buildPlaylistMediaId(if (remote) ID_REMOTE else ID_LOCAL) + .appendPath(playlistId.toString()) + + private fun buildInfoItemMediaId(item: InfoItem) = + buildMediaId() + .appendPath(ID_INFO_ITEM) + .appendPath(infoItemTypeToString(item.infoType)) + .appendPath(item.serviceId.toString()) + .appendQueryParameter(ID_URL, item.url) + + private fun createMediaIdForInfoItem( + remote: Boolean, + playlistId: Long, + ) = buildLocalPlaylistItemMediaId(remote, playlistId) + .build() + .toString() + + private fun createLocalPlaylistStreamMediaItem( + playlistId: Long, + item: PlaylistStreamEntry, + index: Int + ): MediaBrowserCompat.MediaItem { + val builder = MediaDescriptionCompat.Builder() + builder.setMediaId(createMediaIdForPlaylistIndex(false, playlistId, index)) + .setTitle(item.streamEntity.title) + .setSubtitle(item.streamEntity.uploader) + .setIconUri(Uri.parse(item.streamEntity.thumbnailUrl)) + + return MediaBrowserCompat.MediaItem( + builder.build(), + MediaBrowserCompat.MediaItem.FLAG_PLAYABLE + ) + } + + private fun createRemotePlaylistStreamMediaItem( + playlistId: Long, + item: StreamInfoItem, + index: Int + ): MediaBrowserCompat.MediaItem { + val builder = MediaDescriptionCompat.Builder() + builder.setMediaId(createMediaIdForPlaylistIndex(true, playlistId, index)) + .setTitle(item.name) + .setSubtitle(item.uploaderName) + item.thumbnails.firstOrNull()?.let { + builder.setIconUri(Uri.parse(it.url)) + } + + return MediaBrowserCompat.MediaItem( + builder.build(), + MediaBrowserCompat.MediaItem.FLAG_PLAYABLE + ) + } + + private fun createMediaIdForPlaylistIndex( + remote: Boolean, + playlistId: Long, + index: Int, + ) = buildLocalPlaylistItemMediaId(remote, playlistId) + .appendPath(index.toString()) + .build() + .toString() + + private fun createMediaIdForInfoItem(item: InfoItem) = buildInfoItemMediaId(item).build().toString() + + fun onGetRoot( + clientPackageName: String, + clientUid: Int, + rootHints: Bundle? + ): MediaBrowserServiceCompat.BrowserRoot { + if (MainActivity.DEBUG) { + Log.d( + TAG, + String.format( + "MediaBrowserService.onGetRoot(%s, %s, %s)", + clientPackageName, clientUid, rootHints + ) + ) + } + + val extras = Bundle() + extras.putBoolean( + MediaConstants.BROWSER_SERVICE_EXTRAS_KEY_SEARCH_SUPPORTED, true + ) + return MediaBrowserServiceCompat.BrowserRoot(ID_ROOT, extras) + } + + fun onLoadChildren(parentId: String): Single> { + if (MainActivity.DEBUG) { + Log.d(TAG, String.format("MediaBrowserService.onLoadChildren(%s)", parentId)) + } + + try { + val parentIdUri = Uri.parse(parentId) + val path = parentIdUri.pathSegments + + if (path.isEmpty()) { + val mediaItems: MutableList = ArrayList() + mediaItems.add( + createRootMediaItem( + ID_BOOKMARKS, + playerService.resources.getString( + R.string.tab_bookmarks_short + ), + R.drawable.ic_bookmark_white + ) + ) + mediaItems.add( + createRootMediaItem( + ID_HISTORY, + playerService.resources.getString(R.string.action_history), + R.drawable.ic_history_white + ) + ) + return Single.just(mediaItems) + } + + val uriType = path[0] + path.removeAt(0) + + when (uriType) { + ID_BOOKMARKS -> { + if (path.isEmpty()) { + return populateBookmarks() + } + if (path.size == 2) { + val localOrRemote = path[0] + val playlistId = path[1].toLong() + if (localOrRemote == ID_LOCAL) { + return populateLocalPlaylist(playlistId) + } else if (localOrRemote == ID_REMOTE) { + return populateRemotePlaylist(playlistId) + } + } + Log.w(TAG, "Unknown playlist URI: $parentId") + throw parseError() + } + + ID_HISTORY -> return populateHistory() + else -> throw parseError() + } + } catch (e: ContentNotAvailableException) { + return Single.error(e) + } + } + + private fun populateHistory(): Single> = + database + .streamHistoryDAO() + .history + .firstOrError() + .map { items -> items.map(::createHistoryMediaItem) } + + private fun createHistoryMediaItem(streamHistoryEntry: StreamHistoryEntry): MediaBrowserCompat.MediaItem { + val builder = MediaDescriptionCompat.Builder() + val mediaId = buildMediaId() + .appendPath(ID_HISTORY) + .appendPath(streamHistoryEntry.streamId.toString()) + .build().toString() + builder.setMediaId(mediaId) + .setTitle(streamHistoryEntry.streamEntity.title) + .setSubtitle(streamHistoryEntry.streamEntity.uploader) + .setIconUri(Uri.parse(streamHistoryEntry.streamEntity.thumbnailUrl)) + + return MediaBrowserCompat.MediaItem( + builder.build(), + MediaBrowserCompat.MediaItem.FLAG_PLAYABLE + ) + } + + private fun setupBookmarksNotifications() { + bookmarksNotificationsDisposable = + playlists.subscribe { _ -> + playerService.notifyChildrenChanged(ID_BOOKMARKS) + } + } + + private fun disposeBookmarksNotifications() { + bookmarksNotificationsDisposable?.dispose() + } + + // Suppress Sonar warning replace list collection by Stream.toList call, as this method is only + // available in Android API 34 and not currently available with desugaring + private fun populateBookmarks() = + playlists.firstOrError().map { playlist -> + playlist.filterNotNull().map { createPlaylistMediaItem(it) } + } + + private fun populateLocalPlaylist(playlistId: Long): Single> = + localPlaylistManager + .getPlaylistStreams(playlistId) + .firstOrError() + .map { items: List -> + val results: MutableList = ArrayList() + for ((index, item) in items.withIndex()) { + results.add(createLocalPlaylistStreamMediaItem(playlistId, item, index)) + } + results + } + + private fun getRemotePlaylist(playlistId: Long): Single>> = + remotePlaylistManager + .getPlaylist(playlistId) + .firstOrError() + .map { playlistEntities -> + val playlist = playlistEntities[0] + ExtractorHelper + .getPlaylistInfo(playlist.serviceId, playlist.url, false) + .map { info -> + handlePlaylistInfoErrors(info) + info.relatedItems.withIndex().map { (index, item) -> item to index } + } + }.flatMap { it } + + private fun handlePlaylistInfoErrors(info: PlaylistInfo) { + val nonContentNotSupportedErrors = info.errors.filterNot { it is ContentNotSupportedException } + if (nonContentNotSupportedErrors.isNotEmpty()) { + throw nonContentNotSupportedErrors.first() + } + } + + private fun populateRemotePlaylist(playlistId: Long): Single> = + getRemotePlaylist(playlistId).map { items -> + items.map { pair -> + createRemotePlaylistStreamMediaItem( + playlistId, + pair.first, + pair.second, + ) + } + } + + private fun playbackError(@StringRes resId: Int, code: Int) { + playerService.stopForImmediateReusing() + sessionConnector.setCustomErrorMessage(playerService.getString(resId), code) + } + + private fun playbackError(errorInfo: ErrorInfo) { + playbackError(errorInfo.messageStringId, PlaybackStateCompat.ERROR_CODE_APP_ERROR) + } + + private fun extractLocalPlayQueue(playlistId: Long, index: Int): Single { + return localPlaylistManager.getPlaylistStreams(playlistId) + .firstOrError() + .map { items: List -> + val infoItems = items.stream() + .map { obj: PlaylistStreamEntry -> obj.toStreamInfoItem() } + .collect(Collectors.toList()) + SinglePlayQueue(infoItems, index) + } + } + + private fun extractRemotePlayQueue(playlistId: Long, index: Int): Single { + return getRemotePlaylist(playlistId).map { items -> + val infoItems = items.map { (item, _) -> item } + SinglePlayQueue(infoItems, index) + } + } + + private fun extractPlayQueueFromMediaId(mediaId: String): Single { + try { + val mediaIdUri = Uri.parse(mediaId) + val path = mediaIdUri.pathSegments + + if (path.isEmpty()) { + throw parseError() + } + + val uriType = path[0] + path.removeAt(0) + + return when (uriType) { + ID_BOOKMARKS -> extractPlayQueueFromPlaylistMediaId( + path, + mediaIdUri.getQueryParameter(ID_URL) + ) + + ID_HISTORY -> extractPlayQueueFromHistoryMediaId(path) + ID_INFO_ITEM -> extractPlayQueueFromInfoItemMediaId( + path, + mediaIdUri.getQueryParameter(ID_URL) + ) + + else -> throw parseError() + } + } catch (error: ContentNotAvailableException) { + return Single.error(error) + } + } + + @Throws(ContentNotAvailableException::class) + private fun extractPlayQueueFromPlaylistMediaId( + mediaIdSegments: List, + url: String?, + ): Single { + if (mediaIdSegments.isEmpty()) { + throw parseError() + } + + when (val playlistType = mediaIdSegments.first()) { + ID_LOCAL, ID_REMOTE -> { + if (mediaIdSegments.size != 2) { + throw parseError() + } + val playlistId = mediaIdSegments[0].toLong() + val index = mediaIdSegments[1].toInt() + return if (playlistType == ID_LOCAL) { + extractLocalPlayQueue(playlistId, index) + } else { + extractRemotePlayQueue(playlistId, index) + } + } + + ID_URL -> { + if (mediaIdSegments.size != 1) { + throw parseError() + } + + val serviceId = mediaIdSegments[0].toInt() + return ExtractorHelper + .getPlaylistInfo(serviceId, url, false) + .map(::PlaylistPlayQueue) + } + + else -> throw parseError() + } + } + + @Throws(ContentNotAvailableException::class) + private fun extractPlayQueueFromHistoryMediaId( + path: List + ): Single { + if (path.size != 1) { + throw parseError() + } + + val streamId = path[0].toLong() + return database + .streamHistoryDAO() + .history + .firstOrError() + .map { items -> + val infoItems = + items + .filter { streamHistoryEntry -> streamHistoryEntry.streamId == streamId } + .map { streamHistoryEntry -> streamHistoryEntry.toStreamInfoItem() } + SinglePlayQueue(infoItems, 0) + } + } + + override fun getSupportedPrepareActions() = PlaybackStateCompat.ACTION_PLAY_FROM_MEDIA_ID + + private fun disposePrepareOrPlayCommands() { + prepareOrPlayDisposable?.dispose() + } + + override fun onPrepare(playWhenReady: Boolean) { + disposePrepareOrPlayCommands() + // No need to prepare + } + + override fun onPrepareFromMediaId( + mediaId: String, + playWhenReady: Boolean, + extras: Bundle? + ) { + if (MainActivity.DEBUG) { + Log.d( + TAG, + String.format( + "MediaBrowserConnector.onPrepareFromMediaId(%s, %s, %s)", + mediaId, playWhenReady, extras + ) + ) + } + + disposePrepareOrPlayCommands() + prepareOrPlayDisposable = extractPlayQueueFromMediaId(mediaId) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { playQueue -> + sessionConnector.setCustomErrorMessage(null) + NavigationHelper.playOnBackgroundPlayer( + playerService, playQueue, + playWhenReady + ) + }, + { throwable -> + playbackError( + ErrorInfo( + throwable, UserAction.PLAY_STREAM, + "Failed playback of media ID [$mediaId]: " + ) + ) + } + ) + } + + override fun onPrepareFromSearch( + query: String, + playWhenReady: Boolean, + extras: Bundle? + ) { + disposePrepareOrPlayCommands() + playbackError( + R.string.content_not_supported, + PlaybackStateCompat.ERROR_CODE_NOT_SUPPORTED + ) + } + + private fun searchMusicBySongTitle(query: String): Single { + val serviceId = ServiceHelper.getSelectedServiceId(playerService) + return ExtractorHelper.searchFor( + serviceId, query, + ArrayList(), "" + ) + } + + private fun mediaItemsFromInfoItemList(result: ListInfo): SingleSource> { + result.errors + .takeIf { exceptions -> + exceptions.isNotEmpty() && + !( + exceptions.size == 1 && + exceptions.first() is NothingFoundException + ) + }?.let { exceptions -> + return Single.error(exceptions.first()) + } + + val items = result.relatedItems + if (items.isEmpty()) { + return Single.error(NullPointerException("Got no search results.")) + } + try { + val results = + items + .filter { item -> + item.infoType == InfoType.STREAM || + item.infoType == InfoType.PLAYLIST || + item.infoType == InfoType.CHANNEL + }.map { item -> this.createInfoItemMediaItem(item) } + + return Single.just(results) + } catch (error: Exception) { + return Single.error(error) + } + } + + private fun handleSearchError(throwable: Throwable) { + Log.e(TAG, "Search error: $throwable") + disposePrepareOrPlayCommands() + playbackError(R.string.content_not_supported, PlaybackStateCompat.ERROR_CODE_NOT_SUPPORTED) + } + + override fun onPrepareFromUri( + uri: Uri, + playWhenReady: Boolean, + extras: Bundle? + ) { + disposePrepareOrPlayCommands() + playbackError(R.string.content_not_supported, PlaybackStateCompat.ERROR_CODE_NOT_SUPPORTED) + } + + override fun onCommand( + player: Player, + command: String, + extras: Bundle?, + cb: ResultReceiver?, + ) = false + + fun onSearch( + query: String, + result: MediaBrowserServiceCompat.Result>, + ) { + result.detach() + searchDisposable?.dispose() + searchDisposable = + searchMusicBySongTitle(query) + .flatMap(::mediaItemsFromInfoItemList) + .subscribeOn(Schedulers.io()) + .subscribe( + { mediaItemsResult -> result.sendResult(mediaItemsResult) }, + { throwable -> this.handleSearchError(throwable) }, + ) + } + + companion object { + private val TAG: String = MediaBrowserConnector::class.java.simpleName + + private const val ID_AUTHORITY = BuildConfig.APPLICATION_ID + private const val ID_ROOT = "//$ID_AUTHORITY" + private const val ID_BOOKMARKS = "playlists" + private const val ID_HISTORY = "history" + private const val ID_INFO_ITEM = "item" + + private const val ID_LOCAL = "local" + private const val ID_REMOTE = "remote" + private const val ID_URL = "url" + private const val ID_STREAM = "stream" + private const val ID_PLAYLIST = "playlist" + private const val ID_CHANNEL = "channel" + + private fun infoItemTypeToString(type: InfoType): String { + return when (type) { + InfoType.STREAM -> ID_STREAM + InfoType.PLAYLIST -> ID_PLAYLIST + InfoType.CHANNEL -> ID_CHANNEL + else -> throw IllegalStateException("Unexpected value: $type") + } + } + + private fun infoItemTypeFromString(type: String): InfoType { + return when (type) { + ID_STREAM -> InfoType.STREAM + ID_PLAYLIST -> InfoType.PLAYLIST + ID_CHANNEL -> InfoType.CHANNEL + else -> throw IllegalStateException("Unexpected value: $type") + } + } + + private fun parseError(): ContentNotAvailableException { + return ContentNotAvailableException("Failed to parse media ID") + } + + @Throws(ContentNotAvailableException::class) + private fun extractPlayQueueFromInfoItemMediaId( + path: List, + url: String? + ): Single { + if (path.size != 2) { + throw parseError() + } + val infoItemType = infoItemTypeFromString(path[0]) + val serviceId = path[1].toInt() + return when (infoItemType) { + InfoType.STREAM -> ExtractorHelper.getStreamInfo(serviceId, url, false) + .map(::SinglePlayQueue) + + InfoType.PLAYLIST -> ExtractorHelper.getPlaylistInfo(serviceId, url, false) + .map(::PlaylistPlayQueue) + + InfoType.CHANNEL -> ExtractorHelper.getChannelInfo(serviceId, url, false) + .map { info -> + val playableTab = info.tabs + .stream() + .filter { tab -> ChannelTabHelper.isStreamsTab(tab) } + .findFirst() + if (playableTab.isPresent) { + return@map ChannelTabPlayQueue( + serviceId, + ListLinkHandler(playableTab.get()) + ) + } else { + throw ContentNotAvailableException("No streams tab found") + } + } + + else -> throw parseError() + } + } + } +} From 9f26137b9c0dea828d78ebc59c3c9423c939690b Mon Sep 17 00:00:00 2001 From: Haggai Eran Date: Thu, 5 Sep 2024 21:08:12 +0300 Subject: [PATCH 20/26] media browser: kotlin fixes --- .../java/org/schabi/newpipe/player/PlayerService.kt | 2 +- .../player/mediabrowser/MediaBrowserConnector.kt | 11 +++++++---- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/org/schabi/newpipe/player/PlayerService.kt b/app/src/main/java/org/schabi/newpipe/player/PlayerService.kt index db6ed64e574..53ff9b1fda2 100644 --- a/app/src/main/java/org/schabi/newpipe/player/PlayerService.kt +++ b/app/src/main/java/org/schabi/newpipe/player/PlayerService.kt @@ -58,7 +58,7 @@ class PlayerService : MediaBrowserServiceCompat() { private var mediaBrowserConnector: MediaBrowserConnector? = null get() { if (field == null) { - return MediaBrowserConnector(this) + field = MediaBrowserConnector(this) } return field } diff --git a/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserConnector.kt b/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserConnector.kt index f08d8985556..05cf2d8431e 100644 --- a/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserConnector.kt +++ b/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserConnector.kt @@ -263,7 +263,7 @@ class MediaBrowserConnector(private val playerService: PlayerService) : Playback try { val parentIdUri = Uri.parse(parentId) - val path = parentIdUri.pathSegments + val path = ArrayList(parentIdUri.pathSegments) if (path.isEmpty()) { val mediaItems: MutableList = ArrayList() @@ -431,7 +431,7 @@ class MediaBrowserConnector(private val playerService: PlayerService) : Playback private fun extractPlayQueueFromMediaId(mediaId: String): Single { try { val mediaIdUri = Uri.parse(mediaId) - val path = mediaIdUri.pathSegments + val path = ArrayList(mediaIdUri.pathSegments) if (path.isEmpty()) { throw parseError() @@ -461,14 +461,17 @@ class MediaBrowserConnector(private val playerService: PlayerService) : Playback @Throws(ContentNotAvailableException::class) private fun extractPlayQueueFromPlaylistMediaId( - mediaIdSegments: List, + mediaIdSegments: ArrayList, url: String?, ): Single { if (mediaIdSegments.isEmpty()) { throw parseError() } - when (val playlistType = mediaIdSegments.first()) { + val playlistType = mediaIdSegments.first() + mediaIdSegments.removeAt(0) + + when (playlistType) { ID_LOCAL, ID_REMOTE -> { if (mediaIdSegments.size != 2) { throw parseError() From 70620092238cb29733e43792e8cb586fe8c51d20 Mon Sep 17 00:00:00 2001 From: Haggai Eran Date: Tue, 17 Sep 2024 22:37:45 +0300 Subject: [PATCH 21/26] media browser: remove leftover Java comments --- app/src/main/java/org/schabi/newpipe/player/PlayerService.kt | 2 -- .../schabi/newpipe/player/mediabrowser/MediaBrowserConnector.kt | 2 -- 2 files changed, 4 deletions(-) diff --git a/app/src/main/java/org/schabi/newpipe/player/PlayerService.kt b/app/src/main/java/org/schabi/newpipe/player/PlayerService.kt index 53ff9b1fda2..68437c7e5db 100644 --- a/app/src/main/java/org/schabi/newpipe/player/PlayerService.kt +++ b/app/src/main/java/org/schabi/newpipe/player/PlayerService.kt @@ -89,8 +89,6 @@ class PlayerService : MediaBrowserServiceCompat() { } } - // Suppress Sonar warning to not always return the same value, as we need to do some actions - // before returning override fun onStartCommand( intent: Intent, flags: Int, diff --git a/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserConnector.kt b/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserConnector.kt index 05cf2d8431e..0fd550bd3d7 100644 --- a/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserConnector.kt +++ b/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserConnector.kt @@ -350,8 +350,6 @@ class MediaBrowserConnector(private val playerService: PlayerService) : Playback bookmarksNotificationsDisposable?.dispose() } - // Suppress Sonar warning replace list collection by Stream.toList call, as this method is only - // available in Android API 34 and not currently available with desugaring private fun populateBookmarks() = playlists.firstOrError().map { playlist -> playlist.filterNotNull().map { createPlaylistMediaItem(it) } From fad463a403e7b0034be9d8dd9a336b05eaa64103 Mon Sep 17 00:00:00 2001 From: Haggai Eran Date: Tue, 17 Sep 2024 22:40:59 +0300 Subject: [PATCH 22/26] media browser: rename remote -> isRemote --- .../player/mediabrowser/MediaBrowserConnector.kt | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserConnector.kt b/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserConnector.kt index 0fd550bd3d7..fb6d58c8206 100644 --- a/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserConnector.kt +++ b/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserConnector.kt @@ -123,8 +123,8 @@ class MediaBrowserConnector(private val playerService: PlayerService) : Playback private fun createPlaylistMediaItem(playlist: PlaylistLocalItem): MediaBrowserCompat.MediaItem { val builder = MediaDescriptionCompat.Builder() - val remote = playlist is PlaylistRemoteEntity - builder.setMediaId(createMediaIdForInfoItem(remote, playlist.uid)) + val isRemote = playlist is PlaylistRemoteEntity + builder.setMediaId(createMediaIdForInfoItem(isRemote, playlist.uid)) .setTitle(playlist.orderingName) .setIconUri(Uri.parse(playlist.thumbnailUrl)) @@ -168,9 +168,9 @@ class MediaBrowserConnector(private val playerService: PlayerService) : Playback .appendPath(playlistType) private fun buildLocalPlaylistItemMediaId( - remote: Boolean, + isRemote: Boolean, playlistId: Long, - ) = buildPlaylistMediaId(if (remote) ID_REMOTE else ID_LOCAL) + ) = buildPlaylistMediaId(if (isRemote) ID_REMOTE else ID_LOCAL) .appendPath(playlistId.toString()) private fun buildInfoItemMediaId(item: InfoItem) = @@ -181,9 +181,9 @@ class MediaBrowserConnector(private val playerService: PlayerService) : Playback .appendQueryParameter(ID_URL, item.url) private fun createMediaIdForInfoItem( - remote: Boolean, + isRemote: Boolean, playlistId: Long, - ) = buildLocalPlaylistItemMediaId(remote, playlistId) + ) = buildLocalPlaylistItemMediaId(isRemote, playlistId) .build() .toString() @@ -224,10 +224,10 @@ class MediaBrowserConnector(private val playerService: PlayerService) : Playback } private fun createMediaIdForPlaylistIndex( - remote: Boolean, + isRemote: Boolean, playlistId: Long, index: Int, - ) = buildLocalPlaylistItemMediaId(remote, playlistId) + ) = buildLocalPlaylistItemMediaId(isRemote, playlistId) .appendPath(index.toString()) .build() .toString() From b462c97ecdd87ad3d25a489066cbcde76c713835 Mon Sep 17 00:00:00 2001 From: Haggai Eran Date: Tue, 17 Sep 2024 22:46:55 +0300 Subject: [PATCH 23/26] media browser: pass media ID to parsing error exceptions --- .../mediabrowser/MediaBrowserConnector.kt | 33 +++++++++++-------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserConnector.kt b/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserConnector.kt index fb6d58c8206..7e43305af29 100644 --- a/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserConnector.kt +++ b/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserConnector.kt @@ -304,11 +304,11 @@ class MediaBrowserConnector(private val playerService: PlayerService) : Playback } } Log.w(TAG, "Unknown playlist URI: $parentId") - throw parseError() + throw parseError(parentId) } ID_HISTORY -> return populateHistory() - else -> throw parseError() + else -> throw parseError(parentId) } } catch (e: ContentNotAvailableException) { return Single.error(e) @@ -432,7 +432,7 @@ class MediaBrowserConnector(private val playerService: PlayerService) : Playback val path = ArrayList(mediaIdUri.pathSegments) if (path.isEmpty()) { - throw parseError() + throw parseError(mediaId) } val uriType = path[0] @@ -440,17 +440,19 @@ class MediaBrowserConnector(private val playerService: PlayerService) : Playback return when (uriType) { ID_BOOKMARKS -> extractPlayQueueFromPlaylistMediaId( + mediaId, path, mediaIdUri.getQueryParameter(ID_URL) ) - ID_HISTORY -> extractPlayQueueFromHistoryMediaId(path) + ID_HISTORY -> extractPlayQueueFromHistoryMediaId(mediaId, path) ID_INFO_ITEM -> extractPlayQueueFromInfoItemMediaId( + mediaId, path, mediaIdUri.getQueryParameter(ID_URL) ) - else -> throw parseError() + else -> throw parseError(mediaId) } } catch (error: ContentNotAvailableException) { return Single.error(error) @@ -459,11 +461,12 @@ class MediaBrowserConnector(private val playerService: PlayerService) : Playback @Throws(ContentNotAvailableException::class) private fun extractPlayQueueFromPlaylistMediaId( + mediaId: String, mediaIdSegments: ArrayList, url: String?, ): Single { if (mediaIdSegments.isEmpty()) { - throw parseError() + throw parseError(mediaId) } val playlistType = mediaIdSegments.first() @@ -472,7 +475,7 @@ class MediaBrowserConnector(private val playerService: PlayerService) : Playback when (playlistType) { ID_LOCAL, ID_REMOTE -> { if (mediaIdSegments.size != 2) { - throw parseError() + throw parseError(mediaId) } val playlistId = mediaIdSegments[0].toLong() val index = mediaIdSegments[1].toInt() @@ -485,7 +488,7 @@ class MediaBrowserConnector(private val playerService: PlayerService) : Playback ID_URL -> { if (mediaIdSegments.size != 1) { - throw parseError() + throw parseError(mediaId) } val serviceId = mediaIdSegments[0].toInt() @@ -494,16 +497,17 @@ class MediaBrowserConnector(private val playerService: PlayerService) : Playback .map(::PlaylistPlayQueue) } - else -> throw parseError() + else -> throw parseError(mediaId) } } @Throws(ContentNotAvailableException::class) private fun extractPlayQueueFromHistoryMediaId( + mediaId: String, path: List ): Single { if (path.size != 1) { - throw parseError() + throw parseError(mediaId) } val streamId = path[0].toLong() @@ -692,17 +696,18 @@ class MediaBrowserConnector(private val playerService: PlayerService) : Playback } } - private fun parseError(): ContentNotAvailableException { - return ContentNotAvailableException("Failed to parse media ID") + private fun parseError(mediaId: String): ContentNotAvailableException { + return ContentNotAvailableException("Failed to parse media ID $mediaId") } @Throws(ContentNotAvailableException::class) private fun extractPlayQueueFromInfoItemMediaId( + mediaId: String, path: List, url: String? ): Single { if (path.size != 2) { - throw parseError() + throw parseError(mediaId) } val infoItemType = infoItemTypeFromString(path[0]) val serviceId = path[1].toInt() @@ -729,7 +734,7 @@ class MediaBrowserConnector(private val playerService: PlayerService) : Playback } } - else -> throw parseError() + else -> throw parseError(mediaId) } } } From c8ccc60047f12554532771a379dd272b3cda0a15 Mon Sep 17 00:00:00 2001 From: Siddhesh Naik Date: Sat, 12 Oct 2024 21:04:45 +0530 Subject: [PATCH 24/26] Addressed review comments --- .../fragments/detail/VideoDetailFragment.java | 3 ++- .../newpipe/local/dialog/PlaylistDialog.java | 2 +- .../newpipe/player/PlayQueueActivity.java | 27 ++++++++++++------- .../schabi/newpipe/player/PlayerService.kt | 5 +--- .../PlayerServiceExtendedEventListener.java | 4 ++- .../newpipe/player/helper/PlayerHolder.java | 2 +- .../mediabrowser/MediaBrowserConnector.kt | 9 +++++-- 7 files changed, 33 insertions(+), 19 deletions(-) diff --git a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java index 1d1e166e7d7..6c9a8504646 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java @@ -229,6 +229,7 @@ public final class VideoDetailFragment private ContentObserver settingsContentObserver; @Nullable private PlayerService playerService; + @Nullable private Player player; private final PlayerHolder playerHolder = PlayerHolder.getInstance(); @@ -236,7 +237,7 @@ public final class VideoDetailFragment // Service management //////////////////////////////////////////////////////////////////////////*/ @Override - public void onServiceConnected(final Player connectedPlayer, + public void onServiceConnected(@Nullable final Player connectedPlayer, final PlayerService connectedPlayerService, final boolean playAfterConnect) { player = connectedPlayer; diff --git a/app/src/main/java/org/schabi/newpipe/local/dialog/PlaylistDialog.java b/app/src/main/java/org/schabi/newpipe/local/dialog/PlaylistDialog.java index 612c3818187..da408bb50aa 100644 --- a/app/src/main/java/org/schabi/newpipe/local/dialog/PlaylistDialog.java +++ b/app/src/main/java/org/schabi/newpipe/local/dialog/PlaylistDialog.java @@ -163,7 +163,7 @@ public static Disposable createCorrespondingDialog( * @return the disposable that was created */ public static Disposable showForPlayQueue( - final Player player, + @NonNull final Player player, @NonNull final FragmentManager fragmentManager) { final List streamEntities = Stream.of(player.getPlayQueue()) diff --git a/app/src/main/java/org/schabi/newpipe/player/PlayQueueActivity.java b/app/src/main/java/org/schabi/newpipe/player/PlayQueueActivity.java index 195baecbda8..dc959afea01 100644 --- a/app/src/main/java/org/schabi/newpipe/player/PlayQueueActivity.java +++ b/app/src/main/java/org/schabi/newpipe/player/PlayQueueActivity.java @@ -61,6 +61,7 @@ public final class PlayQueueActivity extends AppCompatActivity private static final int MENU_ID_AUDIO_TRACK = 71; + @Nullable private Player player; private boolean serviceBound; @@ -137,30 +138,38 @@ public boolean onOptionsItemSelected(final MenuItem item) { NavigationHelper.openSettings(this); return true; case R.id.action_append_playlist: - PlaylistDialog.showForPlayQueue(player, getSupportFragmentManager()); + if (player != null) { + PlaylistDialog.showForPlayQueue(player, getSupportFragmentManager()); + } return true; case R.id.action_playback_speed: openPlaybackParameterDialog(); return true; case R.id.action_mute: - player.toggleMute(); + if (player != null) { + player.toggleMute(); + } return true; case R.id.action_system_audio: startActivity(new Intent(Settings.ACTION_SOUND_SETTINGS)); return true; case R.id.action_switch_main: - this.player.setRecovery(); - NavigationHelper.playOnMainPlayer(this, player.getPlayQueue(), true); + if (player != null) { + this.player.setRecovery(); + NavigationHelper.playOnMainPlayer(this, player.getPlayQueue(), true); + } return true; case R.id.action_switch_popup: - if (PermissionHelper.isPopupEnabledElseAsk(this)) { + if (PermissionHelper.isPopupEnabledElseAsk(this) && player != null) { this.player.setRecovery(); NavigationHelper.playOnPopupPlayer(this, player.getPlayQueue(), true); } return true; case R.id.action_switch_background: - this.player.setRecovery(); - NavigationHelper.playOnBackgroundPlayer(this, player.getPlayQueue(), true); + if (player != null) { + this.player.setRecovery(); + NavigationHelper.playOnBackgroundPlayer(this, player.getPlayQueue(), true); + } return true; } @@ -309,7 +318,7 @@ public void onMove(final int sourceIndex, final int targetIndex) { @Override public void onSwiped(final int index) { - if (index != -1) { + if (index != -1 && player != null) { player.getPlayQueue().remove(index); } } @@ -659,7 +668,7 @@ private void buildAudioTrackMenu() { * @param itemId index of the selected item */ private void onAudioTrackClick(final int itemId) { - if (player.getCurrentMetadata() == null) { + if (player == null || player.getCurrentMetadata() == null) { return; } player.getCurrentMetadata().getMaybeAudioTrack().ifPresent(audioTrack -> { diff --git a/app/src/main/java/org/schabi/newpipe/player/PlayerService.kt b/app/src/main/java/org/schabi/newpipe/player/PlayerService.kt index 68437c7e5db..82155ee661c 100644 --- a/app/src/main/java/org/schabi/newpipe/player/PlayerService.kt +++ b/app/src/main/java/org/schabi/newpipe/player/PlayerService.kt @@ -221,10 +221,7 @@ class PlayerService : MediaBrowserServiceCompat() { ) : Binder() { private val playerService = WeakReference(playerService) - val service: PlayerService? - get() = playerService.get() - - fun getPlayer(): Player = service?.player ?: throw Error("Player service is null") + fun getPlayer(): Player? = playerService.get()?.player } companion object { diff --git a/app/src/main/java/org/schabi/newpipe/player/event/PlayerServiceExtendedEventListener.java b/app/src/main/java/org/schabi/newpipe/player/event/PlayerServiceExtendedEventListener.java index 8effe2f0e93..15852088b6f 100644 --- a/app/src/main/java/org/schabi/newpipe/player/event/PlayerServiceExtendedEventListener.java +++ b/app/src/main/java/org/schabi/newpipe/player/event/PlayerServiceExtendedEventListener.java @@ -1,10 +1,12 @@ package org.schabi.newpipe.player.event; +import androidx.annotation.Nullable; + import org.schabi.newpipe.player.PlayerService; import org.schabi.newpipe.player.Player; public interface PlayerServiceExtendedEventListener extends PlayerServiceEventListener { - void onServiceConnected(Player player, + void onServiceConnected(@Nullable Player player, PlayerService playerService, boolean playAfterConnect); void onServiceDisconnected(); diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHolder.java b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHolder.java index b55a6547ab7..ba4fb377260 100644 --- a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHolder.java +++ b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHolder.java @@ -166,7 +166,7 @@ public void onServiceConnected(final ComponentName compName, final IBinder servi } final PlayerService.LocalBinder localBinder = (PlayerService.LocalBinder) service; - playerService = localBinder.getService(); + playerService = localBinder.getPlayer().getService(); player = localBinder.getPlayer(); if (listener != null) { listener.onServiceConnected(player, playerService, playAfterConnect); diff --git a/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserConnector.kt b/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserConnector.kt index 7e43305af29..659a4486853 100644 --- a/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserConnector.kt +++ b/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserConnector.kt @@ -59,7 +59,9 @@ import org.schabi.newpipe.util.NavigationHelper import org.schabi.newpipe.util.ServiceHelper import java.util.stream.Collectors -class MediaBrowserConnector(private val playerService: PlayerService) : PlaybackPreparer { +class MediaBrowserConnector( + private val playerService: PlayerService, +) : PlaybackPreparer { private val mediaSession = MediaSessionCompat(playerService, TAG) val sessionConnector = MediaSessionConnector(mediaSession).apply { setMetadataDeduplicationEnabled(true) @@ -627,7 +629,10 @@ class MediaBrowserConnector(private val playerService: PlayerService) : Playback private fun handleSearchError(throwable: Throwable) { Log.e(TAG, "Search error: $throwable") disposePrepareOrPlayCommands() - playbackError(R.string.content_not_supported, PlaybackStateCompat.ERROR_CODE_NOT_SUPPORTED) + sessionConnector.setCustomErrorMessage( + playerService.getString(R.string.search_no_results), + PlaybackStateCompat.ERROR_CODE_APP_ERROR, + ) } override fun onPrepareFromUri( From 9d750edf5f30d1e3afc3275bf1988b4dd46a89a3 Mon Sep 17 00:00:00 2001 From: Siddhesh Naik Date: Sat, 7 Dec 2024 02:08:06 +0530 Subject: [PATCH 25/26] Addressed review comments --- .../org/schabi/newpipe/player/Player.java | 12 ++-- .../schabi/newpipe/player/PlayerService.kt | 56 ++++++++----------- .../mediabrowser/MediaBrowserConnector.kt | 10 ++-- 3 files changed, 36 insertions(+), 42 deletions(-) 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 95692cbed6c..b19df82fa01 100644 --- a/app/src/main/java/org/schabi/newpipe/player/Player.java +++ b/app/src/main/java/org/schabi/newpipe/player/Player.java @@ -45,7 +45,6 @@ import static org.schabi.newpipe.util.ListHelper.getPopupResolutionIndex; import static org.schabi.newpipe.util.ListHelper.getResolutionIndex; import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage; -import static java.util.Objects.requireNonNull; import static java.util.concurrent.TimeUnit.MILLISECONDS; import android.content.BroadcastReceiver; @@ -87,8 +86,8 @@ import org.schabi.newpipe.error.ErrorInfo; import org.schabi.newpipe.error.ErrorUtil; import org.schabi.newpipe.error.UserAction; -import org.schabi.newpipe.extractor.stream.AudioStream; import org.schabi.newpipe.extractor.Image; +import org.schabi.newpipe.extractor.stream.AudioStream; import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.extractor.stream.StreamType; import org.schabi.newpipe.extractor.stream.VideoStream; @@ -119,9 +118,9 @@ import org.schabi.newpipe.util.DependentPreferenceHelper; import org.schabi.newpipe.util.ListHelper; import org.schabi.newpipe.util.NavigationHelper; -import org.schabi.newpipe.util.image.PicassoHelper; import org.schabi.newpipe.util.SerializedCache; import org.schabi.newpipe.util.StreamTypeUtil; +import org.schabi.newpipe.util.image.PicassoHelper; import java.util.List; import java.util.Optional; @@ -416,9 +415,12 @@ public void handleIntent(@NonNull final Intent intent) { == com.google.android.exoplayer2.Player.STATE_IDLE) { simpleExoPlayer.prepare(); } + // Seeks to a specific index and position in the player if the queue index has changed. if (playQueue.getIndex() != newQueue.getIndex()) { - simpleExoPlayer.seekTo(newQueue.getIndex(), - requireNonNull(newQueue.getItem()).getRecoveryPosition()); + final PlayQueueItem queueItem = newQueue.getItem(); + if (queueItem != null) { + simpleExoPlayer.seekTo(newQueue.getIndex(), queueItem.getRecoveryPosition()); + } } simpleExoPlayer.setPlayWhenReady(playWhenReady); diff --git a/app/src/main/java/org/schabi/newpipe/player/PlayerService.kt b/app/src/main/java/org/schabi/newpipe/player/PlayerService.kt index 82155ee661c..4ccda0c1748 100644 --- a/app/src/main/java/org/schabi/newpipe/player/PlayerService.kt +++ b/app/src/main/java/org/schabi/newpipe/player/PlayerService.kt @@ -39,28 +39,18 @@ import java.lang.ref.WeakReference * One service for all players. */ class PlayerService : MediaBrowserServiceCompat() { - private val player: Player by lazy { - Player(this).apply { - /* - Create the player notification and start immediately the service in foreground, - otherwise if nothing is played or initializing the player and its components (especially - loading stream metadata) takes a lot of time, the app would crash on Android 8+ as the - service would never be put in the foreground while we said to the system we would do so - */ - UIs()[NotificationPlayerUi::class.java].ifPresent { - it.createNotificationAndStartForeground() - } - } - } + private var player: Player? = null private val mBinder: IBinder = LocalBinder(this) - private val compositeDisposableLoadChildren = CompositeDisposable() - private var mediaBrowserConnector: MediaBrowserConnector? = null + private val disposables = CompositeDisposable() + private var _mediaBrowserConnector: MediaBrowserConnector? = null + private val mediaBrowserConnector: MediaBrowserConnector get() { - if (field == null) { - field = MediaBrowserConnector(this) + return _mediaBrowserConnector ?: run { + val newMediaBrowserConnector = MediaBrowserConnector(this) + _mediaBrowserConnector = newMediaBrowserConnector + newMediaBrowserConnector } - return field } val sessionConnector: MediaSessionConnector? @@ -78,13 +68,14 @@ class PlayerService : MediaBrowserServiceCompat() { Localization.assureCorrectAppLanguage(this) ThemeHelper.setTheme(this) + player = Player(this) /* Create the player notification and start immediately the service in foreground, otherwise if nothing is played or initializing the player and its components (especially loading stream metadata) takes a lot of time, the app would crash on Android 8+ as the service would never be put in the foreground while we said to the system we would do so */ - player.UIs()[NotificationPlayerUi::class.java].ifPresent { + player!!.UIs()[NotificationPlayerUi::class.java].ifPresent { it.createNotificationAndStartForeground() } } @@ -112,11 +103,11 @@ class PlayerService : MediaBrowserServiceCompat() { If the service is already started in foreground, requesting it to be started shouldn't do anything */ - player.UIs()[NotificationPlayerUi::class.java].ifPresent { + player?.UIs()?.get(NotificationPlayerUi::class.java)?.ifPresent { it.createNotificationAndStartForeground() } - if (Intent.ACTION_MEDIA_BUTTON == intent.action && (player.playQueue == null)) { + if (Intent.ACTION_MEDIA_BUTTON == intent.action && (player?.playQueue == null)) { /* No need to process media button's actions if the player is not working, otherwise the player service would strangely start with nothing to play @@ -127,8 +118,8 @@ class PlayerService : MediaBrowserServiceCompat() { return START_NOT_STICKY } - player.handleIntent(intent) - player.UIs()[MediaSessionPlayerUi::class.java].ifPresent { + player?.handleIntent(intent) + player?.UIs()?.get(MediaSessionPlayerUi::class.java)?.ifPresent { it.handleMediaButtonIntent(intent) } @@ -140,17 +131,17 @@ class PlayerService : MediaBrowserServiceCompat() { Log.d(TAG, "stopForImmediateReusing() called") } - if (!player.exoPlayerIsNull()) { + if (player != null && !player!!.exoPlayerIsNull()) { // Releases wifi & cpu, disables keepScreenOn, etc. // We can't just pause the player here because it will make transition // from one stream to a new stream not smooth - player.smoothStopForImmediateReusing() + player?.smoothStopForImmediateReusing() } } override fun onTaskRemoved(rootIntent: Intent) { super.onTaskRemoved(rootIntent) - if (!player.videoPlayerSelected()) { + if (player != null && !player!!.videoPlayerSelected()) { return } onDestroy() @@ -166,14 +157,15 @@ class PlayerService : MediaBrowserServiceCompat() { cleanup() - mediaBrowserConnector?.release() - mediaBrowserConnector = null + mediaBrowserConnector.release() + _mediaBrowserConnector = null - compositeDisposableLoadChildren.clear() + disposables.clear() } private fun cleanup() { - player.destroy() + player?.destroy() + player = null } fun stopService() { @@ -187,7 +179,7 @@ class PlayerService : MediaBrowserServiceCompat() { override fun onBind(intent: Intent): IBinder = mBinder - // MediaBrowserServiceCompat methods + // MediaBrowserServiceCompat methods (they defer function calls to mediaBrowserConnector) override fun onGetRoot( clientPackageName: String, clientUid: Int, @@ -204,7 +196,7 @@ class PlayerService : MediaBrowserServiceCompat() { it.onLoadChildren(parentId).subscribe { mediaItems -> result.sendResult(mediaItems) } - compositeDisposableLoadChildren.add(disposable) + disposables.add(disposable) } } diff --git a/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserConnector.kt b/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserConnector.kt index 659a4486853..3228753354c 100644 --- a/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserConnector.kt +++ b/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserConnector.kt @@ -125,20 +125,20 @@ class MediaBrowserConnector( private fun createPlaylistMediaItem(playlist: PlaylistLocalItem): MediaBrowserCompat.MediaItem { val builder = MediaDescriptionCompat.Builder() - val isRemote = playlist is PlaylistRemoteEntity - builder.setMediaId(createMediaIdForInfoItem(isRemote, playlist.uid)) + builder + .setMediaId(createMediaIdForInfoItem(playlist is PlaylistRemoteEntity, playlist.uid)) .setTitle(playlist.orderingName) - .setIconUri(Uri.parse(playlist.thumbnailUrl)) + .setIconUri(playlist.thumbnailUrl?.let { Uri.parse(it) }) val extras = Bundle() extras.putString( MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE, - playerService.resources.getString(R.string.tab_bookmarks) + playerService.resources.getString(R.string.tab_bookmarks), ) builder.setExtras(extras) return MediaBrowserCompat.MediaItem( builder.build(), - MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE, ) } From f6f7c377e235e629da690f48dd713ef87d7ed341 Mon Sep 17 00:00:00 2001 From: Haggai Eran Date: Sat, 14 Dec 2024 17:31:51 +0200 Subject: [PATCH 26/26] PlayerService: return appropriate IBinder depending on the action --- .../main/java/org/schabi/newpipe/player/PlayerService.kt | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/org/schabi/newpipe/player/PlayerService.kt b/app/src/main/java/org/schabi/newpipe/player/PlayerService.kt index 4ccda0c1748..10c750f7b51 100644 --- a/app/src/main/java/org/schabi/newpipe/player/PlayerService.kt +++ b/app/src/main/java/org/schabi/newpipe/player/PlayerService.kt @@ -177,7 +177,13 @@ class PlayerService : MediaBrowserServiceCompat() { super.attachBaseContext(AudioServiceLeakFix.preventLeakOf(base)) } - override fun onBind(intent: Intent): IBinder = mBinder + override fun onBind(intent: Intent): IBinder? { + if (SERVICE_INTERFACE == intent.action) { + // For actions related to the media browser service, pass the onBind to the superclass + return super.onBind(intent) + } + return mBinder + } // MediaBrowserServiceCompat methods (they defer function calls to mediaBrowserConnector) override fun onGetRoot(