Initial work on handling many long-press actions

This commit is contained in:
Stypox 2025-02-11 15:46:03 +01:00
parent 8e454dcb71
commit 9abbb7b075
No known key found for this signature in database
GPG Key ID: 4BDF1B40A49FDD23
4 changed files with 271 additions and 110 deletions

View File

@ -3,6 +3,7 @@ package org.schabi.newpipe.fragments.list.playlist;
import static org.schabi.newpipe.extractor.utils.Utils.isBlank;
import static org.schabi.newpipe.ktx.ViewUtils.animate;
import static org.schabi.newpipe.ktx.ViewUtils.animateHideRecyclerViewAllowingScrolling;
import static org.schabi.newpipe.ui.components.menu.LongPressMenuKt.getLongPressMenuView;
import static org.schabi.newpipe.util.ServiceHelper.getServiceById;
import android.content.Context;
@ -150,6 +151,16 @@ public class PlaylistFragment extends BaseListInfoFragment<StreamInfoItem, Playl
@Override
protected void showInfoItemDialog(final StreamInfoItem item) {
activity.addContentView(
getLongPressMenuView(requireContext(), item),
new ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
);
if (Context.class.getSimpleName().startsWith("C")) {
return;
}
final Context context = getContext();
try {
final InfoItemDialog.Builder dialogBuilder =

View File

@ -0,0 +1,162 @@
package org.schabi.newpipe.ui.components.menu
import android.content.Context
import android.net.Uri
import androidx.annotation.StringRes
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.PlaylistAdd
import androidx.compose.material.icons.filled.AddToQueue
import androidx.compose.material.icons.filled.Cast
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Done
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.Headset
import androidx.compose.material.icons.filled.OpenInBrowser
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.PictureInPicture
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.QueuePlayNext
import androidx.compose.material.icons.filled.Share
import androidx.compose.ui.graphics.vector.ImageVector
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
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.InfoItem
import org.schabi.newpipe.extractor.stream.StreamInfoItem
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.player.playqueue.PlayQueue
import org.schabi.newpipe.player.playqueue.SinglePlayQueue
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,
val enabled: (isPlayerRunning: Boolean) -> Boolean = { true },
) {
enum class Type(
@StringRes val label: Int,
val icon: ImageVector,
) {
Enqueue(R.string.enqueue, Icons.Default.AddToQueue),
EnqueueNext(R.string.enqueue_next_stream, Icons.Default.QueuePlayNext),
Background(R.string.controls_background_title, Icons.Default.Headset),
Popup(R.string.controls_popup_title, Icons.Default.PictureInPicture),
Play(R.string.play, Icons.Default.PlayArrow),
PlayWithKodi(R.string.play_with_kodi_title, Icons.Default.Cast),
Download(R.string.download, Icons.Default.Download),
AddToPlaylist(R.string.add_to_playlist, Icons.AutoMirrored.Default.PlaylistAdd),
Share(R.string.share, Icons.Default.Share),
OpenInBrowser(R.string.open_in_browser, Icons.Default.OpenInBrowser),
ShowChannelDetails(R.string.show_channel_details, Icons.Default.Person),
MarkAsWatched(R.string.mark_as_watched, Icons.Default.Done),
Delete(R.string.delete, Icons.Default.Delete),
;
// TODO allow actions to return disposables
// TODO add actions that use the whole list the item belongs to (see wholeListQueue)
fun buildAction(
enabled: (isPlayerRunning: Boolean) -> Boolean = { true },
action: (context: Context) -> Unit,
) = LongPressAction(this, action, enabled)
}
companion object {
private fun buildPlayerActionList(queue: () -> PlayQueue): List<LongPressAction> {
return listOf(
Type.Enqueue.buildAction({ isPlayerRunning -> isPlayerRunning }) { context ->
NavigationHelper.enqueueOnPlayer(context, queue())
},
Type.EnqueueNext.buildAction({ isPlayerRunning -> isPlayerRunning }) { context ->
NavigationHelper.enqueueNextOnPlayer(context, queue())
},
Type.Background.buildAction { context ->
NavigationHelper.playOnBackgroundPlayer(context, queue(), true)
},
Type.Popup.buildAction { context ->
NavigationHelper.playOnPopupPlayer(context, queue(), true)
},
Type.Play.buildAction { context ->
NavigationHelper.playOnMainPlayer(context, queue(), false)
},
)
}
private fun buildShareActionList(item: InfoItem): List<LongPressAction> {
return listOf(
Type.Share.buildAction { context ->
ShareUtils.shareText(context, item.name, item.url, item.thumbnails)
},
Type.OpenInBrowser.buildAction { context ->
ShareUtils.openUrlInBrowser(context, item.url)
},
)
}
fun buildActionList(
item: StreamInfoItem,
isKodiEnabled: Boolean,
/* TODO wholeListQueue: (() -> PlayQueue)? */
): List<LongPressAction> {
return buildPlayerActionList { SinglePlayQueue(item) } +
buildShareActionList(item) +
listOf(
Type.Download.buildAction { context -> /* TODO */ },
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()
},
) + if (isKodiEnabled) listOf(
Type.PlayWithKodi.buildAction { context ->
KoreUtils.playWithKore(context, Uri.parse(item.url))
},
) else listOf()
}
}
}

View File

@ -4,8 +4,10 @@ package org.schabi.newpipe.ui.components.menu
import android.content.Context
import android.content.res.Configuration
import android.view.ViewGroup
import androidx.compose.foundation.basicMarquee
import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
@ -22,25 +24,12 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.PlaylistAdd
import androidx.compose.material.icons.automirrored.filled.PlaylistPlay
import androidx.compose.material.icons.filled.AddToQueue
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Done
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Headset
import androidx.compose.material.icons.filled.OpenInBrowser
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.PictureInPicture
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.QueuePlayNext
import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.BottomSheetDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedButton
@ -51,11 +40,16 @@ import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.material3.rememberStandardBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
@ -74,18 +68,60 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.times
import coil3.compose.AsyncImage
import org.schabi.newpipe.R
import org.schabi.newpipe.extractor.stream.StreamInfoItem
import org.schabi.newpipe.player.playqueue.PlayQueue
import org.schabi.newpipe.player.playqueue.SinglePlayQueue
import org.schabi.newpipe.ui.theme.AppTheme
import org.schabi.newpipe.ui.theme.customColors
import org.schabi.newpipe.util.Either
import org.schabi.newpipe.util.Localization
import org.schabi.newpipe.util.image.ImageStrategy
import java.time.OffsetDateTime
fun getLongPressMenuView(
context: Context,
streamInfoItem: StreamInfoItem,
): ComposeView {
return ComposeView(context).apply {
setContent {
LongPressMenu(
longPressable = object : LongPressable {
override val title: String = streamInfoItem.name
override val url: String? = streamInfoItem.url?.takeIf { it.isNotBlank() }
override val thumbnailUrl: String? =
ImageStrategy.choosePreferredImage(streamInfoItem.thumbnails)
override val uploader: String? =
streamInfoItem.uploaderName?.takeIf { it.isNotBlank() }
override val uploaderUrl: String? =
streamInfoItem.uploaderUrl?.takeIf { it.isNotBlank() }
override val viewCount: Long? =
streamInfoItem.viewCount.takeIf { it >= 0 }
override val uploadDate: Either<String, OffsetDateTime>? =
streamInfoItem.uploadDate?.let { Either.right(it.offsetDateTime()) }
?: streamInfoItem.textualUploadDate?.let { Either.left(it) }
override val decoration: LongPressableDecoration? =
streamInfoItem.duration.takeIf { it >= 0 }?.let {
LongPressableDecoration.Duration(it)
}
override fun getPlayQueue(): PlayQueue {
TODO("Not yet implemented")
}
},
onDismissRequest = { (this.parent as ViewGroup).removeView(this) },
actions = LongPressAction.buildActionList(streamInfoItem, false),
onEditActions = {},
)
}
}
}
@Composable
fun LongPressMenu(
longPressable: LongPressable,
onDismissRequest: () -> Unit,
actions: List<LongPressAction>,
onEditActions: () -> Unit,
sheetState: SheetState = rememberModalBottomSheetState(),
) {
ModalBottomSheet(
@ -99,17 +135,19 @@ fun LongPressMenu(
modifier = Modifier.align(Alignment.Center)
)
IconButton(
onClick = {},
onClick = onEditActions,
modifier = Modifier.align(Alignment.CenterEnd)
) {
// show a small button here, it's not an important button and it shouldn't
// capture the user attention
Icon(
imageVector = Icons.Default.Edit,
imageVector = Icons.Default.Settings,
contentDescription = stringResource(R.string.edit),
// same color and height as the DragHandle
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(2.dp).size(16.dp),
modifier = Modifier
.padding(2.dp)
.size(16.dp),
)
}
}
@ -135,9 +173,21 @@ fun LongPressMenu(
// errors in the calculations above don't make the items wrap at the wrong position
modifier = Modifier.align(Alignment.Center),
) {
val actionsWithoutChannel = actions.toMutableList()
val showChannelAction = actionsWithoutChannel.indexOfFirst {
it.type == LongPressAction.Type.ShowChannelDetails
}.let { i ->
if (i >= 0) {
actionsWithoutChannel.removeAt(i)
} else {
null
}
}
LongPressMenuHeader(
item = longPressable,
thumbnailHeight = buttonHeight,
onUploaderClickAction = showChannelAction?.action,
// subtract 2.dp to account for approximation errors in the calculations above
modifier = if (desiredHeaderWidth >= maxContainerWidth - 2 * padding - 2.dp) {
// leave the height as small as possible, since it's the only item on the
@ -149,92 +199,16 @@ fun LongPressMenu(
}
)
LongPressMenuButton(
icon = Icons.Default.AddToQueue,
text = stringResource(R.string.enqueue),
onClick = {},
enabled = false,
modifier = Modifier.size(buttonWidth, buttonHeight),
)
LongPressMenuButton(
icon = Icons.Default.QueuePlayNext,
text = stringResource(R.string.enqueue_next_stream),
onClick = {},
enabled = false,
modifier = Modifier.size(buttonWidth, buttonHeight),
)
LongPressMenuButton(
icon = Icons.Default.Headset,
text = stringResource(R.string.controls_background_title),
onClick = {},
modifier = Modifier.size(buttonWidth, buttonHeight),
)
LongPressMenuButton(
icon = Icons.Default.PictureInPicture,
text = stringResource(R.string.controls_popup_title),
onClick = {},
modifier = Modifier.size(buttonWidth, buttonHeight),
)
LongPressMenuButton(
icon = Icons.Default.PlayArrow,
text = stringResource(R.string.play),
onClick = {},
modifier = Modifier.size(buttonWidth, buttonHeight),
)
LongPressMenuButton(
icon = Icons.Default.Download,
text = stringResource(R.string.download),
onClick = {},
modifier = Modifier.size(buttonWidth, buttonHeight),
)
LongPressMenuButton(
icon = Icons.AutoMirrored.Default.PlaylistAdd,
text = stringResource(R.string.add_to_playlist),
onClick = {},
modifier = Modifier.size(buttonWidth, buttonHeight),
)
LongPressMenuButton(
icon = Icons.Default.Share,
text = stringResource(R.string.share),
onClick = {},
modifier = Modifier.size(buttonWidth, buttonHeight),
)
LongPressMenuButton(
icon = Icons.Default.OpenInBrowser,
text = stringResource(R.string.open_in_browser),
onClick = {},
modifier = Modifier.size(buttonWidth, buttonHeight),
)
LongPressMenuButton(
icon = Icons.Default.Done,
text = stringResource(R.string.mark_as_watched),
onClick = {},
modifier = Modifier.size(buttonWidth, buttonHeight),
)
LongPressMenuButton(
icon = Icons.Default.Person,
text = stringResource(R.string.show_channel_details),
onClick = {},
enabled = longPressable.uploaderUrl != null,
modifier = Modifier.size(buttonWidth, buttonHeight),
)
LongPressMenuButton(
icon = Icons.Default.Delete,
text = stringResource(R.string.delete),
onClick = {},
modifier = Modifier.size(buttonWidth, buttonHeight),
)
val ctx = LocalContext.current
for (action in actionsWithoutChannel) {
LongPressMenuButton(
icon = action.type.icon,
text = stringResource(action.type.label),
onClick = { action.action(ctx) },
enabled = action.enabled(false),
modifier = Modifier.size(buttonWidth, buttonHeight),
)
}
}
}
}
@ -244,6 +218,7 @@ fun LongPressMenu(
fun LongPressMenuHeader(
item: LongPressable,
thumbnailHeight: Dp,
onUploaderClickAction: ((context: Context) -> Unit)?,
modifier: Modifier = Modifier,
) {
val ctx = LocalContext.current
@ -354,6 +329,7 @@ fun LongPressMenuHeader(
val subtitle = getSubtitleAnnotatedString(
item = item,
showLink = onUploaderClickAction != null,
linkColor = MaterialTheme.customColors.onSurfaceVariantLink,
ctx = ctx,
)
@ -363,12 +339,10 @@ fun LongPressMenuHeader(
Text(
text = subtitle,
style = MaterialTheme.typography.bodyMedium,
modifier = if (item.uploaderUrl.isNullOrBlank()) {
modifier = if (onUploaderClickAction == null) {
Modifier
} else {
Modifier.clickable {
// TODO handle click on uploader URL
}
Modifier.clickable { onUploaderClickAction(ctx) }
}.basicMarquee(iterations = Int.MAX_VALUE)
)
}
@ -379,11 +353,12 @@ fun LongPressMenuHeader(
fun getSubtitleAnnotatedString(
item: LongPressable,
showLink: Boolean,
linkColor: Color,
ctx: Context,
) = buildAnnotatedString {
var shouldAddSeparator = false
if (!item.uploaderUrl.isNullOrBlank()) {
if (showLink) {
withStyle(
SpanStyle(
fontWeight = FontWeight.Bold,
@ -554,10 +529,21 @@ private fun LongPressMenuPreview(
Localization.initPrettyTime(Localization.resolvePrettyTime())
onDispose {}
}
AppTheme {
// the incorrect theme is set when running the preview in an emulator for some reason...
val initialUseDarkTheme = isSystemInDarkTheme()
var useDarkTheme by remember { mutableStateOf(initialUseDarkTheme) }
AppTheme(useDarkTheme = useDarkTheme) {
// longPressable is null when running the preview in an emulator for some reason...
@Suppress("USELESS_ELVIS")
LongPressMenu(
longPressable = longPressable,
longPressable = longPressable ?: LongPressablePreviews().values.first(),
onDismissRequest = {},
actions = LongPressAction.Type.entries
// disable Enqueue actions just to show it off
.map { t -> t.buildAction({ !t.name.startsWith("E") }) { } },
onEditActions = { useDarkTheme = !useDarkTheme },
sheetState = rememberStandardBottomSheetState(), // makes it start out as open
)
}

View File

@ -5,12 +5,14 @@ import org.schabi.newpipe.player.playqueue.PlayQueue
import org.schabi.newpipe.util.Either
import java.time.OffsetDateTime
// TODO move within LongPressable
sealed interface LongPressableDecoration {
data class Duration(val duration: Long) : LongPressableDecoration
data object Live : LongPressableDecoration
data class Playlist(val itemCount: Long) : LongPressableDecoration
}
// TODO this can be a data class
@Stable
interface LongPressable {
val title: String