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 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/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..10c750f7b51 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/PlayerService.kt @@ -0,0 +1,229 @@ +/* + * 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 var player: Player? = null + + private val mBinder: IBinder = LocalBinder(this) + private val disposables = CompositeDisposable() + private var _mediaBrowserConnector: MediaBrowserConnector? = null + private val mediaBrowserConnector: MediaBrowserConnector + get() { + return _mediaBrowserConnector ?: run { + val newMediaBrowserConnector = MediaBrowserConnector(this) + _mediaBrowserConnector = newMediaBrowserConnector + newMediaBrowserConnector + } + } + + 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) + + 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 { + it.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 + */ + player?.UIs()?.get(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()?.get(MediaSessionPlayerUi::class.java)?.ifPresent { + it.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() { + super.onDestroy() + if (DEBUG) { + Log.d(TAG, "destroy() called") + } + + cleanup() + + mediaBrowserConnector.release() + _mediaBrowserConnector = null + + disposables.clear() + } + + private fun cleanup() { + 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 + } + + // MediaBrowserServiceCompat methods (they defer function calls to mediaBrowserConnector) + 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) + } + disposables.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) + + fun getPlayer(): Player? = playerService.get()?.player + } + + 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/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..3228753354c --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserConnector.kt @@ -0,0 +1,746 @@ +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() + 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, + 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( + isRemote: Boolean, + playlistId: Long, + ) = buildPlaylistMediaId(if (isRemote) 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( + isRemote: Boolean, + playlistId: Long, + ) = 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) + item.thumbnails.firstOrNull()?.let { + builder.setIconUri(Uri.parse(it.url)) + } + + return MediaBrowserCompat.MediaItem( + builder.build(), + MediaBrowserCompat.MediaItem.FLAG_PLAYABLE + ) + } + + private fun createMediaIdForPlaylistIndex( + isRemote: Boolean, + playlistId: Long, + index: Int, + ) = buildLocalPlaylistItemMediaId(isRemote, 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 = ArrayList(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(parentId) + } + + ID_HISTORY -> return populateHistory() + else -> throw parseError(parentId) + } + } 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() + } + + 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 = ArrayList(mediaIdUri.pathSegments) + + if (path.isEmpty()) { + throw parseError(mediaId) + } + + val uriType = path[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 (error: ContentNotAvailableException) { + return Single.error(error) + } + } + + @Throws(ContentNotAvailableException::class) + private fun extractPlayQueueFromPlaylistMediaId( + mediaId: String, + mediaIdSegments: ArrayList, + url: String?, + ): Single { + if (mediaIdSegments.isEmpty()) { + throw parseError(mediaId) + } + + val playlistType = mediaIdSegments.first() + mediaIdSegments.removeAt(0) + + when (playlistType) { + ID_LOCAL, ID_REMOTE -> { + if (mediaIdSegments.size != 2) { + throw parseError(mediaId) + } + 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(mediaId) + } + + val serviceId = mediaIdSegments[0].toInt() + return ExtractorHelper + .getPlaylistInfo(serviceId, url, false) + .map(::PlaylistPlayQueue) + } + + else -> throw parseError(mediaId) + } + } + + @Throws(ContentNotAvailableException::class) + private fun extractPlayQueueFromHistoryMediaId( + mediaId: String, + path: List + ): Single { + if (path.size != 1) { + throw parseError(mediaId) + } + + val streamId = path[0].toLong() + return database + .streamHistoryDAO() + .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() + sessionConnector.setCustomErrorMessage( + playerService.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?, + ) = 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(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[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(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 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 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 @@ + + +