diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index d11de9f478d..e52dded5e1a 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -64,6 +64,9 @@ + + + + + + diff --git a/app/src/main/java/org/schabi/newpipe/database/LocalItem.java b/app/src/main/java/org/schabi/newpipe/database/LocalItem.java deleted file mode 100644 index 54b856b0653..00000000000 --- a/app/src/main/java/org/schabi/newpipe/database/LocalItem.java +++ /dev/null @@ -1,13 +0,0 @@ -package org.schabi.newpipe.database; - -public interface LocalItem { - LocalItemType getLocalItemType(); - - enum LocalItemType { - PLAYLIST_LOCAL_ITEM, - PLAYLIST_REMOTE_ITEM, - - PLAYLIST_STREAM_ITEM, - STATISTIC_STREAM_ITEM, - } -} diff --git a/app/src/main/java/org/schabi/newpipe/database/LocalItem.kt b/app/src/main/java/org/schabi/newpipe/database/LocalItem.kt new file mode 100644 index 00000000000..87084cd51d3 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/database/LocalItem.kt @@ -0,0 +1,18 @@ +package org.schabi.newpipe.database + +/** + * Represents a generic item that can be stored locally. This can be a playlist, a stream, etc. + */ +interface LocalItem { + /** + * The type of local item. Can be null if the type is unknown or not applicable. + */ + val localItemType: LocalItemType? + + enum class LocalItemType { + PLAYLIST_LOCAL_ITEM, + PLAYLIST_REMOTE_ITEM, + PLAYLIST_STREAM_ITEM, + STATISTIC_STREAM_ITEM, + } +} diff --git a/app/src/main/java/org/schabi/newpipe/database/history/model/StreamHistoryEntry.kt b/app/src/main/java/org/schabi/newpipe/database/history/model/StreamHistoryEntry.kt index a93ba1652f6..27fc429f1b9 100644 --- a/app/src/main/java/org/schabi/newpipe/database/history/model/StreamHistoryEntry.kt +++ b/app/src/main/java/org/schabi/newpipe/database/history/model/StreamHistoryEntry.kt @@ -3,6 +3,8 @@ package org.schabi.newpipe.database.history.model import androidx.room.ColumnInfo import androidx.room.Embedded import org.schabi.newpipe.database.stream.model.StreamEntity +import org.schabi.newpipe.extractor.stream.StreamInfoItem +import org.schabi.newpipe.util.image.ImageStrategy import java.time.OffsetDateTime data class StreamHistoryEntry( @@ -27,4 +29,17 @@ data class StreamHistoryEntry( return this.streamEntity.uid == other.streamEntity.uid && streamId == other.streamId && accessDate.isEqual(other.accessDate) } + + fun toStreamInfoItem(): StreamInfoItem = + StreamInfoItem( + streamEntity.serviceId, + streamEntity.url, + streamEntity.title, + streamEntity.streamType, + ).apply { + duration = streamEntity.duration + uploaderName = streamEntity.uploader + uploaderUrl = streamEntity.uploaderUrl + thumbnails = ImageStrategy.dbUrlToImageList(streamEntity.thumbnailUrl) + } } diff --git a/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistLocalItem.java b/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistLocalItem.java deleted file mode 100644 index 072c49e2c07..00000000000 --- a/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistLocalItem.java +++ /dev/null @@ -1,13 +0,0 @@ -package org.schabi.newpipe.database.playlist; - -import org.schabi.newpipe.database.LocalItem; - -public interface PlaylistLocalItem extends LocalItem { - String getOrderingName(); - - long getDisplayIndex(); - - long getUid(); - - void setDisplayIndex(long displayIndex); -} diff --git a/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistLocalItem.kt b/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistLocalItem.kt new file mode 100644 index 00000000000..22d57572c24 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistLocalItem.kt @@ -0,0 +1,28 @@ +package org.schabi.newpipe.database.playlist + +import org.schabi.newpipe.database.LocalItem + +/** + * Represents a playlist item stored locally. + */ +interface PlaylistLocalItem : LocalItem { + /** + * The name used for ordering this item within the playlist. Can be null. + */ + val orderingName: String? + + /** + * The index used to display this item within the playlist. + */ + var displayIndex: Long + + /** + * The unique identifier for this playlist item. + */ + val uid: Long + + /** + * The URL of the thumbnail image for this playlist item. Can be null. + */ + val thumbnailUrl: String? +} diff --git a/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistMetadataEntry.java b/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistMetadataEntry.java index 03a1e1e308a..4b0338b390e 100644 --- a/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistMetadataEntry.java +++ b/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistMetadataEntry.java @@ -71,4 +71,9 @@ public long getUid() { public void setDisplayIndex(final long displayIndex) { this.displayIndex = displayIndex; } + + @Override + public String getThumbnailUrl() { + return thumbnailUrl; + } } diff --git a/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistStreamEntry.kt b/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistStreamEntry.kt index 1d74c6d31dc..1b40d223fa1 100644 --- a/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistStreamEntry.kt +++ b/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistStreamEntry.kt @@ -22,19 +22,20 @@ data class PlaylistStreamEntry( @ColumnInfo(name = PlaylistStreamEntity.JOIN_INDEX) val joinIndex: Int ) : LocalItem { - @Throws(IllegalArgumentException::class) - fun toStreamInfoItem(): StreamInfoItem { - val item = StreamInfoItem(streamEntity.serviceId, streamEntity.url, streamEntity.title, streamEntity.streamType) - item.duration = streamEntity.duration - item.uploaderName = streamEntity.uploader - item.uploaderUrl = streamEntity.uploaderUrl - item.thumbnails = ImageStrategy.dbUrlToImageList(streamEntity.thumbnailUrl) - - return item - } - - override fun getLocalItemType(): LocalItem.LocalItemType { - return LocalItem.LocalItemType.PLAYLIST_STREAM_ITEM - } + fun toStreamInfoItem() = + StreamInfoItem( + streamEntity.serviceId, + streamEntity.url, + streamEntity.title, + streamEntity.streamType, + ).apply { + duration = streamEntity.duration + uploaderName = streamEntity.uploader + uploaderUrl = streamEntity.uploaderUrl + thumbnails = ImageStrategy.dbUrlToImageList(streamEntity.thumbnailUrl) + } + + override val localItemType: LocalItem.LocalItemType + get() = LocalItem.LocalItemType.PLAYLIST_STREAM_ITEM } diff --git a/app/src/main/java/org/schabi/newpipe/database/stream/StreamStatisticsEntry.kt b/app/src/main/java/org/schabi/newpipe/database/stream/StreamStatisticsEntry.kt index 1f3654e7ae4..60c913d1190 100644 --- a/app/src/main/java/org/schabi/newpipe/database/stream/StreamStatisticsEntry.kt +++ b/app/src/main/java/org/schabi/newpipe/database/stream/StreamStatisticsEntry.kt @@ -26,19 +26,21 @@ class StreamStatisticsEntry( @ColumnInfo(name = STREAM_WATCH_COUNT) val watchCount: Long ) : LocalItem { - fun toStreamInfoItem(): StreamInfoItem { - val item = StreamInfoItem(streamEntity.serviceId, streamEntity.url, streamEntity.title, streamEntity.streamType) - item.duration = streamEntity.duration - item.uploaderName = streamEntity.uploader - item.uploaderUrl = streamEntity.uploaderUrl - item.thumbnails = ImageStrategy.dbUrlToImageList(streamEntity.thumbnailUrl) - - return item - } - - override fun getLocalItemType(): LocalItem.LocalItemType { - return LocalItem.LocalItemType.STATISTIC_STREAM_ITEM - } + fun toStreamInfoItem() = + StreamInfoItem( + streamEntity.serviceId, + streamEntity.url, + streamEntity.title, + streamEntity.streamType, + ).apply { + duration = streamEntity.duration + uploaderName = streamEntity.uploader + uploaderUrl = streamEntity.uploaderUrl + thumbnails = ImageStrategy.dbUrlToImageList(streamEntity.thumbnailUrl) + } + + override val localItemType: LocalItem.LocalItemType + get() = LocalItem.LocalItemType.STATISTIC_STREAM_ITEM companion object { const val STREAM_LATEST_DATE = "latestAccess" diff --git a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java index 40a22103b0b..87edd9f2963 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java @@ -229,6 +229,7 @@ public final class VideoDetailFragment private ContentObserver settingsContentObserver; @Nullable private PlayerService playerService; + @Nullable private Player player; private final PlayerHolder playerHolder = PlayerHolder.getInstance(); @@ -236,7 +237,7 @@ public final class VideoDetailFragment // Service management //////////////////////////////////////////////////////////////////////////*/ @Override - public void onServiceConnected(final Player connectedPlayer, + public void onServiceConnected(@Nullable final Player connectedPlayer, final PlayerService connectedPlayerService, final boolean playAfterConnect) { player = connectedPlayer; diff --git a/app/src/main/java/org/schabi/newpipe/local/dialog/PlaylistDialog.java b/app/src/main/java/org/schabi/newpipe/local/dialog/PlaylistDialog.java index 612c3818187..da408bb50aa 100644 --- a/app/src/main/java/org/schabi/newpipe/local/dialog/PlaylistDialog.java +++ b/app/src/main/java/org/schabi/newpipe/local/dialog/PlaylistDialog.java @@ -163,7 +163,7 @@ public static Disposable createCorrespondingDialog( * @return the disposable that was created */ public static Disposable showForPlayQueue( - final Player player, + @NonNull final Player player, @NonNull final FragmentManager fragmentManager) { final List streamEntities = Stream.of(player.getPlayQueue()) diff --git a/app/src/main/java/org/schabi/newpipe/local/playlist/RemotePlaylistManager.java b/app/src/main/java/org/schabi/newpipe/local/playlist/RemotePlaylistManager.java index 4cc51f7525e..7104f59629a 100644 --- a/app/src/main/java/org/schabi/newpipe/local/playlist/RemotePlaylistManager.java +++ b/app/src/main/java/org/schabi/newpipe/local/playlist/RemotePlaylistManager.java @@ -26,6 +26,10 @@ public Flowable> getPlaylists() { return playlistRemoteTable.getPlaylists().subscribeOn(Schedulers.io()); } + public Flowable> getPlaylist(final long playlistId) { + return playlistRemoteTable.getPlaylist(playlistId).subscribeOn(Schedulers.io()); + } + public Flowable> getPlaylist(final PlaylistInfo info) { return playlistRemoteTable.getPlaylist(info.getServiceId(), info.getUrl()) .subscribeOn(Schedulers.io()); diff --git a/app/src/main/java/org/schabi/newpipe/player/PlayQueueActivity.java b/app/src/main/java/org/schabi/newpipe/player/PlayQueueActivity.java index 195baecbda8..dc959afea01 100644 --- a/app/src/main/java/org/schabi/newpipe/player/PlayQueueActivity.java +++ b/app/src/main/java/org/schabi/newpipe/player/PlayQueueActivity.java @@ -61,6 +61,7 @@ public final class PlayQueueActivity extends AppCompatActivity private static final int MENU_ID_AUDIO_TRACK = 71; + @Nullable private Player player; private boolean serviceBound; @@ -137,30 +138,38 @@ public boolean onOptionsItemSelected(final MenuItem item) { NavigationHelper.openSettings(this); return true; case R.id.action_append_playlist: - PlaylistDialog.showForPlayQueue(player, getSupportFragmentManager()); + if (player != null) { + PlaylistDialog.showForPlayQueue(player, getSupportFragmentManager()); + } return true; case R.id.action_playback_speed: openPlaybackParameterDialog(); return true; case R.id.action_mute: - player.toggleMute(); + if (player != null) { + player.toggleMute(); + } return true; case R.id.action_system_audio: startActivity(new Intent(Settings.ACTION_SOUND_SETTINGS)); return true; case R.id.action_switch_main: - this.player.setRecovery(); - NavigationHelper.playOnMainPlayer(this, player.getPlayQueue(), true); + if (player != null) { + this.player.setRecovery(); + NavigationHelper.playOnMainPlayer(this, player.getPlayQueue(), true); + } return true; case R.id.action_switch_popup: - if (PermissionHelper.isPopupEnabledElseAsk(this)) { + if (PermissionHelper.isPopupEnabledElseAsk(this) && player != null) { this.player.setRecovery(); NavigationHelper.playOnPopupPlayer(this, player.getPlayQueue(), true); } return true; case R.id.action_switch_background: - this.player.setRecovery(); - NavigationHelper.playOnBackgroundPlayer(this, player.getPlayQueue(), true); + if (player != null) { + this.player.setRecovery(); + NavigationHelper.playOnBackgroundPlayer(this, player.getPlayQueue(), true); + } return true; } @@ -309,7 +318,7 @@ public void onMove(final int sourceIndex, final int targetIndex) { @Override public void onSwiped(final int index) { - if (index != -1) { + if (index != -1 && player != null) { player.getPlayQueue().remove(index); } } @@ -659,7 +668,7 @@ private void buildAudioTrackMenu() { * @param itemId index of the selected item */ private void onAudioTrackClick(final int itemId) { - if (player.getCurrentMetadata() == null) { + if (player == null || player.getCurrentMetadata() == null) { return; } player.getCurrentMetadata().getMaybeAudioTrack().ifPresent(audioTrack -> { diff --git a/app/src/main/java/org/schabi/newpipe/player/Player.java b/app/src/main/java/org/schabi/newpipe/player/Player.java index 920435a7e3b..b19df82fa01 100644 --- a/app/src/main/java/org/schabi/newpipe/player/Player.java +++ b/app/src/main/java/org/schabi/newpipe/player/Player.java @@ -86,8 +86,8 @@ import org.schabi.newpipe.error.ErrorInfo; import org.schabi.newpipe.error.ErrorUtil; import org.schabi.newpipe.error.UserAction; -import org.schabi.newpipe.extractor.stream.AudioStream; import org.schabi.newpipe.extractor.Image; +import org.schabi.newpipe.extractor.stream.AudioStream; import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.extractor.stream.StreamType; import org.schabi.newpipe.extractor.stream.VideoStream; @@ -118,9 +118,9 @@ import org.schabi.newpipe.util.DependentPreferenceHelper; import org.schabi.newpipe.util.ListHelper; import org.schabi.newpipe.util.NavigationHelper; -import org.schabi.newpipe.util.image.PicassoHelper; import org.schabi.newpipe.util.SerializedCache; import org.schabi.newpipe.util.StreamTypeUtil; +import org.schabi.newpipe.util.image.PicassoHelper; import java.util.List; import java.util.Optional; @@ -302,7 +302,7 @@ public Player(@NonNull final PlayerService service) { // notification ui in the UIs list, since the notification depends on the media session in // PlayerUi#initPlayer(), and UIs.call() guarantees UI order is preserved. UIs = new PlayerUiList( - new MediaSessionPlayerUi(this), + new MediaSessionPlayerUi(this, service.getSessionConnector()), new NotificationPlayerUi(this) ); } @@ -415,6 +415,13 @@ public void handleIntent(@NonNull final Intent intent) { == com.google.android.exoplayer2.Player.STATE_IDLE) { simpleExoPlayer.prepare(); } + // Seeks to a specific index and position in the player if the queue index has changed. + if (playQueue.getIndex() != newQueue.getIndex()) { + final PlayQueueItem queueItem = newQueue.getItem(); + if (queueItem != null) { + simpleExoPlayer.seekTo(newQueue.getIndex(), queueItem.getRecoveryPosition()); + } + } simpleExoPlayer.setPlayWhenReady(playWhenReady); } else if (intent.getBooleanExtra(RESUME_PLAYBACK, false) diff --git a/app/src/main/java/org/schabi/newpipe/player/PlayerService.java b/app/src/main/java/org/schabi/newpipe/player/PlayerService.java deleted file mode 100644 index e7abf4320d5..00000000000 --- a/app/src/main/java/org/schabi/newpipe/player/PlayerService.java +++ /dev/null @@ -1,185 +0,0 @@ -/* - * Copyright 2017 Mauricio Colli - * Part of NewPipe - * - * License: GPL-3.0+ - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package org.schabi.newpipe.player; - -import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage; - -import android.app.Service; -import android.content.Context; -import android.content.Intent; -import android.os.Binder; -import android.os.IBinder; -import android.util.Log; - -import org.schabi.newpipe.player.mediasession.MediaSessionPlayerUi; -import org.schabi.newpipe.player.notification.NotificationPlayerUi; -import org.schabi.newpipe.util.ThemeHelper; - -import java.lang.ref.WeakReference; - - -/** - * One service for all players. - */ -public final class PlayerService extends Service { - private static final String TAG = PlayerService.class.getSimpleName(); - private static final boolean DEBUG = Player.DEBUG; - - private Player player; - - private final IBinder mBinder = new PlayerService.LocalBinder(this); - - - /*////////////////////////////////////////////////////////////////////////// - // Service's LifeCycle - //////////////////////////////////////////////////////////////////////////*/ - - @Override - public void onCreate() { - if (DEBUG) { - Log.d(TAG, "onCreate() called"); - } - assureCorrectAppLanguage(this); - ThemeHelper.setTheme(this); - - player = new Player(this); - /* - Create the player notification and start immediately the service in foreground, - otherwise if nothing is played or initializing the player and its components (especially - loading stream metadata) takes a lot of time, the app would crash on Android 8+ as the - service would never be put in the foreground while we said to the system we would do so - */ - player.UIs().get(NotificationPlayerUi.class) - .ifPresent(NotificationPlayerUi::createNotificationAndStartForeground); - } - - @Override - public int onStartCommand(final Intent intent, final int flags, final int startId) { - if (DEBUG) { - Log.d(TAG, "onStartCommand() called with: intent = [" + intent - + "], flags = [" + flags + "], startId = [" + startId + "]"); - } - - /* - Be sure that the player notification is set and the service is started in foreground, - otherwise, the app may crash on Android 8+ as the service would never be put in the - foreground while we said to the system we would do so - The service is always requested to be started in foreground, so always creating a - notification if there is no one already and starting the service in foreground should - not create any issues - If the service is already started in foreground, requesting it to be started shouldn't - do anything - */ - if (player != null) { - player.UIs().get(NotificationPlayerUi.class) - .ifPresent(NotificationPlayerUi::createNotificationAndStartForeground); - } - - if (Intent.ACTION_MEDIA_BUTTON.equals(intent.getAction()) - && (player == null || player.getPlayQueue() == null)) { - /* - No need to process media button's actions if the player is not working, otherwise - the player service would strangely start with nothing to play - Stop the service in this case, which will be removed from the foreground and its - notification cancelled in its destruction - */ - stopSelf(); - return START_NOT_STICKY; - } - - if (player != null) { - player.handleIntent(intent); - player.UIs().get(MediaSessionPlayerUi.class) - .ifPresent(ui -> ui.handleMediaButtonIntent(intent)); - } - - return START_NOT_STICKY; - } - - public void stopForImmediateReusing() { - if (DEBUG) { - Log.d(TAG, "stopForImmediateReusing() called"); - } - - if (player != null && !player.exoPlayerIsNull()) { - // Releases wifi & cpu, disables keepScreenOn, etc. - // We can't just pause the player here because it will make transition - // from one stream to a new stream not smooth - player.smoothStopForImmediateReusing(); - } - } - - @Override - public void onTaskRemoved(final Intent rootIntent) { - super.onTaskRemoved(rootIntent); - if (player != null && !player.videoPlayerSelected()) { - return; - } - onDestroy(); - // Unload from memory completely - Runtime.getRuntime().halt(0); - } - - @Override - public void onDestroy() { - if (DEBUG) { - Log.d(TAG, "destroy() called"); - } - cleanup(); - } - - private void cleanup() { - if (player != null) { - player.destroy(); - player = null; - } - } - - public void stopService() { - cleanup(); - stopSelf(); - } - - @Override - protected void attachBaseContext(final Context base) { - super.attachBaseContext(AudioServiceLeakFix.preventLeakOf(base)); - } - - @Override - public IBinder onBind(final Intent intent) { - return mBinder; - } - - public static class LocalBinder extends Binder { - private final WeakReference playerService; - - LocalBinder(final PlayerService playerService) { - this.playerService = new WeakReference<>(playerService); - } - - public PlayerService getService() { - return playerService.get(); - } - - public Player getPlayer() { - return playerService.get().player; - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/PlayerService.kt b/app/src/main/java/org/schabi/newpipe/player/PlayerService.kt new file mode 100644 index 00000000000..a57b84bf582 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/PlayerService.kt @@ -0,0 +1,245 @@ +/* + * 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.PlayerService.LocalBinder +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 +import java.util.Objects +import java.util.function.Consumer + +/** + * One service for all players. + */ +class PlayerService : MediaBrowserServiceCompat() { + private var player: Player? = null + + private val mBinder: IBinder = LocalBinder(this) + + private var mediaBrowserConnector: MediaBrowserConnector? = null + private val compositeDisposableLoadChildren = CompositeDisposable() + + /*////////////////////////////////////////////////////////////////////////// + // Service's LifeCycle + ////////////////////////////////////////////////////////////////////////// */ + override fun onCreate() { + super.onCreate() + + if (DEBUG) { + Log.d(TAG, "onCreate() called") + } + Localization.assureCorrectAppLanguage(this) + ThemeHelper.setTheme(this) + + mediaBrowserConnector = MediaBrowserConnector(this) + } + + private fun initializePlayerIfNeeded() { + if (player == null) { + 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().get(NotificationPlayerUi::class.java) + .ifPresent(Consumer { obj: NotificationPlayerUi? -> obj!!.createNotificationAndStartForeground() }) + } + } + + override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int { + if (DEBUG) { + Log.d( + TAG, + ( + "onStartCommand() called with: intent = [" + intent + + "], flags = [" + flags + "], startId = [" + startId + "]" + ) + ) + } + + /* + Be sure that the player notification is set and the service is started in foreground, + otherwise, the app may crash on Android 8+ as the service would never be put in the + foreground while we said to the system we would do so + The service is always requested to be started in foreground, so always creating a + notification if there is no one already and starting the service in foreground should + not create any issues + If the service is already started in foreground, requesting it to be started shouldn't + do anything + */ + if (player != null) { + player!!.UIs().get(NotificationPlayerUi::class.java) + .ifPresent(Consumer { obj: NotificationPlayerUi? -> obj!!.createNotificationAndStartForeground() }) + } + + if (Intent.ACTION_MEDIA_BUTTON == 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.java) + .ifPresent(Consumer { ui: MediaSessionPlayerUi? -> ui!!.handleMediaButtonIntent(intent) }) + + return START_NOT_STICKY + } + + fun stopForImmediateReusing() { + if (DEBUG) { + Log.d(TAG, "stopForImmediateReusing() called") + } + + if (player != null && !player!!.exoPlayerIsNull()) { + // Releases wifi & cpu, disables keepScreenOn, etc. + // We can't just pause the player here because it will make transition + // from one stream to a new stream not smooth + player!!.smoothStopForImmediateReusing() + } + } + + override fun onTaskRemoved(rootIntent: Intent?) { + super.onTaskRemoved(rootIntent) + if (player != null && !player!!.videoPlayerSelected()) { + return + } + onDestroy() + // Unload from memory completely + Runtime.getRuntime().halt(0) + } + + override fun onDestroy() { + if (DEBUG) { + Log.d(TAG, "destroy() called") + } + + cleanup() + + if (mediaBrowserConnector != null) { + mediaBrowserConnector!!.release() + mediaBrowserConnector = null + } + + compositeDisposableLoadChildren.clear() + } + + private fun cleanup() { + if (player != null) { + player!!.destroy() + player = null + } + } + + fun stopService() { + cleanup() + stopSelf() + } + + override fun attachBaseContext(base: Context?) { + super.attachBaseContext(AudioServiceLeakFix.preventLeakOf(base)) + } + + override fun onBind(intent: Intent): IBinder? { + if (SERVICE_INTERFACE == intent.action) { + // For actions related to the media browser service, pass the onBind to the superclass + return super.onBind(intent) + } + return mBinder + } + + fun getSessionConnector(): MediaSessionConnector { + return mediaBrowserConnector!!.getSessionConnector() + } + + // MediaBrowserServiceCompat methods + override fun onGetRoot( + clientPackageName: String, + clientUid: Int, + rootHints: Bundle? + ): BrowserRoot? { + return mediaBrowserConnector!!.onGetRoot(clientPackageName, clientUid, rootHints) + } + + override fun onLoadChildren( + parentId: String, + result: Result> + ) { + result.detach() + val disposable = mediaBrowserConnector!!.onLoadChildren(parentId) + .subscribe( + io.reactivex.rxjava3.functions.Consumer { + result.sendResult( + it + ) + } + ) + 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 + + init { + this.playerService = WeakReference(playerService) + } + + fun getService(): PlayerService? { + return playerService.get() + } + + fun getPlayer(): Player? = playerService.get()?.player + } + + companion object { + private val TAG: String = PlayerService::class.java.getSimpleName() + private val DEBUG = Player.DEBUG + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/event/PlayerServiceExtendedEventListener.java b/app/src/main/java/org/schabi/newpipe/player/event/PlayerServiceExtendedEventListener.java index 8effe2f0e93..15852088b6f 100644 --- a/app/src/main/java/org/schabi/newpipe/player/event/PlayerServiceExtendedEventListener.java +++ b/app/src/main/java/org/schabi/newpipe/player/event/PlayerServiceExtendedEventListener.java @@ -1,10 +1,12 @@ package org.schabi.newpipe.player.event; +import androidx.annotation.Nullable; + import org.schabi.newpipe.player.PlayerService; import org.schabi.newpipe.player.Player; public interface PlayerServiceExtendedEventListener extends PlayerServiceEventListener { - void onServiceConnected(Player player, + void onServiceConnected(@Nullable Player player, PlayerService playerService, boolean playAfterConnect); void onServiceDisconnected(); diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHolder.java b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHolder.java index b55a6547ab7..ba4fb377260 100644 --- a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHolder.java +++ b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHolder.java @@ -166,7 +166,7 @@ public void onServiceConnected(final ComponentName compName, final IBinder servi } final PlayerService.LocalBinder localBinder = (PlayerService.LocalBinder) service; - playerService = localBinder.getService(); + playerService = localBinder.getPlayer().getService(); player = localBinder.getPlayer(); if (listener != null) { listener.onServiceConnected(player, playerService, playAfterConnect); diff --git a/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserConnector.kt b/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserConnector.kt new file mode 100644 index 00000000000..3e40834b48c --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserConnector.kt @@ -0,0 +1,814 @@ +package org.schabi.newpipe.player.mediabrowser + +import android.content.ContentResolver +import android.content.Context +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 android.util.Pair +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.Single +import io.reactivex.rxjava3.core.SingleSource +import io.reactivex.rxjava3.disposables.Disposable +import io.reactivex.rxjava3.functions.Function +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.ChannelInfo +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.StreamInfo +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.lang.Exception +import java.lang.IllegalStateException +import java.lang.NullPointerException +import java.util.ArrayList +import java.util.stream.Collectors + +class MediaBrowserConnector(private val playerService: PlayerService) : PlaybackPreparer { + private val context: Context = playerService + private val sessionConnector: MediaSessionConnector + private val mediaSession: MediaSessionCompat = MediaSessionCompat(playerService, TAG) + + private val database: AppDatabase + get() = NewPipeDatabase.getInstance(context) + private val mergedPlaylists + get() = MergedPlaylistManager.getMergedOrderedPlaylists( + LocalPlaylistManager(database), + RemotePlaylistManager(database) + ) + private var prepareOrPlayDisposable: Disposable? = null + private var searchDisposable: Disposable? = null + + var bookmarksNotificationsDisposable: Disposable + + init { + sessionConnector = MediaSessionConnector(mediaSession) + sessionConnector.setMetadataDeduplicationEnabled(true) + sessionConnector.setPlaybackPreparer(this) + playerService.setSessionToken(mediaSession.sessionToken) + + bookmarksNotificationsDisposable = mergedPlaylists.subscribe( + { playlistMetadataEntries -> + playerService.notifyChildrenChanged( + ID_BOOKMARKS + ) + } + ) + } + + fun getSessionConnector(): MediaSessionConnector { + return sessionConnector + } + + fun release() { + disposePrepareOrPlayCommands() + bookmarksNotificationsDisposable.dispose() + 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 = context.resources + builder.setIconUri( + Uri.Builder() + .scheme(ContentResolver.SCHEME_ANDROID_RESOURCE) + .authority(resources.getResourcePackageName(iconResId)) + .appendPath(resources.getResourceTypeName(iconResId)) + .appendPath(resources.getResourceEntryName(iconResId)) + .build() + ) + + val extras = Bundle() + extras.putString( + MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE, + context.getString(R.string.app_name) + ) + builder.setExtras(extras) + return MediaBrowserCompat.MediaItem( + builder.build(), + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ) + } + + private fun createPlaylistMediaItem(playlist: PlaylistLocalItem): MediaBrowserCompat.MediaItem { + val builder = MediaDescriptionCompat.Builder() + builder + .setMediaId(createMediaIdForInfoItem(playlist is PlaylistRemoteEntity, playlist.uid)) + .setTitle(playlist.orderingName) + .setIconUri(playlist.thumbnailUrl?.let { Uri.parse(it) }) + + val extras = Bundle() + extras.putString( + MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE, + context.resources.getString(R.string.tab_bookmarks), + ) + builder.setExtras(extras) + return MediaBrowserCompat.MediaItem( + builder.build(), + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE, + ) + } + + private fun createInfoItemMediaItem(item: InfoItem): MediaBrowserCompat.MediaItem { + val builder = MediaDescriptionCompat.Builder() + builder.setMediaId(createMediaIdForInfoItem(item)) + .setTitle(item.name) + + when (item.infoType) { + InfoType.STREAM -> builder.setSubtitle((item as StreamInfoItem).uploaderName) + InfoType.PLAYLIST -> builder.setSubtitle((item as PlaylistInfoItem).uploaderName) + InfoType.CHANNEL -> builder.setSubtitle((item as ChannelInfoItem).description) + else -> {} + } + val thumbnails = item.thumbnails + if (!thumbnails.isEmpty()) { + builder.setIconUri(Uri.parse(thumbnails.get(0)!!.url)) + } + return MediaBrowserCompat.MediaItem( + builder.build(), + MediaBrowserCompat.MediaItem.FLAG_PLAYABLE + ) + } + + private fun buildMediaId(): Uri.Builder { + return Uri.Builder().authority(ID_AUTHORITY) + } + + private fun buildPlaylistMediaId(playlistType: String?): Uri.Builder { + return buildMediaId() + .appendPath(ID_BOOKMARKS) + .appendPath(playlistType) + } + + private fun buildLocalPlaylistItemMediaId(isRemote: Boolean, playlistId: Long): Uri.Builder { + return buildPlaylistMediaId(if (isRemote) ID_REMOTE else ID_LOCAL) + .appendPath(playlistId.toString()) + } + + private fun buildInfoItemMediaId(item: InfoItem): Uri.Builder { + return buildMediaId() + .appendPath(ID_INFO_ITEM) + .appendPath(infoItemTypeToString(item.infoType)) + .appendPath(item.serviceId.toString()) + .appendQueryParameter(ID_URL, item.url) + } + + private fun createMediaIdForInfoItem(isRemote: Boolean, playlistId: Long): String { + return buildLocalPlaylistItemMediaId(isRemote, playlistId) + .build().toString() + } + + private fun createLocalPlaylistStreamMediaItem( + playlistId: Long, + item: PlaylistStreamEntry, + index: Int + ): MediaBrowserCompat.MediaItem { + val builder = MediaDescriptionCompat.Builder() + builder.setMediaId(createMediaIdForPlaylistIndex(false, playlistId, index)) + .setTitle(item.streamEntity.title) + .setSubtitle(item.streamEntity.uploader) + .setIconUri(Uri.parse(item.streamEntity.thumbnailUrl)) + + return MediaBrowserCompat.MediaItem( + builder.build(), + MediaBrowserCompat.MediaItem.FLAG_PLAYABLE + ) + } + + private fun createRemotePlaylistStreamMediaItem( + playlistId: Long, + item: StreamInfoItem, + index: Int + ): MediaBrowserCompat.MediaItem { + val builder = MediaDescriptionCompat.Builder() + builder.setMediaId(createMediaIdForPlaylistIndex(true, playlistId, index)) + .setTitle(item.name) + .setSubtitle(item.uploaderName) + val thumbnails = item.thumbnails + if (!thumbnails.isEmpty()) { + builder.setIconUri(Uri.parse(thumbnails.get(0)!!.url)) + } + + return MediaBrowserCompat.MediaItem( + builder.build(), + MediaBrowserCompat.MediaItem.FLAG_PLAYABLE + ) + } + + private fun createMediaIdForPlaylistIndex( + isRemote: Boolean, + playlistId: Long, + index: Int + ): String { + return buildLocalPlaylistItemMediaId(isRemote, playlistId) + .appendPath(index.toString()) + .build().toString() + } + + private fun createMediaIdForInfoItem(item: InfoItem): String { + return buildInfoItemMediaId(item).build().toString() + } + + 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: MutableList = ArrayList(parentIdUri.pathSegments) + + if (path.isEmpty()) { + val mediaItems: MutableList = + ArrayList() + mediaItems.add( + createRootMediaItem( + ID_BOOKMARKS, + context.resources.getString( + R.string.tab_bookmarks_short + ), + R.drawable.ic_bookmark_white + ) + ) + mediaItems.add( + createRootMediaItem( + ID_HISTORY, + context.resources.getString(R.string.action_history), + R.drawable.ic_history_white + ) + ) + return Single.just(mediaItems) + } + + val uriType = path.get(0) + path.removeAt(0) + + when (uriType) { + ID_BOOKMARKS -> { + if (path.isEmpty()) { + return populateBookmarks() + } + if (path.size == 2) { + val localOrRemote = path.get(0) + val playlistId = path.get(1).toLong() + if (localOrRemote == ID_LOCAL) { + return populateLocalPlaylist(playlistId) + } else if (localOrRemote == ID_REMOTE) { + return populateRemotePlaylist(playlistId) + } + } + Log.w(TAG, "Unknown playlist URI: " + parentId) + throw parseError(parentId) + } + + ID_HISTORY -> return populateHistory() + else -> throw parseError(parentId) + } + } catch (e: ContentNotAvailableException) { + return Single.error(e) + } + } + + private fun populateHistory(): Single> { + val history = database.streamHistoryDAO().getHistory().firstOrError() + return history.map>( + Function { items -> + items.stream() + .map { streamHistoryEntry: StreamHistoryEntry? -> + this.createHistoryMediaItem( + streamHistoryEntry!! + ) + } + .collect(Collectors.toList()) + } + ) + } + + 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 populateBookmarks(): Single> { + val playlists = mergedPlaylists.firstOrError() + return playlists.map>( + { playlist: List -> + playlist.stream() + .map { playlist: PlaylistLocalItem? -> + this.createPlaylistMediaItem( + playlist!! + ) + } + .collect(Collectors.toList()) + } + ) + } + + private fun populateLocalPlaylist(playlistId: Long): Single> { + val playlist = LocalPlaylistManager(database).getPlaylistStreams(playlistId).firstOrError() + return playlist.map>( + { items: List -> + val results: MutableList = + ArrayList() + var index = 0 + for (item in items) { + results.add(createLocalPlaylistStreamMediaItem(playlistId, item, index)) + ++index + } + results + } + ) + } + + private fun getRemotePlaylist(playlistId: Long): Single>> { + val playlistFlow = RemotePlaylistManager(database).getPlaylist(playlistId).firstOrError() + return playlistFlow.flatMap>>( + { item: List -> + val playlist = item.get(0) + val playlistInfo = ExtractorHelper.getPlaylistInfo( + playlist.serviceId, + playlist.url, false + ) + playlistInfo.flatMap>>( + { info: PlaylistInfo -> + val infoItemsPage = info.relatedItems + if (!info.errors.isEmpty()) { + val errors: MutableList = ArrayList(info.errors) + + errors.removeIf { obj: Throwable? -> + ContentNotSupportedException::class.java.isInstance( + obj + ) + } + + if (!errors.isEmpty()) { + return@flatMap Single.error( + errors.get(0) + ) + } + } + Single.just>>( + infoItemsPage.withIndex().map { + Pair(it.value, it.index) + } + ) + } + ) + } + ) + } + + private fun populateRemotePlaylist(playlistId: Long): Single> { + return 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(context.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(database).getPlaylistStreams(playlistId) + .firstOrError() + .map( + { items: MutableList? -> + 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 { pair -> pair.first } + SinglePlayQueue(infoItems, index) + } + ) + } + + private fun extractPlayQueueFromMediaId(mediaId: String): Single { + try { + val mediaIdUri = Uri.parse(mediaId) + val path: MutableList = ArrayList(mediaIdUri.pathSegments) + + if (path.isEmpty()) { + throw parseError(mediaId) + } + + val uriType: String? = path.get(0) + path.removeAt(0) + + return when (uriType) { + ID_BOOKMARKS -> extractPlayQueueFromPlaylistMediaId( + mediaId, + path, + mediaIdUri.getQueryParameter(ID_URL) + ) + + ID_HISTORY -> extractPlayQueueFromHistoryMediaId(mediaId, path) + ID_INFO_ITEM -> extractPlayQueueFromInfoItemMediaId( + mediaId, + path, + mediaIdUri.getQueryParameter(ID_URL) + ) + + else -> throw parseError(mediaId) + } + } catch (e: ContentNotAvailableException) { + return Single.error(e) + } + } + + @Throws(ContentNotAvailableException::class) + private fun extractPlayQueueFromPlaylistMediaId( + mediaId: String, + path: MutableList, + url: String? + ): Single { + if (path.isEmpty()) { + throw parseError(mediaId) + } + + val playlistType = path.get(0) + path.removeAt(0) + + when (playlistType) { + ID_LOCAL, ID_REMOTE -> { + if (path.size != 2) { + throw parseError(mediaId) + } + val playlistId = path.get(0).toLong() + val index = path.get(1).toInt() + return if (playlistType == ID_LOCAL) + extractLocalPlayQueue(playlistId, index) + else + extractRemotePlayQueue(playlistId, index) + } + + ID_URL -> { + if (path.size != 1) { + throw parseError(mediaId) + } + + val serviceId = path.get(0).toInt() + return ExtractorHelper.getPlaylistInfo(serviceId, url, false) + .map({ info: PlaylistInfo? -> PlaylistPlayQueue(info) }) + } + + else -> throw parseError(mediaId) + } + } + + @Throws(ContentNotAvailableException::class) + private fun extractPlayQueueFromHistoryMediaId( + mediaId: String, + path: List + ): Single { + if (path.size != 1) { + throw parseError(mediaId) + } + + val streamId = path.get(0).toLong() + return database.streamHistoryDAO().getHistory() + .firstOrError() + .map( + Function { items: MutableList? -> + val infoItems = items!!.stream() + .filter { it: StreamHistoryEntry? -> it!!.streamId == streamId } + .map { obj: StreamHistoryEntry? -> obj!!.toStreamInfoItem() } + .collect(Collectors.toList()) + SinglePlayQueue(infoItems, 0) + } + ) + } + + override fun getSupportedPrepareActions(): Long { + return 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: PlayQueue? -> + sessionConnector.setCustomErrorMessage(null) + NavigationHelper.playOnBackgroundPlayer( + context, playQueue, + playWhenReady + ) + }, + { throwable: 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(context) + return ExtractorHelper.searchFor( + serviceId, query, + ArrayList(), "" + ) + } + + private fun mediaItemsFromInfoItemList(result: ListInfo): SingleSource> { + val exceptions = result.errors + if (!exceptions.isEmpty() && + !( + exceptions.size == 1 && + exceptions.get(0) is NothingFoundException + ) + ) { + return Single.error(exceptions.get(0)) + } + + val items = result.getRelatedItems() + if (items.isEmpty()) { + return Single.error(NullPointerException("Got no search results.")) + } + try { + val results = items + .filter { item: InfoItem -> item.infoType == InfoType.STREAM || item.infoType == InfoType.PLAYLIST || item.infoType == InfoType.CHANNEL } + .map { item: InfoItem -> + this.createInfoItemMediaItem( + item + ) + } + return Single.just(results) + } catch (e: Exception) { + return Single.error(e) + } + } + + private fun handleSearchError(throwable: Throwable) { + Log.e(TAG, "Search error: " + throwable) + disposePrepareOrPlayCommands() + sessionConnector.setCustomErrorMessage( + context.getString(R.string.search_no_results), + PlaybackStateCompat.ERROR_CODE_APP_ERROR, + ) + } + + override fun onPrepareFromUri( + uri: Uri, + playWhenReady: Boolean, + extras: Bundle? + ) { + disposePrepareOrPlayCommands() + playbackError(R.string.content_not_supported, PlaybackStateCompat.ERROR_CODE_NOT_SUPPORTED) + } + + override fun onCommand( + player: Player, + command: String, + extras: Bundle?, + cb: ResultReceiver? + ): Boolean { + return false + } + + fun onSearch( + query: String, + result: MediaBrowserServiceCompat.Result> + ) { + result.detach() + if (searchDisposable != null) { + searchDisposable!!.dispose() + } + searchDisposable = searchMusicBySongTitle(query) + .flatMap> + { + this.mediaItemsFromInfoItemList( + it + ) + } + .subscribeOn(Schedulers.io()) + .subscribe( + { result.sendResult(it) }, + { throwable: Throwable -> this.handleSearchError(throwable) } + ) + } + + companion object { + private val TAG: String = MediaBrowserConnector::class.java.getSimpleName() + + private val ID_AUTHORITY = BuildConfig.APPLICATION_ID + private val ID_ROOT = "//" + ID_AUTHORITY + private const val ID_BOOKMARKS = "playlists" + private const val ID_HISTORY = "history" + private const val ID_INFO_ITEM = "item" + + private const val ID_LOCAL = "local" + private const val ID_REMOTE = "remote" + private const val ID_URL = "url" + private const val ID_STREAM = "stream" + private const val ID_PLAYLIST = "playlist" + private const val ID_CHANNEL = "channel" + + private fun infoItemTypeToString(type: InfoType): String { + return when (type) { + InfoType.STREAM -> ID_STREAM + InfoType.PLAYLIST -> ID_PLAYLIST + InfoType.CHANNEL -> ID_CHANNEL + else -> throw IllegalStateException("Unexpected value: " + type) + } + } + + private fun infoItemTypeFromString(type: String): InfoType { + return when (type) { + ID_STREAM -> InfoType.STREAM + ID_PLAYLIST -> InfoType.PLAYLIST + ID_CHANNEL -> InfoType.CHANNEL + else -> throw IllegalStateException("Unexpected value: " + type) + } + } + + private fun parseError(mediaId: String): ContentNotAvailableException { + return ContentNotAvailableException("Failed to parse media ID $mediaId") + } + + @Throws(ContentNotAvailableException::class) + private fun extractPlayQueueFromInfoItemMediaId( + mediaId: String, + path: List, + url: String? + ): Single { + if (path.size != 2) { + throw parseError(mediaId) + } + val infoItemType = infoItemTypeFromString(path.get(0)) + val serviceId = path.get(1).toInt() + return when (infoItemType) { + InfoType.STREAM -> ExtractorHelper.getStreamInfo(serviceId, url, false) + .map(Function { info: StreamInfo? -> SinglePlayQueue(info) }) + + InfoType.PLAYLIST -> ExtractorHelper.getPlaylistInfo(serviceId, url, false) + .map(Function { info: PlaylistInfo? -> PlaylistPlayQueue(info) }) + + InfoType.CHANNEL -> { + ExtractorHelper.getChannelInfo(serviceId, url, false) + .map { info: ChannelInfo -> + val playableTab = info.tabs + .stream() + .filter { tab: ListLinkHandler? -> ChannelTabHelper.isStreamsTab(tab) } + .findFirst() + if (playableTab.isPresent) { + return@map ChannelTabPlayQueue( + serviceId, + ListLinkHandler(playableTab.get()) + ) + } else { + throw ContentNotAvailableException("No streams tab found") + } + } + } + + else -> throw parseError(mediaId) + } + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/mediasession/MediaSessionPlayerUi.java b/app/src/main/java/org/schabi/newpipe/player/mediasession/MediaSessionPlayerUi.java index c673e688c47..8b69db82fa9 100644 --- a/app/src/main/java/org/schabi/newpipe/player/mediasession/MediaSessionPlayerUi.java +++ b/app/src/main/java/org/schabi/newpipe/player/mediasession/MediaSessionPlayerUi.java @@ -50,8 +50,11 @@ public class MediaSessionPlayerUi extends PlayerUi private List prevNotificationActions = List.of(); - public MediaSessionPlayerUi(@NonNull final Player player) { + public MediaSessionPlayerUi(@NonNull final Player player, + @NonNull final MediaSessionConnector sessionConnector) { super(player); + this.mediaSession = sessionConnector.mediaSession; + this.sessionConnector = sessionConnector; ignoreHardwareMediaButtonsKey = context.getString(R.string.ignore_hardware_media_buttons_key); } @@ -61,10 +64,8 @@ public void initPlayer() { super.initPlayer(); destroyPlayer(); // release previously used resources - mediaSession = new MediaSessionCompat(context, TAG); mediaSession.setActive(true); - sessionConnector = new MediaSessionConnector(mediaSession); sessionConnector.setQueueNavigator(new PlayQueueNavigator(mediaSession, player)); sessionConnector.setPlayer(getForwardingPlayer()); @@ -77,7 +78,6 @@ public void initPlayer() { updateShouldIgnoreHardwareMediaButtons(player.getPrefs()); player.getPrefs().registerOnSharedPreferenceChangeListener(this); - sessionConnector.setMetadataDeduplicationEnabled(true); sessionConnector.setMediaMetadataProvider(exoPlayer -> buildMediaMetadata()); // force updating media session actions by resetting the previous ones @@ -89,27 +89,23 @@ public void initPlayer() { public void destroyPlayer() { super.destroyPlayer(); player.getPrefs().unregisterOnSharedPreferenceChangeListener(this); - if (sessionConnector != null) { - sessionConnector.setMediaButtonEventHandler(null); - sessionConnector.setPlayer(null); - sessionConnector.setQueueNavigator(null); - sessionConnector = null; - } - if (mediaSession != null) { - mediaSession.setActive(false); - mediaSession.release(); - mediaSession = null; - } + + sessionConnector.setMediaButtonEventHandler(null); + sessionConnector.setPlayer(null); + sessionConnector.setQueueNavigator(null); + sessionConnector.setMediaMetadataProvider(null); + + mediaSession.setActive(false); + prevNotificationActions = List.of(); } @Override public void onThumbnailLoaded(@Nullable final Bitmap bitmap) { super.onThumbnailLoaded(bitmap); - if (sessionConnector != null) { - // the thumbnail is now loaded: invalidate the metadata to trigger a metadata update - sessionConnector.invalidateMediaSessionMetadata(); - } + + // the thumbnail is now loaded: invalidate the metadata to trigger a metadata update + sessionConnector.invalidateMediaSessionMetadata(); } @@ -132,7 +128,7 @@ public void handleMediaButtonIntent(final Intent intent) { } public Optional getSessionToken() { - return Optional.ofNullable(mediaSession).map(MediaSessionCompat::getSessionToken); + return Optional.of(mediaSession.getSessionToken()); } diff --git a/app/src/main/res/drawable/ic_bookmark_white.xml b/app/src/main/res/drawable/ic_bookmark_white.xml new file mode 100644 index 00000000000..a04ed256e9d --- /dev/null +++ b/app/src/main/res/drawable/ic_bookmark_white.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_history_white.xml b/app/src/main/res/drawable/ic_history_white.xml new file mode 100644 index 00000000000..585285b890c --- /dev/null +++ b/app/src/main/res/drawable/ic_history_white.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2c27e6cbb76..529ef0d9d6b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -33,6 +33,7 @@ Show info Subscriptions Bookmarked Playlists + Playlists Choose Tab Background Popup diff --git a/app/src/main/res/xml/automotive_app_desc.xml b/app/src/main/res/xml/automotive_app_desc.xml new file mode 100644 index 00000000000..90e6f30efe6 --- /dev/null +++ b/app/src/main/res/xml/automotive_app_desc.xml @@ -0,0 +1,3 @@ + + +