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 be4f076dd..10c90a6c4 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 @@ -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 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 { + 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 { + 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 { + 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() + } + } +} 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 b870f72b1..7f4be175d 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 @@ -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? = + 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, + 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 ) } 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 0b9d15f14..1dce8161a 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 @@ -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