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
This commit is contained in:
Stypox 2026-02-02 19:37:39 +01:00
parent c62004d903
commit 8f19f95fee
No known key found for this signature in database
GPG Key ID: 4BDF1B40A49FDD23
15 changed files with 375 additions and 369 deletions

View File

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

View File

@ -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",

View File

@ -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"),
}

View File

@ -439,7 +439,7 @@ class VideoDetailFragment :
PlaylistDialog.createCorrespondingDialog(
requireContext(),
listOf(StreamEntity(info))
) { dialog -> dialog.show(getParentFragmentManager(), TAG) }
).subscribe { dialog -> dialog.show(getParentFragmentManager(), TAG) }
)
}
}

View File

@ -237,15 +237,15 @@ public class PlaylistFragment extends BaseListInfoFragment<StreamInfoItem, Playl
break;
case R.id.menu_item_append_playlist:
if (currentInfo != null) {
disposables.add(PlaylistDialog.createCorrespondingDialog(
getContext(),
getPlayQueue()
.getStreams()
.stream()
.map(StreamEntity::new)
.collect(Collectors.toList()),
dialog -> 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:

View File

@ -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<PlaylistDialog> createCorrespondingDialog(
final Context context,
final List<StreamEntity> streamEntities,
final Consumer<PlaylistDialog> onExec) {
final List<StreamEntity> 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"));
}
}

View File

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

View File

@ -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)

View File

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

View File

@ -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
}

View File

@ -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<Type> = 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<LongPressAction> {
private fun buildPlayerActionList(
queue: suspend (Context) -> PlayQueue
): List<LongPressAction> {
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<LongPressAction> {
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<LongPressAction> {
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<LongPressAction> {
// 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()
}
)
}

View File

@ -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<LongPressAction>,
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 = {},
)
}
}

View File

@ -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,

View File

@ -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
}
}

View File

@ -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<SinglePlayQueue> 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<String> 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<StreamInfo> 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)
));
}
}