From 8f19f95feed377e84ad6f97db5864faa60bd7ff5 Mon Sep 17 00:00:00 2001 From: Stypox Date: Mon, 2 Feb 2026 19:37:39 +0100 Subject: [PATCH] Show loading when action takes some time Convert RxJava3 calls to suspend functions, sometimes requiring .await() to bridge between the two Also migrate play queue items' popup menu to new long press menu Also do centralized error handling --- .../org/schabi/newpipe/QueueItemMenuUtil.java | 94 --------- .../org/schabi/newpipe/RouterActivity.java | 18 +- .../org/schabi/newpipe/error/UserAction.kt | 4 +- .../fragments/detail/VideoDetailFragment.kt | 2 +- .../list/playlist/PlaylistFragment.java | 18 +- .../newpipe/local/dialog/PlaylistDialog.java | 24 +-- .../newpipe/player/PlayQueueActivity.java | 11 +- .../newpipe/player/playqueue/PlayQueueItem.kt | 9 + .../newpipe/player/ui/MainPlayerUi.java | 11 +- ...ectDragModifier.kt => GestureModifiers.kt} | 21 ++ .../ui/components/menu/LongPressAction.kt | 196 ++++++++++-------- .../ui/components/menu/LongPressMenu.kt | 87 ++++++-- .../ui/components/menu/LongPressable.kt | 16 +- .../ui/components/menu/SparseItemUtil.kt | 106 ++++++++++ .../schabi/newpipe/util/SparseItemUtil.java | 127 ------------ 15 files changed, 375 insertions(+), 369 deletions(-) delete mode 100644 app/src/main/java/org/schabi/newpipe/QueueItemMenuUtil.java rename app/src/main/java/org/schabi/newpipe/ui/{DetectDragModifier.kt => GestureModifiers.kt} (84%) create mode 100644 app/src/main/java/org/schabi/newpipe/ui/components/menu/SparseItemUtil.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/util/SparseItemUtil.java diff --git a/app/src/main/java/org/schabi/newpipe/QueueItemMenuUtil.java b/app/src/main/java/org/schabi/newpipe/QueueItemMenuUtil.java deleted file mode 100644 index e6177f6a3..000000000 --- a/app/src/main/java/org/schabi/newpipe/QueueItemMenuUtil.java +++ /dev/null @@ -1,94 +0,0 @@ -package org.schabi.newpipe; - -import static org.schabi.newpipe.util.SparseItemUtil.fetchStreamInfoAndSaveToDatabase; -import static org.schabi.newpipe.util.external_communication.ShareUtils.shareText; - -import android.content.Context; -import android.view.ContextThemeWrapper; -import android.view.View; -import android.widget.PopupMenu; - -import androidx.fragment.app.FragmentManager; - -import org.schabi.newpipe.database.stream.model.StreamEntity; -import org.schabi.newpipe.download.DownloadDialog; -import org.schabi.newpipe.local.dialog.PlaylistDialog; -import org.schabi.newpipe.player.playqueue.PlayQueue; -import org.schabi.newpipe.player.playqueue.PlayQueueItem; -import org.schabi.newpipe.util.NavigationHelper; -import org.schabi.newpipe.util.SparseItemUtil; - -import java.util.List; - -public final class QueueItemMenuUtil { - private QueueItemMenuUtil() { - } - - public static void openPopupMenu(final PlayQueue playQueue, - final PlayQueueItem item, - final View view, - final boolean hideDetails, - final FragmentManager fragmentManager, - final Context context) { - final ContextThemeWrapper themeWrapper = - new ContextThemeWrapper(context, R.style.DarkPopupMenu); - - final PopupMenu popupMenu = new PopupMenu(themeWrapper, view); - popupMenu.inflate(R.menu.menu_play_queue_item); - - if (hideDetails) { - popupMenu.getMenu().findItem(R.id.menu_item_details).setVisible(false); - } - - popupMenu.setOnMenuItemClickListener(menuItem -> { - switch (menuItem.getItemId()) { - case R.id.menu_item_remove: - final int index = playQueue.indexOf(item); - playQueue.remove(index); - return true; - case R.id.menu_item_details: - // playQueue is null since we don't want any queue change - NavigationHelper.openVideoDetail(context, item.getServiceId(), - item.getUrl(), item.getTitle(), null, - false); - return true; - case R.id.menu_item_append_playlist: - PlaylistDialog.createCorrespondingDialog( - context, - List.of(new StreamEntity(item)), - dialog -> dialog.show( - fragmentManager, - "QueueItemMenuUtil@append_playlist" - ) - ); - - return true; - case R.id.menu_item_channel_details: - SparseItemUtil.fetchUploaderUrlIfSparse(context, item.getServiceId(), - item.getUrl(), item.getUploaderUrl(), - // An intent must be used here. - // Opening with FragmentManager transactions is not working, - // as PlayQueueActivity doesn't use fragments. - uploaderUrl -> NavigationHelper.openChannelFragmentUsingIntent( - context, item.getServiceId(), uploaderUrl, item.getUploader() - )); - return true; - case R.id.menu_item_share: - shareText(context, item.getTitle(), item.getUrl(), - item.getThumbnails()); - return true; - case R.id.menu_item_download: - fetchStreamInfoAndSaveToDatabase(context, item.getServiceId(), item.getUrl(), - info -> { - final DownloadDialog downloadDialog = new DownloadDialog(context, - info); - downloadDialog.show(fragmentManager, "downloadDialog"); - }); - return true; - } - return false; - }); - - popupMenu.show(); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/RouterActivity.java b/app/src/main/java/org/schabi/newpipe/RouterActivity.java index 6d3863018..587e94ebe 100644 --- a/app/src/main/java/org/schabi/newpipe/RouterActivity.java +++ b/app/src/main/java/org/schabi/newpipe/RouterActivity.java @@ -823,15 +823,15 @@ public class RouterActivity extends AppCompatActivity { .compose(this::pleaseWait) .subscribe( info -> getActivityContext().ifPresent(context -> - PlaylistDialog.createCorrespondingDialog(context, - List.of(new StreamEntity(info)), - playlistDialog -> runOnVisible(ctx -> { - // dismiss listener to be handled by FragmentManager - final FragmentManager fm = - ctx.getSupportFragmentManager(); - playlistDialog.show(fm, "addToPlaylistDialog"); - }) - )), + disposables.add( + PlaylistDialog.createCorrespondingDialog(context, + List.of(new StreamEntity(info))) + .subscribe(dialog -> runOnVisible(ctx -> { + // dismiss listener to be handled by FragmentManager + final FragmentManager fm = + ctx.getSupportFragmentManager(); + dialog.show(fm, "addToPlaylistDialog"); + })))), throwable -> runOnVisible(ctx -> handleError(ctx, new ErrorInfo( throwable, UserAction.REQUESTED_STREAM, "Tried to add " + currentUrl + " to a playlist", diff --git a/app/src/main/java/org/schabi/newpipe/error/UserAction.kt b/app/src/main/java/org/schabi/newpipe/error/UserAction.kt index b3f14e2da..1b0d35755 100644 --- a/app/src/main/java/org/schabi/newpipe/error/UserAction.kt +++ b/app/src/main/java/org/schabi/newpipe/error/UserAction.kt @@ -37,8 +37,8 @@ enum class UserAction(val message: String) { PREFERENCES_MIGRATION("migration of preferences"), SHARE_TO_NEWPIPE("share to newpipe"), CHECK_FOR_NEW_APP_VERSION("check for new app version"), - OPEN_INFO_ITEM_DIALOG("open info item dialog"), GETTING_MAIN_SCREEN_TAB("getting main screen tab"), PLAY_ON_POPUP("play on popup"), - SUBSCRIPTIONS("loading subscriptions") + SUBSCRIPTIONS("loading subscriptions"), + LONG_PRESS_MENU_ACTION("long press menu action"), } diff --git a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.kt b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.kt index aa3fad60c..6d8a20630 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.kt +++ b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.kt @@ -439,7 +439,7 @@ class VideoDetailFragment : PlaylistDialog.createCorrespondingDialog( requireContext(), listOf(StreamEntity(info)) - ) { dialog -> dialog.show(getParentFragmentManager(), TAG) } + ).subscribe { dialog -> dialog.show(getParentFragmentManager(), TAG) } ) } } diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/playlist/PlaylistFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/playlist/PlaylistFragment.java index 634e2520a..5cee18136 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/playlist/PlaylistFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/playlist/PlaylistFragment.java @@ -237,15 +237,15 @@ public class PlaylistFragment extends BaseListInfoFragment dialog.show(getFM(), TAG) - )); + disposables.add( + PlaylistDialog.createCorrespondingDialog( + getContext(), + getPlayQueue() + .getStreams() + .stream() + .map(StreamEntity::new) + .collect(Collectors.toList()) + ).subscribe(dialog -> dialog.show(getFM(), TAG))); } break; default: 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 612c38181..ddc84e783 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 @@ -20,11 +20,11 @@ import org.schabi.newpipe.util.StateSaver; import java.util.List; import java.util.Objects; import java.util.Queue; -import java.util.function.Consumer; import java.util.stream.Collectors; import java.util.stream.Stream; import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; +import io.reactivex.rxjava3.core.Maybe; import io.reactivex.rxjava3.disposables.Disposable; public abstract class PlaylistDialog extends DialogFragment implements StateSaver.WriteRead { @@ -135,22 +135,18 @@ public abstract class PlaylistDialog extends DialogFragment implements StateSave * * @param context context used for accessing the database * @param streamEntities used for crating the dialog - * @param onExec execution that should occur after a dialog got created, e.g. showing it - * @return the disposable that was created + * @return the {@link Maybe} to subscribe to to obtain the correct {@link PlaylistDialog} */ - public static Disposable createCorrespondingDialog( + public static Maybe createCorrespondingDialog( final Context context, - final List streamEntities, - final Consumer onExec) { + final List streamEntities) { return new LocalPlaylistManager(NewPipeDatabase.getInstance(context)) .hasPlaylists() - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(hasPlaylists -> - onExec.accept(hasPlaylists - ? PlaylistAppendDialog.newInstance(streamEntities) - : PlaylistCreationDialog.newInstance(streamEntities)) - ); + .map(hasPlaylists -> hasPlaylists + ? PlaylistAppendDialog.newInstance(streamEntities) + : PlaylistCreationDialog.newInstance(streamEntities)) + .observeOn(AndroidSchedulers.mainThread()); } /** @@ -175,7 +171,7 @@ public abstract class PlaylistDialog extends DialogFragment implements StateSave return Disposable.empty(); } - return PlaylistDialog.createCorrespondingDialog(player.getContext(), streamEntities, - dialog -> dialog.show(fragmentManager, "PlaylistDialog")); + return PlaylistDialog.createCorrespondingDialog(player.getContext(), streamEntities) + .subscribe(dialog -> dialog.show(fragmentManager, "PlaylistDialog")); } } 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 7f3a8dbd5..6d1da1bed 100644 --- a/app/src/main/java/org/schabi/newpipe/player/PlayQueueActivity.java +++ b/app/src/main/java/org/schabi/newpipe/player/PlayQueueActivity.java @@ -1,7 +1,7 @@ package org.schabi.newpipe.player; -import static org.schabi.newpipe.QueueItemMenuUtil.openPopupMenu; import static org.schabi.newpipe.player.helper.PlayerHelper.formatSpeed; +import static org.schabi.newpipe.ui.components.menu.LongPressMenuKt.openLongPressMenuInActivity; import android.content.ComponentName; import android.content.Intent; @@ -41,6 +41,8 @@ import org.schabi.newpipe.player.playqueue.PlayQueueItem; import org.schabi.newpipe.player.playqueue.PlayQueueItemBuilder; import org.schabi.newpipe.player.playqueue.PlayQueueItemHolder; import org.schabi.newpipe.player.playqueue.PlayQueueItemTouchCallback; +import org.schabi.newpipe.ui.components.menu.LongPressAction; +import org.schabi.newpipe.ui.components.menu.LongPressable; import org.schabi.newpipe.util.Localization; import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.PermissionHelper; @@ -328,8 +330,11 @@ public final class PlayQueueActivity extends AppCompatActivity @Override public void held(final PlayQueueItem item, final View view) { if (player != null && player.getPlayQueue().indexOf(item) != -1) { - openPopupMenu(player.getPlayQueue(), item, view, false, - getSupportFragmentManager(), PlayQueueActivity.this); + openLongPressMenuInActivity( + PlayQueueActivity.this, + LongPressable.fromPlayQueueItem(item), + LongPressAction.fromPlayQueueItem(item, player.getPlayQueue(), true) + ); } } diff --git a/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueItem.kt b/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueItem.kt index 0e7a3b90b..96e2578f5 100644 --- a/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueItem.kt +++ b/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueItem.kt @@ -65,6 +65,15 @@ class PlayQueueItem private constructor( .subscribeOn(Schedulers.io()) .doOnError { throwable -> error = throwable } + fun toStreamInfoItem(): StreamInfoItem { + val item = StreamInfoItem(serviceId, url, title, streamType) + item.duration = duration + item.thumbnails = thumbnails + item.uploaderName = uploader + item.uploaderUrl = uploaderUrl + return item + } + override fun equals(o: Any?) = o is PlayQueueItem && serviceId == o.serviceId && url == o.url override fun hashCode() = Objects.hash(url, serviceId) diff --git a/app/src/main/java/org/schabi/newpipe/player/ui/MainPlayerUi.java b/app/src/main/java/org/schabi/newpipe/player/ui/MainPlayerUi.java index 034e18368..717d1a7fd 100644 --- a/app/src/main/java/org/schabi/newpipe/player/ui/MainPlayerUi.java +++ b/app/src/main/java/org/schabi/newpipe/player/ui/MainPlayerUi.java @@ -2,7 +2,6 @@ package org.schabi.newpipe.player.ui; import static android.view.ViewGroup.LayoutParams.MATCH_PARENT; import static org.schabi.newpipe.MainActivity.DEBUG; -import static org.schabi.newpipe.QueueItemMenuUtil.openPopupMenu; import static org.schabi.newpipe.extractor.ServiceList.YouTube; import static org.schabi.newpipe.ktx.ViewUtils.animate; import static org.schabi.newpipe.player.Player.STATE_COMPLETED; @@ -14,6 +13,7 @@ import static org.schabi.newpipe.player.helper.PlayerHelper.getMinimizeOnExitAct import static org.schabi.newpipe.player.helper.PlayerHelper.getTimeString; import static org.schabi.newpipe.player.helper.PlayerHelper.globalScreenOrientationLocked; import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_PLAY_PAUSE; +import static org.schabi.newpipe.ui.components.menu.LongPressMenuKt.openLongPressMenuInActivity; import android.app.Activity; import android.content.Context; @@ -68,6 +68,8 @@ import org.schabi.newpipe.player.playqueue.PlayQueueItem; import org.schabi.newpipe.player.playqueue.PlayQueueItemBuilder; import org.schabi.newpipe.player.playqueue.PlayQueueItemHolder; import org.schabi.newpipe.player.playqueue.PlayQueueItemTouchCallback; +import org.schabi.newpipe.ui.components.menu.LongPressAction; +import org.schabi.newpipe.ui.components.menu.LongPressable; import org.schabi.newpipe.util.DeviceUtils; import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.external_communication.KoreUtils; @@ -795,8 +797,11 @@ public final class MainPlayerUi extends VideoPlayerUi implements View.OnLayoutCh @Nullable final PlayQueue playQueue = player.getPlayQueue(); @Nullable final AppCompatActivity parentActivity = getParentActivity().orElse(null); if (playQueue != null && parentActivity != null && playQueue.indexOf(item) != -1) { - openPopupMenu(player.getPlayQueue(), item, view, true, - parentActivity.getSupportFragmentManager(), context); + openLongPressMenuInActivity( + parentActivity, + LongPressable.fromPlayQueueItem(item), + LongPressAction.fromPlayQueueItem(item, playQueue, false) + ); } } diff --git a/app/src/main/java/org/schabi/newpipe/ui/DetectDragModifier.kt b/app/src/main/java/org/schabi/newpipe/ui/GestureModifiers.kt similarity index 84% rename from app/src/main/java/org/schabi/newpipe/ui/DetectDragModifier.kt rename to app/src/main/java/org/schabi/newpipe/ui/GestureModifiers.kt index ca844d855..164f28e72 100644 --- a/app/src/main/java/org/schabi/newpipe/ui/DetectDragModifier.kt +++ b/app/src/main/java/org/schabi/newpipe/ui/GestureModifiers.kt @@ -5,7 +5,9 @@ import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.pointer.PointerEventPass import androidx.compose.ui.input.pointer.PointerEventTimeoutCancellationException +import androidx.compose.ui.input.pointer.PointerInputChange import androidx.compose.ui.input.pointer.changedToUp import androidx.compose.ui.input.pointer.isOutOfBounds import androidx.compose.ui.input.pointer.pointerInput @@ -87,3 +89,22 @@ fun Modifier.detectDragGestures( } private fun Offset.toIntOffset() = IntOffset(this.x.toInt(), this.y.toInt()) + +/** + * Discards all touches on child composables. See https://stackoverflow.com/a/69146178. + * @param doDiscard whether this Modifier is active (touches discarded) or not (no effect). + */ +fun Modifier.discardAllTouchesIf(doDiscard: Boolean) = if (doDiscard) { + pointerInput(Unit) { + awaitPointerEventScope { + // we should wait for all new pointer events + while (true) { + awaitPointerEvent(pass = PointerEventPass.Initial) + .changes + .forEach(PointerInputChange::consume) + } + } + } +} else { + this +} diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressAction.kt b/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressAction.kt index 19cd612f8..95042e9e9 100644 --- a/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressAction.kt +++ b/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressAction.kt @@ -1,8 +1,8 @@ package org.schabi.newpipe.ui.components.menu import android.content.Context -import android.net.Uri import android.widget.Toast +import androidx.annotation.MainThread import androidx.annotation.StringRes import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.PlaylistAdd @@ -15,6 +15,7 @@ import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Headset import androidx.compose.material.icons.filled.HideImage import androidx.compose.material.icons.filled.Image +import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.OpenInBrowser import androidx.compose.material.icons.filled.Person import androidx.compose.material.icons.filled.PictureInPicture @@ -23,7 +24,11 @@ import androidx.compose.material.icons.filled.QueuePlayNext import androidx.compose.material.icons.filled.Share import androidx.compose.ui.graphics.vector.ImageVector import androidx.core.net.toUri -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.rx3.await +import kotlinx.coroutines.rx3.awaitSingle +import kotlinx.coroutines.withContext +import org.schabi.newpipe.NewPipeDatabase import org.schabi.newpipe.R import org.schabi.newpipe.database.playlist.PlaylistMetadataEntry import org.schabi.newpipe.database.playlist.PlaylistStreamEntry @@ -31,9 +36,6 @@ import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity import org.schabi.newpipe.database.stream.StreamStatisticsEntry import org.schabi.newpipe.database.stream.model.StreamEntity import org.schabi.newpipe.download.DownloadDialog -import org.schabi.newpipe.error.ErrorInfo -import org.schabi.newpipe.error.ErrorUtil -import org.schabi.newpipe.error.UserAction import org.schabi.newpipe.extractor.InfoItem import org.schabi.newpipe.extractor.channel.ChannelInfoItem import org.schabi.newpipe.extractor.playlist.PlaylistInfoItem @@ -42,21 +44,22 @@ import org.schabi.newpipe.ktx.findFragmentActivity import org.schabi.newpipe.local.dialog.PlaylistAppendDialog import org.schabi.newpipe.local.dialog.PlaylistDialog import org.schabi.newpipe.local.history.HistoryRecordManager +import org.schabi.newpipe.local.playlist.LocalPlaylistManager import org.schabi.newpipe.player.playqueue.ChannelTabPlayQueue import org.schabi.newpipe.player.playqueue.PlayQueue +import org.schabi.newpipe.player.playqueue.PlayQueueItem import org.schabi.newpipe.player.playqueue.PlaylistPlayQueue -import org.schabi.newpipe.player.playqueue.SinglePlayQueue import org.schabi.newpipe.ui.components.menu.icons.BackgroundFromHere import org.schabi.newpipe.ui.components.menu.icons.PlayFromHere import org.schabi.newpipe.ui.components.menu.icons.PopupFromHere import org.schabi.newpipe.util.NavigationHelper -import org.schabi.newpipe.util.SparseItemUtil import org.schabi.newpipe.util.external_communication.KoreUtils import org.schabi.newpipe.util.external_communication.ShareUtils data class LongPressAction( val type: Type, - val action: (context: Context) -> Unit, + @MainThread + val action: suspend (context: Context) -> Unit, val enabled: (isPlayerRunning: Boolean) -> Boolean = { true }, ) { enum class Type( @@ -88,6 +91,8 @@ data class LongPressAction( SetAsPlaylistThumbnail(17, R.string.set_as_playlist_thumbnail, Icons.Default.Image), UnsetPlaylistThumbnail(18, R.string.unset_playlist_thumbnail, Icons.Default.HideImage), Unsubscribe(19, R.string.unsubscribe, Icons.Default.Delete), + ShowDetails(20, R.string.play_queue_stream_detail, Icons.Default.Info), + Remove(21, R.string.play_queue_remove, Icons.Default.Delete), ; // TODO allow actions to return disposables @@ -95,37 +100,39 @@ data class LongPressAction( fun buildAction( enabled: (isPlayerRunning: Boolean) -> Boolean = { true }, - action: (context: Context) -> Unit, + action: suspend (context: Context) -> Unit, ) = LongPressAction(this, action, enabled) companion object { // ShowChannelDetails is not enabled by default, since navigating to channel details can // also be done by clicking on the uploader name in the long press menu header val DefaultEnabledActions: List = listOf( - Enqueue, EnqueueNext, Background, Popup, BackgroundFromHere, Download, + ShowDetails, Enqueue, EnqueueNext, Background, Popup, BackgroundFromHere, Download, AddToPlaylist, Share, OpenInBrowser, MarkAsWatched, Delete, - Rename, SetAsPlaylistThumbnail, UnsetPlaylistThumbnail, Unsubscribe + Rename, SetAsPlaylistThumbnail, UnsetPlaylistThumbnail, Unsubscribe, Remove, ) } } companion object { - private fun buildPlayerActionList(queue: () -> PlayQueue): List { + private fun buildPlayerActionList( + queue: suspend (Context) -> PlayQueue + ): List { return listOf( Type.Enqueue.buildAction({ isPlayerRunning -> isPlayerRunning }) { context -> - NavigationHelper.enqueueOnPlayer(context, queue()) + NavigationHelper.enqueueOnPlayer(context, queue(context)) }, Type.EnqueueNext.buildAction({ isPlayerRunning -> isPlayerRunning }) { context -> - NavigationHelper.enqueueNextOnPlayer(context, queue()) + NavigationHelper.enqueueNextOnPlayer(context, queue(context)) }, Type.Background.buildAction { context -> - NavigationHelper.playOnBackgroundPlayer(context, queue(), true) + NavigationHelper.playOnBackgroundPlayer(context, queue(context), true) }, Type.Popup.buildAction { context -> - NavigationHelper.playOnPopupPlayer(context, queue(), true) + NavigationHelper.playOnPopupPlayer(context, queue(context), true) }, Type.Play.buildAction { context -> - NavigationHelper.playOnMainPlayer(context, queue(), false) + NavigationHelper.playOnMainPlayer(context, queue(context), false) }, ) } @@ -166,6 +173,53 @@ data class LongPressAction( ) } + private fun buildAdditionalStreamActionList(item: StreamInfoItem): List { + return listOf( + Type.Download.buildAction { context -> + val info = fetchStreamInfoAndSaveToDatabase(context, item.serviceId, item.url) + val downloadDialog = DownloadDialog(context, info) + val fragmentManager = context.findFragmentActivity() + .supportFragmentManager + downloadDialog.show(fragmentManager, "downloadDialog") + }, + Type.AddToPlaylist.buildAction { context -> + LocalPlaylistManager(NewPipeDatabase.getInstance(context)) + .hasPlaylists() + val dialog = withContext(Dispatchers.IO) { + PlaylistDialog.createCorrespondingDialog( + context, + listOf(StreamEntity(item)) + ) + .awaitSingle() + } + val tag = if (dialog is PlaylistAppendDialog) "append" else "create" + dialog.show( + context.findFragmentActivity().supportFragmentManager, + "StreamDialogEntry@${tag}_playlist" + ) + }, + Type.ShowChannelDetails.buildAction { context -> + val uploaderUrl = fetchUploaderUrlIfSparse( + context, item.serviceId, item.url, item.uploaderUrl + ) + NavigationHelper.openChannelFragment( + context.findFragmentActivity().supportFragmentManager, + item.serviceId, + uploaderUrl, + item.uploaderName, + ) + }, + Type.MarkAsWatched.buildAction { context -> + withContext(Dispatchers.IO) { + HistoryRecordManager(context).markAsWatched(item).await() + } + }, + Type.PlayWithKodi.buildAction { context -> + KoreUtils.playWithKore(context, item.url.toUri()) + }, + ) + } + /** * @param queueFromHere returns a play queue for the list that contains [item], with the * queue index pointing to [item], used to build actions like "Play playlist from here". @@ -176,65 +230,10 @@ data class LongPressAction( queueFromHere: (() -> PlayQueue)?, /* TODO isKodiEnabled: Boolean, */ ): List { - return buildPlayerActionList { SinglePlayQueue(item) } + + return buildPlayerActionList { context -> fetchItemInfoIfSparse(context, item) } + (queueFromHere?.let { buildPlayerFromHereActionList(queueFromHere) } ?: listOf()) + buildShareActionList(item) + - listOf( - Type.Download.buildAction { context -> - SparseItemUtil.fetchStreamInfoAndSaveToDatabase( - context, item.serviceId, item.url - ) { info -> - val downloadDialog = DownloadDialog(context, info) - val fragmentManager = context.findFragmentActivity() - .supportFragmentManager - downloadDialog.show(fragmentManager, "downloadDialog") - } - }, - Type.AddToPlaylist.buildAction { context -> - PlaylistDialog.createCorrespondingDialog( - context, - listOf(StreamEntity(item)) - ) { dialog: PlaylistDialog -> - val tag = if (dialog is PlaylistAppendDialog) "append" else "create" - dialog.show( - context.findFragmentActivity().supportFragmentManager, - "StreamDialogEntry@${tag}_playlist" - ) - } - }, - Type.ShowChannelDetails.buildAction { context -> - SparseItemUtil.fetchUploaderUrlIfSparse( - context, item.serviceId, item.url, item.uploaderUrl - ) { url: String -> - NavigationHelper.openChannelFragment( - context.findFragmentActivity().supportFragmentManager, - item.serviceId, - url, - item.uploaderName, - ) - } - }, - Type.MarkAsWatched.buildAction { context -> - HistoryRecordManager(context) - .markAsWatched(item) - .doOnError { error -> - ErrorUtil.showSnackbar( - context, - ErrorInfo( - error, - UserAction.OPEN_INFO_ITEM_DIALOG, - "Got an error when trying to mark as watched" - ) - ) - } - .onErrorComplete() - .observeOn(AndroidSchedulers.mainThread()) - .subscribe() - }, - Type.PlayWithKodi.buildAction { context -> - KoreUtils.playWithKore(context, item.url.toUri()) - }, - ) + buildAdditionalStreamActionList(item) } @JvmStatic @@ -248,6 +247,38 @@ data class LongPressAction( return fromStreamInfoItem(item.toStreamInfoItem(), queueFromHere) } + @JvmStatic + fun fromPlayQueueItem( + item: PlayQueueItem, + playQueueFromWhichToDelete: PlayQueue, + showDetails: Boolean, + ): List { + // TODO decide if it's fine to just convert to StreamInfoItem here (it poses an + // unnecessary dependency on the extractor, when we want to just look at data; maybe + // using something like LongPressable would work) + val streamInfoItem = item.toStreamInfoItem() + return buildShareActionList(streamInfoItem) + + buildAdditionalStreamActionList(streamInfoItem) + + if (showDetails) { + listOf( + Type.ShowDetails.buildAction { context -> + // playQueue is null since we don't want any queue change + NavigationHelper.openVideoDetail( + context, item.serviceId, item.url, item.title, null, false + ) + } + ) + } else { + listOf() + } + + listOf( + Type.Remove.buildAction { + val index = playQueueFromWhichToDelete.indexOf(item) + playQueueFromWhichToDelete.remove(index) + } + ) + } + @JvmStatic fun fromStreamStatisticsEntry( item: StreamStatisticsEntry, @@ -256,16 +287,13 @@ data class LongPressAction( return fromStreamEntity(item.streamEntity, queueFromHere) + listOf( Type.Delete.buildAction { context -> - HistoryRecordManager(context) - .deleteStreamHistoryAndState(item.streamId) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe { - Toast.makeText( - context, - R.string.one_item_deleted, - Toast.LENGTH_SHORT - ).show() - } + withContext(Dispatchers.IO) { + HistoryRecordManager(context) + .deleteStreamHistoryAndState(item.streamId) + .await() + } + Toast.makeText(context, R.string.one_item_deleted, Toast.LENGTH_SHORT) + .show() } ) } diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressMenu.kt b/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressMenu.kt index 5857f1e77..f92027c39 100644 --- a/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressMenu.kt +++ b/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressMenu.kt @@ -7,6 +7,9 @@ import android.content.Context import android.content.res.Configuration import android.view.ViewGroup import android.view.ViewGroup.LayoutParams +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.focusable import androidx.compose.foundation.isSystemInDarkTheme @@ -32,6 +35,7 @@ import androidx.compose.material.icons.automirrored.filled.OpenInNew import androidx.compose.material.icons.automirrored.filled.PlaylistPlay import androidx.compose.material.icons.filled.Tune import androidx.compose.material3.BottomSheetDefaults +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -48,6 +52,7 @@ import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -74,11 +79,18 @@ import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import androidx.lifecycle.viewmodel.compose.viewModel import coil3.compose.AsyncImage +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.schabi.newpipe.R +import org.schabi.newpipe.error.ErrorInfo +import org.schabi.newpipe.error.ErrorUtil +import org.schabi.newpipe.error.UserAction.LONG_PRESS_MENU_ACTION import org.schabi.newpipe.extractor.stream.StreamType import org.schabi.newpipe.ui.components.common.ScaffoldWithToolbar import org.schabi.newpipe.ui.components.menu.LongPressAction.Type.EnqueueNext import org.schabi.newpipe.ui.components.menu.LongPressAction.Type.ShowChannelDetails +import org.schabi.newpipe.ui.discardAllTouchesIf import org.schabi.newpipe.ui.theme.AppTheme import org.schabi.newpipe.ui.theme.customColors import org.schabi.newpipe.util.Either @@ -129,9 +141,10 @@ fun LongPressMenu( val isHeaderEnabled by viewModel.isHeaderEnabled.collectAsState() val actionArrangement by viewModel.actionArrangement.collectAsState() var showEditor by rememberSaveable { mutableStateOf(false) } + var isLoading by remember { mutableStateOf(false) } val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - if (showEditor) { + if (showEditor && !isLoading) { // we can't put the editor in a bottom sheet, because it relies on dragging gestures Dialog( onDismissRequest = { showEditor = false }, @@ -153,19 +166,33 @@ fun LongPressMenu( } } + val ctx = LocalContext.current + // run actions on the main thread! + val coroutineScope = rememberCoroutineScope { Dispatchers.Main } + fun runActionAndDismiss(action: LongPressAction) { + if (isLoading) { + return + } + isLoading = true + coroutineScope.launch { + try { + action.action(ctx) + } catch (t: Throwable) { + ErrorUtil.showSnackbar( + ctx, ErrorInfo(t, LONG_PRESS_MENU_ACTION, "Running action ${action.type}") + ) + } + onDismissRequest() + } + } + // show a clickable uploader in the header if an uploader action is available and the // "show channel details" action is not enabled as a standalone action - val ctx = LocalContext.current val onUploaderClick by remember { derivedStateOf { longPressActions.firstOrNull { it.type == ShowChannelDetails } ?.takeIf { !actionArrangement.contains(ShowChannelDetails) } - ?.let { showChannelDetailsAction -> - { - showChannelDetailsAction.action(ctx) - onDismissRequest() - } - } + ?.let { showChannelAction -> { runActionAndDismiss(showChannelAction) } } } } @@ -174,12 +201,27 @@ fun LongPressMenu( onDismissRequest = onDismissRequest, dragHandle = { LongPressMenuDragHandle(onEditActions = { showEditor = true }) }, ) { - LongPressMenuContent( - header = longPressable.takeIf { isHeaderEnabled }, - onUploaderClick = onUploaderClick, - actions = enabledLongPressActions, - onDismissRequest = onDismissRequest, - ) + Box(modifier = Modifier.discardAllTouchesIf(isLoading)) { + LongPressMenuContent( + header = longPressable.takeIf { isHeaderEnabled }, + onUploaderClick = onUploaderClick, + actions = enabledLongPressActions, + runActionAndDismiss = ::runActionAndDismiss, + ) + // importing makes the ColumnScope overload be resolved, so we use qualified path... + androidx.compose.animation.AnimatedVisibility( + visible = isLoading, + enter = fadeIn(), + exit = fadeOut(), + modifier = Modifier + .matchParentSize() + .background(MaterialTheme.colorScheme.surfaceContainerLow), + ) { + Box(contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + } + } } } } @@ -189,7 +231,7 @@ private fun LongPressMenuContent( header: LongPressable?, onUploaderClick: (() -> Unit)?, actions: List, - onDismissRequest: () -> Unit, + runActionAndDismiss: (LongPressAction) -> Unit, ) { BoxWithConstraints( modifier = Modifier @@ -203,7 +245,6 @@ private fun LongPressMenuContent( // width for the landscape/reduced header, measured in button widths val headerWidthInButtonsReducedSpan = 4 val buttonsPerRow = (this.maxWidth / MinButtonWidth).toInt() - val ctx = LocalContext.current Column { var actionIndex = if (header != null) -1 else 0 // -1 indicates the header @@ -230,10 +271,7 @@ private fun LongPressMenuContent( LongPressMenuButton( icon = action.type.icon, text = stringResource(action.type.label), - onClick = { - action.action(ctx) - onDismissRequest() - }, + onClick = { runActionAndDismiss(action) }, enabled = action.enabled(false), modifier = Modifier .height(buttonHeight) @@ -296,7 +334,12 @@ fun LongPressMenuDragHandle(onEditActions: () -> Unit) { // the focus to "nothing focused". Ideally it would be great to focus the first item in // the long press menu, but then there would need to be a way to ignore the UP from the // DPAD after an externally-triggered long press. - Box(Modifier.size(1.dp).focusable().onFocusChanged { showFocusTrap = !it.isFocused }) + Box( + Modifier + .size(1.dp) + .focusable() + .onFocusChanged { showFocusTrap = !it.isFocused } + ) } BottomSheetDefaults.DragHandle( modifier = Modifier.align(Alignment.Center) @@ -693,7 +736,7 @@ private fun LongPressMenuPreview( actions = LongPressAction.Type.entries // disable Enqueue actions just to show it off .map { t -> t.buildAction({ t != EnqueueNext }) { } }, - onDismissRequest = {}, + runActionAndDismiss = {}, ) } } diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressable.kt b/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressable.kt index 8c9c1a1eb..ebafa71b8 100644 --- a/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressable.kt +++ b/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressable.kt @@ -11,6 +11,7 @@ import org.schabi.newpipe.extractor.stream.StreamInfoItem import org.schabi.newpipe.extractor.stream.StreamType import org.schabi.newpipe.extractor.stream.StreamType.AUDIO_LIVE_STREAM import org.schabi.newpipe.extractor.stream.StreamType.LIVE_STREAM +import org.schabi.newpipe.player.playqueue.PlayQueueItem import org.schabi.newpipe.util.Either import org.schabi.newpipe.util.image.ImageStrategy import java.time.OffsetDateTime @@ -71,6 +72,19 @@ data class LongPressable( decoration = Decoration.from(item.streamType, item.duration), ) + @JvmStatic + fun fromPlayQueueItem(item: PlayQueueItem) = LongPressable( + title = item.title, + url = item.url.takeIf { it.isNotBlank() }, + thumbnailUrl = ImageStrategy.choosePreferredImage(item.thumbnails), + uploader = item.uploader.takeIf { it.isNotBlank() }, + uploaderUrl = item.uploaderUrl?.takeIf { it.isNotBlank() }, + viewCount = null, + streamType = item.streamType, + uploadDate = null, + decoration = Decoration.from(item.streamType, item.duration), + ) + @JvmStatic fun fromPlaylistMetadataEntry(item: PlaylistMetadataEntry) = LongPressable( // many fields are null because this is a local playlist @@ -118,7 +132,7 @@ data class LongPressable( title = item.name, url = item.url?.takeIf { it.isNotBlank() }, thumbnailUrl = ImageStrategy.choosePreferredImage(item.thumbnails), - uploader = item.uploaderName.takeIf { it.isNotBlank() }, + uploader = item.uploaderName?.takeIf { it.isNotBlank() }, uploaderUrl = item.uploaderUrl?.takeIf { it.isNotBlank() }, viewCount = null, streamType = null, diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/menu/SparseItemUtil.kt b/app/src/main/java/org/schabi/newpipe/ui/components/menu/SparseItemUtil.kt new file mode 100644 index 000000000..33f03ad5c --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/ui/components/menu/SparseItemUtil.kt @@ -0,0 +1,106 @@ +package org.schabi.newpipe.ui.components.menu + +import android.content.Context +import android.widget.Toast +import androidx.annotation.MainThread +import io.reactivex.rxjava3.schedulers.Schedulers +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.rx3.await +import kotlinx.coroutines.withContext +import org.schabi.newpipe.NewPipeDatabase +import org.schabi.newpipe.R +import org.schabi.newpipe.database.stream.model.StreamEntity +import org.schabi.newpipe.extractor.stream.StreamInfo +import org.schabi.newpipe.extractor.stream.StreamInfoItem +import org.schabi.newpipe.extractor.utils.Utils +import org.schabi.newpipe.player.playqueue.SinglePlayQueue +import org.schabi.newpipe.util.ExtractorHelper +import org.schabi.newpipe.util.StreamTypeUtil + +// Utilities for fetching additional data for stream items when needed. + +/** + * Use this to certainly obtain an single play queue with all of the data filled in when the + * stream info item you are handling might be sparse, e.g. because it was fetched via a + * [org.schabi.newpipe.extractor.feed.FeedExtractor]. FeedExtractors provide a fast and + * lightweight method to fetch info, but the info might be incomplete (see + * [org.schabi.newpipe.local.feed.service.FeedLoadService] for more details). + * + * @param context Android context + * @param item item which is checked and eventually loaded completely + * @return a [SinglePlayQueue] with full data (fetched if necessary) + */ +@MainThread +suspend fun fetchItemInfoIfSparse( + context: Context, + item: StreamInfoItem, +): SinglePlayQueue { + if ((StreamTypeUtil.isLiveStream(item.streamType) || item.duration >= 0) && + !Utils.isNullOrEmpty(item.uploaderUrl) + ) { + // if the duration is >= 0 (provided that the item is not a livestream) and there is an + // uploader url, probably all info is already there, so there is no need to fetch it + return SinglePlayQueue(item) + } + + // either the duration or the uploader url are not available, so fetch more info + val streamInfo = fetchStreamInfoAndSaveToDatabase(context, item.serviceId, item.url) + return SinglePlayQueue(streamInfo) +} + +/** + * Use this to certainly obtain an uploader url when the stream info item or play queue item you + * are handling might not have the uploader url (e.g. because it was fetched with + * [org.schabi.newpipe.extractor.feed.FeedExtractor]). A toast is shown if loading details is + * required. + * + * @param context Android context + * @param serviceId serviceId of the item + * @param url item url + * @param uploaderUrl uploaderUrl of the item; if null or empty will be fetched + * @return the original or the fetched uploader URL (may still be null if the extractor didn't + * provide one) + */ +@MainThread +suspend fun fetchUploaderUrlIfSparse( + context: Context, + serviceId: Int, + url: String, + uploaderUrl: String?, +): String? { + if (!uploaderUrl.isNullOrEmpty()) { + return uploaderUrl + } + val streamInfo = fetchStreamInfoAndSaveToDatabase(context, serviceId, url) + return streamInfo.uploaderUrl +} + +/** + * Loads the stream info corresponding to the given data on an I/O thread, stores the result in + * the database, and returns. A toast will be shown to the user about loading stream details, so + * this needs to be called on the main thread. + * + * @param context Android context + * @param serviceId service id of the stream to load + * @param url url of the stream to load + * @return the fetched [StreamInfo] + */ +@MainThread +suspend fun fetchStreamInfoAndSaveToDatabase( + context: Context, + serviceId: Int, + url: String, +): StreamInfo { + Toast.makeText(context, R.string.loading_stream_details, Toast.LENGTH_SHORT).show() + + return withContext(Dispatchers.IO) { + val streamInfo = ExtractorHelper.getStreamInfo(serviceId, url, false) + .subscribeOn(Schedulers.io()) + .await() + // save to database + NewPipeDatabase.getInstance(context) + .streamDAO() + .upsert(StreamEntity(streamInfo)) + return@withContext streamInfo + } +} diff --git a/app/src/main/java/org/schabi/newpipe/util/SparseItemUtil.java b/app/src/main/java/org/schabi/newpipe/util/SparseItemUtil.java deleted file mode 100644 index 05f26f178..000000000 --- a/app/src/main/java/org/schabi/newpipe/util/SparseItemUtil.java +++ /dev/null @@ -1,127 +0,0 @@ -package org.schabi.newpipe.util; - -import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty; - -import android.content.Context; -import android.widget.Toast; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import org.schabi.newpipe.NewPipeDatabase; -import org.schabi.newpipe.R; -import org.schabi.newpipe.database.stream.model.StreamEntity; -import org.schabi.newpipe.error.ErrorInfo; -import org.schabi.newpipe.error.ErrorUtil; -import org.schabi.newpipe.error.UserAction; -import org.schabi.newpipe.extractor.stream.StreamInfo; -import org.schabi.newpipe.extractor.stream.StreamInfoItem; -import org.schabi.newpipe.player.playqueue.SinglePlayQueue; - -import java.util.function.Consumer; - -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; -import io.reactivex.rxjava3.core.Completable; -import io.reactivex.rxjava3.schedulers.Schedulers; - -/** - * Utility class for fetching additional data for stream items when needed. - */ -public final class SparseItemUtil { - private SparseItemUtil() { - } - - /** - * Use this to certainly obtain an single play queue with all of the data filled in when the - * stream info item you are handling might be sparse, e.g. because it was fetched via a {@link - * org.schabi.newpipe.extractor.feed.FeedExtractor}. FeedExtractors provide a fast and - * lightweight method to fetch info, but the info might be incomplete (see - * {@link org.schabi.newpipe.local.feed.service.FeedLoadService} for more details). - * - * @param context Android context - * @param item item which is checked and eventually loaded completely - * @param callback callback to call with the single play queue built from the original item if - * all info was available, otherwise from the fetched {@link - * org.schabi.newpipe.extractor.stream.StreamInfo} - */ - public static void fetchItemInfoIfSparse(@NonNull final Context context, - @NonNull final StreamInfoItem item, - @NonNull final Consumer callback) { - if ((StreamTypeUtil.isLiveStream(item.getStreamType()) || item.getDuration() >= 0) - && !isNullOrEmpty(item.getUploaderUrl())) { - // if the duration is >= 0 (provided that the item is not a livestream) and there is an - // uploader url, probably all info is already there, so there is no need to fetch it - callback.accept(new SinglePlayQueue(item)); - return; - } - - // either the duration or the uploader url are not available, so fetch more info - fetchStreamInfoAndSaveToDatabase(context, item.getServiceId(), item.getUrl(), - streamInfo -> callback.accept(new SinglePlayQueue(streamInfo))); - } - - /** - * Use this to certainly obtain an uploader url when the stream info item or play queue item you - * are handling might not have the uploader url (e.g. because it was fetched with {@link - * org.schabi.newpipe.extractor.feed.FeedExtractor}). A toast is shown if loading details is - * required. - * - * @param context Android context - * @param serviceId serviceId of the item - * @param url item url - * @param uploaderUrl uploaderUrl of the item; if null or empty will be fetched - * @param callback callback to be called with either the original uploaderUrl, if it was a - * valid url, otherwise with the uploader url obtained by fetching the {@link - * org.schabi.newpipe.extractor.stream.StreamInfo} corresponding to the item - */ - public static void fetchUploaderUrlIfSparse(@NonNull final Context context, - final int serviceId, - @NonNull final String url, - @Nullable final String uploaderUrl, - @NonNull final Consumer callback) { - if (!isNullOrEmpty(uploaderUrl)) { - callback.accept(uploaderUrl); - return; - } - fetchStreamInfoAndSaveToDatabase(context, serviceId, url, - streamInfo -> callback.accept(streamInfo.getUploaderUrl())); - } - - /** - * Loads the stream info corresponding to the given data on an I/O thread, stores the result in - * the database and calls the callback on the main thread with the result. A toast will be shown - * to the user about loading stream details, so this needs to be called on the main thread. - * - * @param context Android context - * @param serviceId service id of the stream to load - * @param url url of the stream to load - * @param callback callback to be called with the result - */ - public static void fetchStreamInfoAndSaveToDatabase(@NonNull final Context context, - final int serviceId, - @NonNull final String url, - final Consumer callback) { - Toast.makeText(context, R.string.loading_stream_details, Toast.LENGTH_SHORT).show(); - ExtractorHelper.getStreamInfo(serviceId, url, false) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(result -> { - // save to database in the background (not on main thread) - Completable.fromAction(() -> NewPipeDatabase.getInstance(context) - .streamDAO().upsert(new StreamEntity(result))) - .subscribeOn(Schedulers.io()) - .observeOn(Schedulers.io()) - .doOnError(throwable -> - ErrorUtil.createNotification(context, - new ErrorInfo(throwable, UserAction.REQUESTED_STREAM, - "Saving stream info to database", result))) - .subscribe(); - - // call callback on main thread with the obtained result - callback.accept(result); - }, throwable -> ErrorUtil.createNotification(context, - new ErrorInfo(throwable, UserAction.REQUESTED_STREAM, - "Loading stream info: " + url, serviceId, url) - )); - } -}