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) - )); - } -}