From 49d4d395127f45a02576819687f3043048be45d4 Mon Sep 17 00:00:00 2001 From: Stypox Date: Tue, 28 Jan 2025 11:12:06 +0100 Subject: [PATCH 01/41] Add Either type with left, right and match functions --- .../java/org/schabi/newpipe/util/Either.kt | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 app/src/main/java/org/schabi/newpipe/util/Either.kt diff --git a/app/src/main/java/org/schabi/newpipe/util/Either.kt b/app/src/main/java/org/schabi/newpipe/util/Either.kt new file mode 100644 index 00000000000..9d1f8f0f24c --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/Either.kt @@ -0,0 +1,25 @@ +package org.schabi.newpipe.util + +import androidx.compose.runtime.Stable +import kotlin.reflect.KClass +import kotlin.reflect.cast +import kotlin.reflect.safeCast + +@Stable +data class Either( + val value: Any, + val classA: KClass, + val classB: KClass, +) { + inline fun match(ifLeft: (A) -> R, ifRight: (B) -> R): R { + return classA.safeCast(value)?.let { ifLeft(it) } + ?: ifRight(classB.cast(value)) + } + + companion object { + inline fun left(a: A): Either = + Either(a, A::class, B::class) + inline fun right(b: B): Either = + Either(b, A::class, B::class) + } +} From 2421ccde03b84ea6ead4b355f7b7a258f999d163 Mon Sep 17 00:00:00 2001 From: Stypox Date: Tue, 28 Jan 2025 11:14:12 +0100 Subject: [PATCH 02/41] Start implementing LongPressMenu Implement content preview --- .../ui/components/menu/LongPressMenu.kt | 252 ++++++++++++++++++ .../ui/components/menu/LongPressable.kt | 21 ++ 2 files changed, 273 insertions(+) create mode 100644 app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressMenu.kt create mode 100644 app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressable.kt 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 new file mode 100644 index 00000000000..44731b12080 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressMenu.kt @@ -0,0 +1,252 @@ +@file:OptIn(ExperimentalMaterial3Api::class) + +package org.schabi.newpipe.ui.components.menu + +import androidx.compose.foundation.basicMarquee +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +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.PlaylistPlay +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.SheetState +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.material3.rememberStandardBottomSheetState +import androidx.compose.runtime.Composable +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.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider +import androidx.compose.ui.tooling.preview.datasource.LoremIpsum +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import org.schabi.newpipe.R +import org.schabi.newpipe.player.playqueue.PlayQueue +import org.schabi.newpipe.player.playqueue.SinglePlayQueue +import org.schabi.newpipe.util.Either +import org.schabi.newpipe.util.Localization +import java.time.OffsetDateTime + +@Composable +fun LongPressMenu( + longPressable: LongPressable, + onDismissRequest: () -> Unit, + sheetState: SheetState = rememberModalBottomSheetState(), +) { + ModalBottomSheet( + onDismissRequest, + sheetState = sheetState, + ) { + Column { + LongPressMenuHeader( + item = longPressable, + modifier = Modifier + .padding(horizontal = 12.dp) + .fillMaxWidth() + ) + Spacer(Modifier.height(100.dp)) + } + } +} + +@Composable +fun LongPressMenuHeader(item: LongPressable, modifier: Modifier = Modifier) { + val ctx = LocalContext.current + + Surface( + color = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer, + shape = MaterialTheme.shapes.large, + modifier = modifier, + ) { + Row { + Box( + modifier = Modifier.height(70.dp) + ) { + if (item.thumbnailUrl != null) { + AsyncImage( + model = item.thumbnailUrl, + contentDescription = null, + placeholder = painterResource(R.drawable.placeholder_thumbnail_video), + error = painterResource(R.drawable.placeholder_thumbnail_video), + modifier = Modifier + .fillMaxHeight() + .widthIn(max = 125.dp) // 16:9 thumbnail at most + .clip(MaterialTheme.shapes.large) + ) + } + + item.playlistSize?.let { playlistSize -> + Surface( + color = Color.Black.copy(alpha = 0.6f), + contentColor = Color.White, + modifier = Modifier + .align(Alignment.TopEnd) + .fillMaxHeight() + .width(40.dp) + .clip(MaterialTheme.shapes.large), + ) { + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxWidth(), + ) { + Icon( + Icons.AutoMirrored.Default.PlaylistPlay, + contentDescription = null, + ) + Text( + text = Localization.localizeStreamCountMini(ctx, playlistSize), + style = MaterialTheme.typography.labelMedium, + maxLines = 1, + ) + } + } + } + + item.duration?.takeIf { it >= 0 }?.let { duration -> + // only show duration if there is a thumbnail and there is no playlist header + if (item.thumbnailUrl != null && item.playlistSize == null) { + Surface( + color = Color.Black.copy(alpha = 0.6f), + contentColor = Color.White, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(4.dp) + .clip(MaterialTheme.shapes.medium), + ) { + Text( + text = Localization.getDurationString(duration), + modifier = Modifier.padding(vertical = 2.dp, horizontal = 3.dp) + ) + } + } + } + } + + Column( + verticalArrangement = Arrangement.SpaceBetween, + modifier = Modifier + .height(70.dp) + .padding(vertical = 12.dp, horizontal = 12.dp), + ) { + Text( + text = item.title, + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + modifier = Modifier.basicMarquee(iterations = Int.MAX_VALUE), + ) + + Text( + text = Localization.concatenateStrings( + item.uploader, + item.uploadDate?.match( + { it }, + { Localization.localizeUploadDate(ctx, it) } + ), + item.viewCount?.let { Localization.localizeViewCount(ctx, it) } + ), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.basicMarquee(iterations = Int.MAX_VALUE), + ) + } + } + } +} + +private class LongPressablePreviews : CollectionPreviewParameterProvider( + listOf( + object : LongPressable { + override val title: String = "Big Buck Bunny" + override val url: String = "https://www.youtube.com/watch?v=YE7VzlLtp-4" + override val thumbnailUrl: String = + "https://i.ytimg.com/vi_webp/YE7VzlLtp-4/maxresdefault.webp" + override val uploader: String = "Blender" + override val uploaderUrl: String = "https://www.youtube.com/@BlenderOfficial" + override val viewCount: Long = 8765432 + override val uploadDate: Either = Either.left("16 years ago") + override val playlistSize: Long = 12 + override val duration: Long = 500 + + override fun getPlayQueue(): PlayQueue { + return SinglePlayQueue(listOf(), 0) + } + }, + object : LongPressable { + override val title: String = LoremIpsum().values.first() + override val url: String = "https://www.youtube.com/watch?v=YE7VzlLtp-4" + override val thumbnailUrl: String? = null + override val uploader: String = "Blender" + override val uploaderUrl: String = "https://www.youtube.com/@BlenderOfficial" + override val viewCount: Long = 8765432 + override val uploadDate: Either = Either.left("16 years ago") + override val playlistSize: Long? = null + override val duration: Long = 500 + + override fun getPlayQueue(): PlayQueue { + return SinglePlayQueue(listOf(), 0) + } + }, + object : LongPressable { + override val title: String = LoremIpsum().values.first() + override val url: String = "https://www.youtube.com/watch?v=YE7VzlLtp-4" + override val thumbnailUrl: String = + "https://i.ytimg.com/vi_webp/YE7VzlLtp-4/maxresdefault.webp" + override val uploader: String? = null + override val uploaderUrl: String? = null + override val viewCount: Long? = null + override val uploadDate: Either? = null + override val playlistSize: Long? = null + override val duration: Long = 500 + + override fun getPlayQueue(): PlayQueue { + return SinglePlayQueue(listOf(), 0) + } + }, + object : LongPressable { + override val title: String = LoremIpsum().values.first() + override val url: String = "https://www.youtube.com/watch?v=YE7VzlLtp-4" + override val thumbnailUrl: String? = null + override val uploader: String? = null + override val uploaderUrl: String? = null + override val viewCount: Long? = null + override val uploadDate: Either? = null + override val playlistSize: Long = 1500 + override val duration: Long = 500 + + override fun getPlayQueue(): PlayQueue { + return SinglePlayQueue(listOf(), 0) + } + } + ) +) + +@Preview +@Composable +private fun LongPressMenuPreview( + @PreviewParameter(LongPressablePreviews::class) longPressable: LongPressable +) { + LongPressMenu( + longPressable = longPressable, + onDismissRequest = {}, + 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 new file mode 100644 index 00000000000..6f66c84b4e3 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressable.kt @@ -0,0 +1,21 @@ +package org.schabi.newpipe.ui.components.menu + +import androidx.compose.runtime.Stable +import org.schabi.newpipe.player.playqueue.PlayQueue +import org.schabi.newpipe.util.Either +import java.time.OffsetDateTime + +@Stable +interface LongPressable { + val title: String + val url: String? + val thumbnailUrl: String? + val uploader: String? + val uploaderUrl: String? + val viewCount: Long? + val uploadDate: Either? + val playlistSize: Long? // null if this is not a playlist + val duration: Long? + + fun getPlayQueue(): PlayQueue +} From c39f3f544702f55c592fe3e319cf032a382581cb Mon Sep 17 00:00:00 2001 From: Stypox Date: Tue, 28 Jan 2025 12:36:32 +0100 Subject: [PATCH 03/41] Calculate button placing in long press menu --- .../ui/components/menu/LongPressMenu.kt | 130 +++++++++++++----- 1 file changed, 96 insertions(+), 34 deletions(-) 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 44731b12080..30e499a7397 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 @@ -1,17 +1,20 @@ -@file:OptIn(ExperimentalMaterial3Api::class) +@file:OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) package org.schabi.newpipe.ui.components.menu import androidx.compose.foundation.basicMarquee import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.material.icons.Icons @@ -36,7 +39,9 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider import androidx.compose.ui.tooling.preview.datasource.LoremIpsum +import androidx.compose.ui.unit.Dp 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.player.playqueue.PlayQueue @@ -55,20 +60,53 @@ fun LongPressMenu( onDismissRequest, sheetState = sheetState, ) { - Column { - LongPressMenuHeader( - item = longPressable, - modifier = Modifier - .padding(horizontal = 12.dp) - .fillMaxWidth() - ) - Spacer(Modifier.height(100.dp)) + BoxWithConstraints( + modifier = Modifier.fillMaxWidth() + .padding(bottom = 16.dp) + ) { + val maxContainerWidth = maxWidth + val minButtonWidth = 60.dp + val buttonHeight = 70.dp + val padding = 12.dp + val boxCount = ((maxContainerWidth - padding) / (minButtonWidth + padding)).toInt() + val buttonWidth = (maxContainerWidth - (boxCount + 1) * padding) / boxCount + val desiredHeaderWidth = buttonWidth * 5 + padding * 4 + + FlowRow( + horizontalArrangement = Arrangement.spacedBy(padding), + verticalArrangement = Arrangement.spacedBy(padding), + // left and right padding are implicit in the .align(Center), this way approximation + // errors in the calculations above don't make the items wrap at the wrong position + modifier = Modifier.align(Alignment.Center), + ) { + LongPressMenuHeader( + item = longPressable, + thumbnailHeight = buttonHeight, + // 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 + // row anyway + Modifier.width(maxContainerWidth - 2 * padding) + } else { + // make sure it has the same height as other buttons + Modifier.size(desiredHeaderWidth, buttonHeight) + } + ) + + for (i in 0..10) { + LongPressMenuButton(modifier = Modifier.size(buttonWidth, buttonHeight)) + } + } } } } @Composable -fun LongPressMenuHeader(item: LongPressable, modifier: Modifier = Modifier) { +fun LongPressMenuHeader( + item: LongPressable, + thumbnailHeight: Dp, + modifier: Modifier = Modifier, +) { val ctx = LocalContext.current Surface( @@ -77,10 +115,8 @@ fun LongPressMenuHeader(item: LongPressable, modifier: Modifier = Modifier) { shape = MaterialTheme.shapes.large, modifier = modifier, ) { - Row { - Box( - modifier = Modifier.height(70.dp) - ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Box { if (item.thumbnailUrl != null) { AsyncImage( model = item.thumbnailUrl, @@ -88,7 +124,7 @@ fun LongPressMenuHeader(item: LongPressable, modifier: Modifier = Modifier) { placeholder = painterResource(R.drawable.placeholder_thumbnail_video), error = painterResource(R.drawable.placeholder_thumbnail_video), modifier = Modifier - .fillMaxHeight() + .height(thumbnailHeight) .widthIn(max = 125.dp) // 16:9 thumbnail at most .clip(MaterialTheme.shapes.large) ) @@ -100,7 +136,7 @@ fun LongPressMenuHeader(item: LongPressable, modifier: Modifier = Modifier) { contentColor = Color.White, modifier = Modifier .align(Alignment.TopEnd) - .fillMaxHeight() + .height(thumbnailHeight) .width(40.dp) .clip(MaterialTheme.shapes.large), ) { @@ -143,10 +179,7 @@ fun LongPressMenuHeader(item: LongPressable, modifier: Modifier = Modifier) { } Column( - verticalArrangement = Arrangement.SpaceBetween, - modifier = Modifier - .height(70.dp) - .padding(vertical = 12.dp, horizontal = 12.dp), + modifier = Modifier.padding(vertical = 12.dp, horizontal = 12.dp), ) { Text( text = item.title, @@ -155,23 +188,36 @@ fun LongPressMenuHeader(item: LongPressable, modifier: Modifier = Modifier) { modifier = Modifier.basicMarquee(iterations = Int.MAX_VALUE), ) - Text( - text = Localization.concatenateStrings( - item.uploader, - item.uploadDate?.match( - { it }, - { Localization.localizeUploadDate(ctx, it) } - ), - item.viewCount?.let { Localization.localizeViewCount(ctx, it) } + val subtitle = Localization.concatenateStrings( + item.uploader, + item.uploadDate?.match( + { it }, + { Localization.relativeTime(it) } ), - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.basicMarquee(iterations = Int.MAX_VALUE), + item.viewCount?.let { Localization.localizeViewCount(ctx, it) } ) + if (subtitle.isNotBlank()) { + Spacer(Modifier.height(1.dp)) + Text( + text = subtitle, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.basicMarquee(iterations = Int.MAX_VALUE), + ) + } } } } } +@Composable +fun LongPressMenuButton(modifier: Modifier = Modifier) { + Surface( + color = Color.Black, + modifier = modifier, + shape = MaterialTheme.shapes.large, + ) { } +} + private class LongPressablePreviews : CollectionPreviewParameterProvider( listOf( object : LongPressable { @@ -205,6 +251,21 @@ private class LongPressablePreviews : CollectionPreviewParameterProvider? = null + override val playlistSize: Long? = null + override val duration: Long? = null + + override fun getPlayQueue(): PlayQueue { + return SinglePlayQueue(listOf(), 0) + } + }, object : LongPressable { override val title: String = LoremIpsum().values.first() override val url: String = "https://www.youtube.com/watch?v=YE7VzlLtp-4" @@ -228,7 +289,7 @@ private class LongPressablePreviews : CollectionPreviewParameterProvider? = null + override val uploadDate: Either = Either.right(OffsetDateTime.now().minusSeconds(12)) override val playlistSize: Long = 1500 override val duration: Long = 500 @@ -240,13 +301,14 @@ private class LongPressablePreviews : CollectionPreviewParameterProvider Date: Tue, 28 Jan 2025 13:08:00 +0100 Subject: [PATCH 04/41] Treat decorations better --- .../ui/components/menu/LongPressMenu.kt | 118 +++++++++++------- .../ui/components/menu/LongPressable.kt | 9 +- 2 files changed, 79 insertions(+), 48 deletions(-) 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 30e499a7397..abcf1585fb3 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 @@ -29,12 +29,14 @@ import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.rememberStandardBottomSheetState import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect 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.platform.LocalContext import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider @@ -130,51 +132,76 @@ fun LongPressMenuHeader( ) } - item.playlistSize?.let { playlistSize -> - Surface( - color = Color.Black.copy(alpha = 0.6f), - contentColor = Color.White, - modifier = Modifier - .align(Alignment.TopEnd) - .height(thumbnailHeight) - .width(40.dp) - .clip(MaterialTheme.shapes.large), - ) { - Column( - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.fillMaxWidth(), - ) { - Icon( - Icons.AutoMirrored.Default.PlaylistPlay, - contentDescription = null, - ) - Text( - text = Localization.localizeStreamCountMini(ctx, playlistSize), - style = MaterialTheme.typography.labelMedium, - maxLines = 1, - ) + when (val decoration = item.decoration) { + is LongPressableDecoration.Duration -> { + // only show duration if there is a thumbnail + if (item.thumbnailUrl != null) { + Surface( + color = Color.Black.copy(alpha = 0.6f), + contentColor = Color.White, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(4.dp) + .clip(MaterialTheme.shapes.medium), + ) { + Text( + text = Localization.getDurationString(decoration.duration), + modifier = Modifier.padding(vertical = 2.dp, horizontal = 4.dp) + ) + } + } + } + is LongPressableDecoration.Live -> { + // only show "Live" if there is a thumbnail + if (item.thumbnailUrl != null) { + Surface( + color = Color.Red.copy(alpha = 0.6f), + contentColor = Color.White, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(4.dp) + .clip(MaterialTheme.shapes.medium), + ) { + Text( + text = stringResource(R.string.duration_live).uppercase(), + modifier = Modifier.padding(vertical = 2.dp, horizontal = 4.dp) + ) + } } } - } - item.duration?.takeIf { it >= 0 }?.let { duration -> - // only show duration if there is a thumbnail and there is no playlist header - if (item.thumbnailUrl != null && item.playlistSize == null) { + is LongPressableDecoration.Playlist -> { Surface( color = Color.Black.copy(alpha = 0.6f), contentColor = Color.White, modifier = Modifier - .align(Alignment.BottomEnd) - .padding(4.dp) - .clip(MaterialTheme.shapes.medium), + .align(Alignment.TopEnd) + .height(thumbnailHeight) + .width(40.dp) + .clip(MaterialTheme.shapes.large), ) { - Text( - text = Localization.getDurationString(duration), - modifier = Modifier.padding(vertical = 2.dp, horizontal = 3.dp) - ) + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxWidth(), + ) { + Icon( + Icons.AutoMirrored.Default.PlaylistPlay, + contentDescription = null, + ) + Text( + text = Localization.localizeStreamCountMini( + ctx, + decoration.itemCount + ), + style = MaterialTheme.typography.labelMedium, + maxLines = 1, + ) + } } } + + null -> {} } } @@ -229,8 +256,7 @@ private class LongPressablePreviews : CollectionPreviewParameterProvider = Either.left("16 years ago") - override val playlistSize: Long = 12 - override val duration: Long = 500 + override val decoration: LongPressableDecoration = LongPressableDecoration.Playlist(12) override fun getPlayQueue(): PlayQueue { return SinglePlayQueue(listOf(), 0) @@ -244,8 +270,7 @@ private class LongPressablePreviews : CollectionPreviewParameterProvider = Either.left("16 years ago") - override val playlistSize: Long? = null - override val duration: Long = 500 + override val decoration: LongPressableDecoration = LongPressableDecoration.Duration(500) override fun getPlayQueue(): PlayQueue { return SinglePlayQueue(listOf(), 0) @@ -259,8 +284,7 @@ private class LongPressablePreviews : CollectionPreviewParameterProvider? = null - override val playlistSize: Long? = null - override val duration: Long? = null + override val decoration: LongPressableDecoration? = null override fun getPlayQueue(): PlayQueue { return SinglePlayQueue(listOf(), 0) @@ -275,8 +299,7 @@ private class LongPressablePreviews : CollectionPreviewParameterProvider? = null - override val playlistSize: Long? = null - override val duration: Long = 500 + override val decoration: LongPressableDecoration = LongPressableDecoration.Live override fun getPlayQueue(): PlayQueue { return SinglePlayQueue(listOf(), 0) @@ -290,8 +313,7 @@ private class LongPressablePreviews : CollectionPreviewParameterProvider = Either.right(OffsetDateTime.now().minusSeconds(12)) - override val playlistSize: Long = 1500 - override val duration: Long = 500 + override val decoration: LongPressableDecoration = LongPressableDecoration.Playlist(1500) override fun getPlayQueue(): PlayQueue { return SinglePlayQueue(listOf(), 0) @@ -306,8 +328,12 @@ private class LongPressablePreviews : CollectionPreviewParameterProvider? - val playlistSize: Long? // null if this is not a playlist - val duration: Long? + val decoration: LongPressableDecoration? fun getPlayQueue(): PlayQueue } From 52add9db2b9f43ab7ff889c085185dba52f3b326 Mon Sep 17 00:00:00 2001 From: Stypox Date: Tue, 28 Jan 2025 14:03:24 +0100 Subject: [PATCH 05/41] Add buttons to long press menu --- .../ui/components/menu/LongPressMenu.kt | 165 ++++++++++++++++-- 1 file changed, 148 insertions(+), 17 deletions(-) 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 abcf1585fb3..f58f13ec10a 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 @@ -2,6 +2,7 @@ package org.schabi.newpipe.ui.components.menu +import android.content.res.Configuration import androidx.compose.foundation.basicMarquee import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -9,6 +10,7 @@ import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth @@ -18,8 +20,22 @@ 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.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.PlaylistAdd +import androidx.compose.material.icons.filled.QueuePlayNext +import androidx.compose.material.icons.filled.Share import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet @@ -34,6 +50,7 @@ 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.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -48,6 +65,7 @@ import coil3.compose.AsyncImage import org.schabi.newpipe.R 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.util.Either import org.schabi.newpipe.util.Localization import java.time.OffsetDateTime @@ -67,12 +85,12 @@ fun LongPressMenu( .padding(bottom = 16.dp) ) { val maxContainerWidth = maxWidth - val minButtonWidth = 60.dp + val minButtonWidth = 70.dp val buttonHeight = 70.dp val padding = 12.dp val boxCount = ((maxContainerWidth - padding) / (minButtonWidth + padding)).toInt() val buttonWidth = (maxContainerWidth - (boxCount + 1) * padding) / boxCount - val desiredHeaderWidth = buttonWidth * 5 + padding * 4 + val desiredHeaderWidth = buttonWidth * 4 + padding * 3 FlowRow( horizontalArrangement = Arrangement.spacedBy(padding), @@ -95,9 +113,91 @@ fun LongPressMenu( } ) - for (i in 0..10) { - LongPressMenuButton(modifier = Modifier.size(buttonWidth, buttonHeight)) - } + 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 = {}, + modifier = Modifier.size(buttonWidth, buttonHeight), + ) + + LongPressMenuButton( + icon = Icons.Default.Delete, + text = stringResource(R.string.delete), + onClick = {}, + modifier = Modifier.size(buttonWidth, buttonHeight), + ) } } } @@ -112,8 +212,8 @@ fun LongPressMenuHeader( val ctx = LocalContext.current Surface( - color = MaterialTheme.colorScheme.primaryContainer, - contentColor = MaterialTheme.colorScheme.onPrimaryContainer, + color = MaterialTheme.colorScheme.surfaceVariant, + contentColor = MaterialTheme.colorScheme.onSurfaceVariant, shape = MaterialTheme.shapes.large, modifier = modifier, ) { @@ -237,12 +337,40 @@ fun LongPressMenuHeader( } @Composable -fun LongPressMenuButton(modifier: Modifier = Modifier) { - Surface( - color = Color.Black, - modifier = modifier, +fun LongPressMenuButton( + icon: ImageVector, + text: String, + onClick: () -> Unit, + enabled: Boolean = true, + modifier: Modifier = Modifier, +) { + FilledTonalButton( + onClick = onClick, + enabled = enabled, shape = MaterialTheme.shapes.large, - ) { } + contentPadding = PaddingValues(4.dp), + modifier = modifier, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(32.dp), + ) + Text( + text = text, + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + // make all text visible with marquee (so new users can learn about the button + // labels), but wait 3 seconds before making many parts of the UI move so that + // normal users are not distracted + modifier = Modifier.basicMarquee( + initialDelayMillis = 3000, + iterations = if (enabled) 2 else 0, + ) + ) + } + } } private class LongPressablePreviews : CollectionPreviewParameterProvider( @@ -323,6 +451,7 @@ private class LongPressablePreviews : CollectionPreviewParameterProvider Date: Tue, 28 Jan 2025 21:27:14 +0100 Subject: [PATCH 06/41] Remove button background and make text 2 lines --- .../ui/components/menu/LongPressMenu.kt | 39 +++++++++++-------- 1 file changed, 23 insertions(+), 16 deletions(-) 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 f58f13ec10a..038f99f1b91 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 @@ -31,14 +31,13 @@ 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.PlaylistAdd import androidx.compose.material.icons.filled.QueuePlayNext import androidx.compose.material.icons.filled.Share import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.SheetState import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -54,6 +53,7 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider @@ -81,7 +81,8 @@ fun LongPressMenu( sheetState = sheetState, ) { BoxWithConstraints( - modifier = Modifier.fillMaxWidth() + modifier = Modifier + .fillMaxWidth() .padding(bottom = 16.dp) ) { val maxContainerWidth = maxWidth @@ -189,6 +190,7 @@ fun LongPressMenu( icon = Icons.Default.Person, text = stringResource(R.string.show_channel_details), onClick = {}, + enabled = longPressable.uploaderUrl != null, modifier = Modifier.size(buttonWidth, buttonHeight), ) @@ -344,11 +346,12 @@ fun LongPressMenuButton( enabled: Boolean = true, modifier: Modifier = Modifier, ) { - FilledTonalButton( + OutlinedButton( onClick = onClick, enabled = enabled, shape = MaterialTheme.shapes.large, contentPadding = PaddingValues(4.dp), + border = null, modifier = modifier, ) { Column(horizontalAlignment = Alignment.CenterHorizontally) { @@ -357,18 +360,22 @@ fun LongPressMenuButton( contentDescription = null, modifier = Modifier.size(32.dp), ) - Text( - text = text, - style = MaterialTheme.typography.bodySmall, - maxLines = 1, - // make all text visible with marquee (so new users can learn about the button - // labels), but wait 3 seconds before making many parts of the UI move so that - // normal users are not distracted - modifier = Modifier.basicMarquee( - initialDelayMillis = 3000, - iterations = if (enabled) 2 else 0, + Box { + // this allows making the box always the same height (i.e. the height of two text + // lines), while making the text appear centered if it is just a single line + Text( + text = "", + style = MaterialTheme.typography.bodySmall, + minLines = 2, ) - ) + Text( + text = text, + style = MaterialTheme.typography.bodySmall, + maxLines = 2, + textAlign = TextAlign.Center, + modifier = Modifier.align(Alignment.Center) + ) + } } } } @@ -463,7 +470,7 @@ private fun LongPressMenuPreview( } AppTheme { LongPressMenu( - longPressable = LongPressablePreviews().values.first(), + longPressable = longPressable, onDismissRequest = {}, sheetState = rememberStandardBottomSheetState(), // makes it start out as open ) From f38c4b9c28d2f05fe58781ca973a96f7332df75b Mon Sep 17 00:00:00 2001 From: Stypox Date: Fri, 31 Jan 2025 13:18:30 +0100 Subject: [PATCH 07/41] Add small Edit button in LongPressMenu --- .../ui/components/menu/LongPressMenu.kt | 29 +++++++++++++++++++ app/src/main/res/values/strings.xml | 1 + 2 files changed, 30 insertions(+) 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 038f99f1b91..ee153157342 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 @@ -26,6 +26,7 @@ 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 @@ -33,8 +34,11 @@ 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.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 @@ -53,6 +57,8 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter @@ -79,6 +85,29 @@ fun LongPressMenu( ModalBottomSheet( onDismissRequest, sheetState = sheetState, + dragHandle = { + Box( + modifier = Modifier.fillMaxWidth() + ) { + BottomSheetDefaults.DragHandle( + modifier = Modifier.align(Alignment.Center) + ) + IconButton( + onClick = {}, + 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, + contentDescription = stringResource(R.string.edit), + // same color and height as the DragHandle + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(2.dp).size(16.dp), + ) + } + } + }, ) { BoxWithConstraints( modifier = Modifier diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c439f19e272..4a736122cad 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -871,6 +871,7 @@ SoundCloud has discontinued the original Top 50 charts. The corresponding tab has been removed from your main page. Next NewPipeExtractor is a library for extracting things from streaming sites. It is a core component of NewPipe, but could be used independently. + Edit %d comment %d comments From 0cf0e162aec3828dd68197f371ad0872abbf6844 Mon Sep 17 00:00:00 2001 From: Stypox Date: Tue, 11 Feb 2025 15:03:37 +0100 Subject: [PATCH 08/41] Click on long press menu subtitle opens channel details --- .../ui/components/menu/LongPressMenu.kt | 77 ++++++++++++++++--- .../schabi/newpipe/ui/theme/CustomColors.kt | 32 ++++++++ .../java/org/schabi/newpipe/ui/theme/Theme.kt | 25 +++--- 3 files changed, 114 insertions(+), 20 deletions(-) create mode 100644 app/src/main/java/org/schabi/newpipe/ui/theme/CustomColors.kt 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 ee153157342..b870f72b154 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 @@ -2,8 +2,10 @@ package org.schabi.newpipe.ui.components.menu +import android.content.Context import android.content.res.Configuration import androidx.compose.foundation.basicMarquee +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints @@ -57,9 +59,12 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.withStyle import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider @@ -72,6 +77,7 @@ import org.schabi.newpipe.R 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 java.time.OffsetDateTime @@ -346,20 +352,24 @@ fun LongPressMenuHeader( modifier = Modifier.basicMarquee(iterations = Int.MAX_VALUE), ) - val subtitle = Localization.concatenateStrings( - item.uploader, - item.uploadDate?.match( - { it }, - { Localization.relativeTime(it) } - ), - item.viewCount?.let { Localization.localizeViewCount(ctx, it) } + val subtitle = getSubtitleAnnotatedString( + item = item, + linkColor = MaterialTheme.customColors.onSurfaceVariantLink, + ctx = ctx, ) if (subtitle.isNotBlank()) { Spacer(Modifier.height(1.dp)) + Text( text = subtitle, style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.basicMarquee(iterations = Int.MAX_VALUE), + modifier = if (item.uploaderUrl.isNullOrBlank()) { + Modifier + } else { + Modifier.clickable { + // TODO handle click on uploader URL + } + }.basicMarquee(iterations = Int.MAX_VALUE) ) } } @@ -367,6 +377,53 @@ fun LongPressMenuHeader( } } +fun getSubtitleAnnotatedString( + item: LongPressable, + linkColor: Color, + ctx: Context, +) = buildAnnotatedString { + var shouldAddSeparator = false + if (!item.uploaderUrl.isNullOrBlank()) { + withStyle( + SpanStyle( + fontWeight = FontWeight.Bold, + color = linkColor, + textDecoration = TextDecoration.Underline + ) + ) { + if (item.uploader.isNullOrBlank()) { + append(ctx.getString(R.string.show_channel_details)) + } else { + append(item.uploader) + } + } + shouldAddSeparator = true + } else if (!item.uploader.isNullOrBlank()) { + append(item.uploader) + shouldAddSeparator = true + } + + val uploadDate = item.uploadDate?.match( + { it }, + { Localization.relativeTime(it) } + ) + if (!uploadDate.isNullOrBlank()) { + if (shouldAddSeparator) { + append(Localization.DOT_SEPARATOR) + } + shouldAddSeparator = true + append(uploadDate) + } + + val viewCount = item.viewCount?.let { Localization.localizeViewCount(ctx, it) } + if (!viewCount.isNullOrBlank()) { + if (shouldAddSeparator) { + append(Localization.DOT_SEPARATOR) + } + append(viewCount) + } +} + @Composable fun LongPressMenuButton( icon: ImageVector, diff --git a/app/src/main/java/org/schabi/newpipe/ui/theme/CustomColors.kt b/app/src/main/java/org/schabi/newpipe/ui/theme/CustomColors.kt new file mode 100644 index 00000000000..aac91ee048a --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/ui/theme/CustomColors.kt @@ -0,0 +1,32 @@ +package org.schabi.newpipe.ui.theme + +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.Color + +@Immutable +data class CustomColors( + val onSurfaceVariantLink: Color = Color.Unspecified, +) + +val onSurfaceVariantLinkLight = Color(0xFF5060B0) + +val onSurfaceVariantLinkDark = Color(0xFFC0D0FF) + +val lightCustomColors = CustomColors( + onSurfaceVariantLink = onSurfaceVariantLinkLight +) + +val darkCustomColors = CustomColors( + onSurfaceVariantLink = onSurfaceVariantLinkDark +) + +val LocalCustomColors = staticCompositionLocalOf { CustomColors() } + +val MaterialTheme.customColors: CustomColors + @Composable + @ReadOnlyComposable + get() = LocalCustomColors.current diff --git a/app/src/main/java/org/schabi/newpipe/ui/theme/Theme.kt b/app/src/main/java/org/schabi/newpipe/ui/theme/Theme.kt index d436b35a2e6..dbe4949841c 100644 --- a/app/src/main/java/org/schabi/newpipe/ui/theme/Theme.kt +++ b/app/src/main/java/org/schabi/newpipe/ui/theme/Theme.kt @@ -5,6 +5,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.darkColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.preference.PreferenceManager @@ -93,14 +94,18 @@ fun AppTheme(useDarkTheme: Boolean = isSystemInDarkTheme(), content: @Composable val theme = sharedPreferences.getString("theme", "auto_device_theme") val nightTheme = sharedPreferences.getString("night_theme", "dark_theme") - MaterialTheme( - colorScheme = if (!useDarkTheme) { - lightScheme - } else if (theme == "black_theme" || nightTheme == "black_theme") { - blackScheme - } else { - darkScheme - }, - content = content - ) + CompositionLocalProvider( + LocalCustomColors provides if (!useDarkTheme) lightCustomColors else darkCustomColors + ) { + MaterialTheme( + colorScheme = if (!useDarkTheme) { + lightScheme + } else if (theme == "black_theme" || nightTheme == "black_theme") { + blackScheme + } else { + darkScheme + }, + content = content + ) + } } From 7164725ef82fc871e83dc06db8fec9002bd8d9bf Mon Sep 17 00:00:00 2001 From: Stypox Date: Tue, 11 Feb 2025 15:46:03 +0100 Subject: [PATCH 09/41] Initial work on handling many long-press actions --- .../list/playlist/PlaylistFragment.java | 11 + .../ui/components/menu/LongPressAction.kt | 149 +++++++++++++ .../ui/components/menu/LongPressMenu.kt | 206 ++++++++---------- .../ui/components/menu/LongPressable.kt | 2 + 4 files changed, 258 insertions(+), 110 deletions(-) create mode 100644 app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressAction.kt 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 be4f076ddf0..10c90a6c4e0 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 @@ 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 @@ private PlayQueue getPlayQueueStartingAt(final StreamInfoItem infoItem) { @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 = 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 new file mode 100644 index 00000000000..793978860ca --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressAction.kt @@ -0,0 +1,149 @@ +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.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 { + 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) + .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 b870f72b154..7f4be175d4c 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 0b9d15f14a8..1dce8161a77 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 From 81750c71a4d3558302f10c769fd23747d6baf400 Mon Sep 17 00:00:00 2001 From: Stypox Date: Tue, 11 Feb 2025 15:56:09 +0100 Subject: [PATCH 10/41] Make LongPressable a data class --- .../ui/components/menu/LongPressMenu.kt | 170 +++++++----------- .../ui/components/menu/LongPressable.kt | 35 ++-- 2 files changed, 83 insertions(+), 122 deletions(-) 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 7f4be175d4c..7c19daef87c 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 @@ -69,8 +69,6 @@ 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 @@ -80,36 +78,26 @@ import java.time.OffsetDateTime fun getLongPressMenuView( context: Context, - streamInfoItem: StreamInfoItem, + item: 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") - } - }, + longPressable = LongPressable( + title = item.name, + url = item.url?.takeIf { it.isNotBlank() }, + thumbnailUrl = ImageStrategy.choosePreferredImage(item.thumbnails), + uploader = item.uploaderName?.takeIf { it.isNotBlank() }, + uploaderUrl = item.uploaderUrl?.takeIf { it.isNotBlank() }, + viewCount = item.viewCount.takeIf { it >= 0 }, + uploadDate = item.uploadDate?.let { Either.right(it.offsetDateTime()) } + ?: item.textualUploadDate?.let { Either.left(it) }, + decoration = item.duration.takeIf { it >= 0 }?.let { + LongPressable.Decoration.Duration(it) + }, + ), onDismissRequest = { (this.parent as ViewGroup).removeView(this) }, - actions = LongPressAction.buildActionList(streamInfoItem, false), + actions = LongPressAction.buildActionList(item, false), onEditActions = {}, ) } @@ -245,7 +233,7 @@ fun LongPressMenuHeader( } when (val decoration = item.decoration) { - is LongPressableDecoration.Duration -> { + is LongPressable.Decoration.Duration -> { // only show duration if there is a thumbnail if (item.thumbnailUrl != null) { Surface( @@ -263,7 +251,7 @@ fun LongPressMenuHeader( } } } - is LongPressableDecoration.Live -> { + is LongPressable.Decoration.Live -> { // only show "Live" if there is a thumbnail if (item.thumbnailUrl != null) { Surface( @@ -282,7 +270,7 @@ fun LongPressMenuHeader( } } - is LongPressableDecoration.Playlist -> { + is LongPressable.Decoration.Playlist -> { Surface( color = Color.Black.copy(alpha = 0.6f), contentColor = Color.White, @@ -443,78 +431,56 @@ fun LongPressMenuButton( private class LongPressablePreviews : CollectionPreviewParameterProvider( listOf( - object : LongPressable { - override val title: String = "Big Buck Bunny" - override val url: String = "https://www.youtube.com/watch?v=YE7VzlLtp-4" - override val thumbnailUrl: String = - "https://i.ytimg.com/vi_webp/YE7VzlLtp-4/maxresdefault.webp" - override val uploader: String = "Blender" - override val uploaderUrl: String = "https://www.youtube.com/@BlenderOfficial" - override val viewCount: Long = 8765432 - override val uploadDate: Either = Either.left("16 years ago") - override val decoration: LongPressableDecoration = LongPressableDecoration.Playlist(12) - - override fun getPlayQueue(): PlayQueue { - return SinglePlayQueue(listOf(), 0) - } - }, - object : LongPressable { - override val title: String = LoremIpsum().values.first() - override val url: String = "https://www.youtube.com/watch?v=YE7VzlLtp-4" - override val thumbnailUrl: String? = null - override val uploader: String = "Blender" - override val uploaderUrl: String = "https://www.youtube.com/@BlenderOfficial" - override val viewCount: Long = 8765432 - override val uploadDate: Either = Either.left("16 years ago") - override val decoration: LongPressableDecoration = LongPressableDecoration.Duration(500) - - override fun getPlayQueue(): PlayQueue { - return SinglePlayQueue(listOf(), 0) - } - }, - object : LongPressable { - override val title: String = LoremIpsum().values.first() - override val url: String = "https://www.youtube.com/watch?v=YE7VzlLtp-4" - override val thumbnailUrl: String? = null - override val uploader: String? = null - override val uploaderUrl: String = "https://www.youtube.com/@BlenderOfficial" - override val viewCount: Long? = null - override val uploadDate: Either? = null - override val decoration: LongPressableDecoration? = null - - override fun getPlayQueue(): PlayQueue { - return SinglePlayQueue(listOf(), 0) - } - }, - object : LongPressable { - override val title: String = LoremIpsum().values.first() - override val url: String = "https://www.youtube.com/watch?v=YE7VzlLtp-4" - override val thumbnailUrl: String = - "https://i.ytimg.com/vi_webp/YE7VzlLtp-4/maxresdefault.webp" - override val uploader: String? = null - override val uploaderUrl: String? = null - override val viewCount: Long? = null - override val uploadDate: Either? = null - override val decoration: LongPressableDecoration = LongPressableDecoration.Live - - override fun getPlayQueue(): PlayQueue { - return SinglePlayQueue(listOf(), 0) - } - }, - object : LongPressable { - override val title: String = LoremIpsum().values.first() - override val url: String = "https://www.youtube.com/watch?v=YE7VzlLtp-4" - override val thumbnailUrl: String? = null - override val uploader: String? = null - override val uploaderUrl: String? = null - override val viewCount: Long? = null - override val uploadDate: Either = Either.right(OffsetDateTime.now().minusSeconds(12)) - override val decoration: LongPressableDecoration = LongPressableDecoration.Playlist(1500) - - override fun getPlayQueue(): PlayQueue { - return SinglePlayQueue(listOf(), 0) - } - } + LongPressable( + title = "Big Buck Bunny", + url = "https://www.youtube.com/watch?v=YE7VzlLtp-4", + thumbnailUrl = "https://i.ytimg.com/vi_webp/YE7VzlLtp-4/maxresdefault.webp", + uploader = "Blender", + uploaderUrl = "https://www.youtube.com/@BlenderOfficial", + viewCount = 8765432, + uploadDate = Either.left("16 years ago"), + decoration = LongPressable.Decoration.Playlist(12), + ), + LongPressable( + title = LoremIpsum().values.first(), + url = "https://www.youtube.com/watch?v=YE7VzlLtp-4", + thumbnailUrl = null, + uploader = "Blender", + uploaderUrl = "https://www.youtube.com/@BlenderOfficial", + viewCount = 8765432, + uploadDate = Either.left("16 years ago"), + decoration = LongPressable.Decoration.Duration(500), + ), + LongPressable( + title = LoremIpsum().values.first(), + url = "https://www.youtube.com/watch?v=YE7VzlLtp-4", + thumbnailUrl = null, + uploader = null, + uploaderUrl = "https://www.youtube.com/@BlenderOfficial", + viewCount = null, + uploadDate = null, + decoration = null, + ), + LongPressable( + title = LoremIpsum().values.first(), + url = "https://www.youtube.com/watch?v=YE7VzlLtp-4", + thumbnailUrl = "https://i.ytimg.com/vi_webp/YE7VzlLtp-4/maxresdefault.webp", + uploader = null, + uploaderUrl = null, + viewCount = null, + uploadDate = null, + decoration = LongPressable.Decoration.Live, + ), + LongPressable( + title = LoremIpsum().values.first(), + url = "https://www.youtube.com/watch?v=YE7VzlLtp-4", + thumbnailUrl = null, + uploader = null, + uploaderUrl = null, + viewCount = null, + uploadDate = Either.right(OffsetDateTime.now().minusSeconds(12)), + decoration = LongPressable.Decoration.Playlist(1500), + ), ) ) 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 1dce8161a77..b4b9cae1ae4 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 @@ -1,28 +1,23 @@ package org.schabi.newpipe.ui.components.menu import androidx.compose.runtime.Stable -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 - val url: String? - val thumbnailUrl: String? - val uploader: String? - val uploaderUrl: String? - val viewCount: Long? - val uploadDate: Either? - val decoration: LongPressableDecoration? - - fun getPlayQueue(): PlayQueue +data class LongPressable( + val title: String, + val url: String?, + val thumbnailUrl: String?, + val uploader: String?, + val uploaderUrl: String?, + val viewCount: Long?, + val uploadDate: Either?, + val decoration: Decoration?, +) { + sealed interface Decoration { + data class Duration(val duration: Long) : Decoration + data object Live : Decoration + data class Playlist(val itemCount: Long) : Decoration + } } From 2bc4cf30cc906b66f4ff388e86dc26b21bbcda95 Mon Sep 17 00:00:00 2001 From: Stypox Date: Tue, 11 Feb 2025 16:09:03 +0100 Subject: [PATCH 11/41] Move long press menu drag handle to its own composable --- .../ui/components/menu/LongPressMenu.kt | 54 ++++++++++--------- 1 file changed, 29 insertions(+), 25 deletions(-) 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 7c19daef87c..a8ab0e1614d 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 @@ -115,31 +115,7 @@ fun LongPressMenu( ModalBottomSheet( onDismissRequest, sheetState = sheetState, - dragHandle = { - Box( - modifier = Modifier.fillMaxWidth() - ) { - BottomSheetDefaults.DragHandle( - modifier = Modifier.align(Alignment.Center) - ) - IconButton( - 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.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), - ) - } - } - }, + dragHandle = { LongPressMenuDragHandle(onEditActions) }, ) { BoxWithConstraints( modifier = Modifier @@ -202,6 +178,34 @@ fun LongPressMenu( } } +@Preview +@Composable +fun LongPressMenuDragHandle(onEditActions: () -> Unit = {}) { + Box( + modifier = Modifier.fillMaxWidth() + ) { + BottomSheetDefaults.DragHandle( + modifier = Modifier.align(Alignment.Center) + ) + IconButton( + 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.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), + ) + } + } +} + @Composable fun LongPressMenuHeader( item: LongPressable, From 054dbdf619462e3d13bb20d19e213c87e1bc595c Mon Sep 17 00:00:00 2001 From: Stypox Date: Tue, 11 Feb 2025 17:18:08 +0100 Subject: [PATCH 12/41] Improve how long press menu buttons are laid out --- .../main/java/org/schabi/newpipe/ktx/List.kt | 9 ++ .../ui/components/menu/LongPressMenu.kt | 151 ++++++++++-------- 2 files changed, 97 insertions(+), 63 deletions(-) create mode 100644 app/src/main/java/org/schabi/newpipe/ktx/List.kt diff --git a/app/src/main/java/org/schabi/newpipe/ktx/List.kt b/app/src/main/java/org/schabi/newpipe/ktx/List.kt new file mode 100644 index 00000000000..0dd41bb6e1f --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/ktx/List.kt @@ -0,0 +1,9 @@ +package org.schabi.newpipe.ktx + +fun MutableList.popFirst(filter: (A) -> Boolean): A? { + val i = indexOfFirst(filter) + if (i < 0) { + return null + } + return removeAt(i) +} 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 a8ab0e1614d..55b8c56569e 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 @@ -1,4 +1,4 @@ -@file:OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) +@file:OptIn(ExperimentalMaterial3Api::class) package org.schabi.newpipe.ui.components.menu @@ -12,16 +12,14 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ExperimentalLayoutApi -import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding 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.PlaylistPlay @@ -63,12 +61,13 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider import androidx.compose.ui.tooling.preview.datasource.LoremIpsum -import androidx.compose.ui.unit.Dp 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.ktx.popFirst +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.theme.AppTheme import org.schabi.newpipe.ui.theme.customColors import org.schabi.newpipe.util.Either @@ -97,7 +96,7 @@ fun getLongPressMenuView( }, ), onDismissRequest = { (this.parent as ViewGroup).removeView(this) }, - actions = LongPressAction.buildActionList(item, false), + longPressActions = LongPressAction.buildActionList(item, false), onEditActions = {}, ) } @@ -108,7 +107,7 @@ fun getLongPressMenuView( fun LongPressMenu( longPressable: LongPressable, onDismissRequest: () -> Unit, - actions: List, + longPressActions: List, onEditActions: () -> Unit, sheetState: SheetState = rememberModalBottomSheetState(), ) { @@ -120,58 +119,86 @@ fun LongPressMenu( BoxWithConstraints( modifier = Modifier .fillMaxWidth() - .padding(bottom = 16.dp) + .padding(start = 6.dp, end = 6.dp, bottom = 16.dp) ) { - val maxContainerWidth = maxWidth - val minButtonWidth = 70.dp - val buttonHeight = 70.dp - val padding = 12.dp - val boxCount = ((maxContainerWidth - padding) / (minButtonWidth + padding)).toInt() - val buttonWidth = (maxContainerWidth - (boxCount + 1) * padding) / boxCount - val desiredHeaderWidth = buttonWidth * 4 + padding * 3 + val minButtonWidth = 80.dp + val buttonHeight = 85.dp + val headerWidthInButtons = 5 // the header is 5 times as wide as the buttons + val buttonsPerRow = (maxWidth / minButtonWidth).toInt() - FlowRow( - horizontalArrangement = Arrangement.spacedBy(padding), - verticalArrangement = Arrangement.spacedBy(padding), - // left and right padding are implicit in the .align(Center), this way approximation - // 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 - } - } + // the channel icon goes in the menu header, so do not show a button for it + val actions = longPressActions.toMutableList() + val showChannelAction = actions.popFirst { it.type == ShowChannelDetails } + val ctx = LocalContext.current - 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 - // row anyway - Modifier.width(maxContainerWidth - 2 * padding) - } else { - // make sure it has the same height as other buttons - Modifier.size(desiredHeaderWidth, buttonHeight) - } - ) + Column { + var actionIndex = -1 // -1 indicates the header + while (actionIndex < actions.size) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + var rowIndex = 0 + while (rowIndex < buttonsPerRow) { + if (actionIndex >= actions.size) { + // no more buttons to show, fill the rest of the row with a + // spacer that has the same weight as the missing buttons, so that + // the other buttons don't grow too wide + Spacer( + modifier = Modifier + .height(buttonHeight) + .fillMaxWidth() + .weight((buttonsPerRow - rowIndex).toFloat()), + ) + break - 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), - ) + } else if (actionIndex >= 0) { + val action = actions[actionIndex] + LongPressMenuButton( + icon = action.type.icon, + text = stringResource(action.type.label), + onClick = { action.action(ctx) }, + enabled = action.enabled(false), + modifier = Modifier + .height(buttonHeight) + .fillMaxWidth() + .weight(1F), + ) + rowIndex += 1 + + } else if (headerWidthInButtons >= buttonsPerRow) { + // this branch is taken if the header is going to fit on one line + // (i.e. on phones in portrait) + LongPressMenuHeader( + item = longPressable, + onUploaderClickAction = showChannelAction?.action, + modifier = Modifier + // leave the height as small as possible, since it's the + // only item on the row anyway + .padding(start = 6.dp, end = 6.dp, bottom = 6.dp) + .fillMaxWidth() + .weight(headerWidthInButtons.toFloat()), + ) + rowIndex += headerWidthInButtons + + } else { + // this branch is taken if the header will have some buttons to its + // right (i.e. on tablets or on phones in landscape) + LongPressMenuHeader( + item = longPressable, + onUploaderClickAction = showChannelAction?.action, + modifier = Modifier + .padding(6.dp) + .heightIn(min = 70.dp) + .fillMaxWidth() + .weight(headerWidthInButtons.toFloat()), + ) + rowIndex += headerWidthInButtons + + } + actionIndex += 1 + } + } } } } @@ -209,7 +236,6 @@ fun LongPressMenuDragHandle(onEditActions: () -> Unit = {}) { @Composable fun LongPressMenuHeader( item: LongPressable, - thumbnailHeight: Dp, onUploaderClickAction: ((context: Context) -> Unit)?, modifier: Modifier = Modifier, ) { @@ -230,7 +256,7 @@ fun LongPressMenuHeader( placeholder = painterResource(R.drawable.placeholder_thumbnail_video), error = painterResource(R.drawable.placeholder_thumbnail_video), modifier = Modifier - .height(thumbnailHeight) + .height(70.dp) .widthIn(max = 125.dp) // 16:9 thumbnail at most .clip(MaterialTheme.shapes.large) ) @@ -280,8 +306,7 @@ fun LongPressMenuHeader( contentColor = Color.White, modifier = Modifier .align(Alignment.TopEnd) - .height(thumbnailHeight) - .width(40.dp) + .size(width = 40.dp, height = 70.dp) .clip(MaterialTheme.shapes.large), ) { Column( @@ -403,7 +428,7 @@ fun LongPressMenuButton( onClick = onClick, enabled = enabled, shape = MaterialTheme.shapes.large, - contentPadding = PaddingValues(4.dp), + contentPadding = PaddingValues(start = 3.dp, top = 8.dp, end = 3.dp, bottom = 2.dp), border = null, modifier = modifier, ) { @@ -510,9 +535,9 @@ private fun LongPressMenuPreview( LongPressMenu( longPressable = longPressable ?: LongPressablePreviews().values.first(), onDismissRequest = {}, - actions = LongPressAction.Type.entries + longPressActions = LongPressAction.Type.entries // disable Enqueue actions just to show it off - .map { t -> t.buildAction({ !t.name.startsWith("E") }) { } }, + .map { t -> t.buildAction({ t != EnqueueNext }) { } }, onEditActions = { useDarkTheme = !useDarkTheme }, sheetState = rememberStandardBottomSheetState(), // makes it start out as open ) From 7d79f699191d98ada49d07961c2eef34aeb08230 Mon Sep 17 00:00:00 2001 From: Stypox Date: Tue, 11 Feb 2025 17:29:33 +0100 Subject: [PATCH 13/41] Dismiss long press menu after click on a button --- .../ui/components/menu/LongPressMenu.kt | 29 +++++++++++++------ 1 file changed, 20 insertions(+), 9 deletions(-) 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 55b8c56569e..f01e58501ed 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 @@ -128,8 +128,14 @@ fun LongPressMenu( // the channel icon goes in the menu header, so do not show a button for it val actions = longPressActions.toMutableList() - val showChannelAction = actions.popFirst { it.type == ShowChannelDetails } val ctx = LocalContext.current + val onUploaderClick = actions.popFirst { it.type == ShowChannelDetails } + ?.let { showChannelDetailsAction -> + { + showChannelDetailsAction.action(ctx) + onDismissRequest() + } + } Column { var actionIndex = -1 // -1 indicates the header @@ -157,7 +163,10 @@ fun LongPressMenu( LongPressMenuButton( icon = action.type.icon, text = stringResource(action.type.label), - onClick = { action.action(ctx) }, + onClick = { + action.action(ctx) + onDismissRequest() + }, enabled = action.enabled(false), modifier = Modifier .height(buttonHeight) @@ -171,7 +180,7 @@ fun LongPressMenu( // (i.e. on phones in portrait) LongPressMenuHeader( item = longPressable, - onUploaderClickAction = showChannelAction?.action, + onUploaderClick = onUploaderClick, modifier = Modifier // leave the height as small as possible, since it's the // only item on the row anyway @@ -186,7 +195,7 @@ fun LongPressMenu( // right (i.e. on tablets or on phones in landscape) LongPressMenuHeader( item = longPressable, - onUploaderClickAction = showChannelAction?.action, + onUploaderClick = onUploaderClick, modifier = Modifier .padding(6.dp) .heightIn(min = 70.dp) @@ -236,7 +245,7 @@ fun LongPressMenuDragHandle(onEditActions: () -> Unit = {}) { @Composable fun LongPressMenuHeader( item: LongPressable, - onUploaderClickAction: ((context: Context) -> Unit)?, + onUploaderClick: (() -> Unit)?, modifier: Modifier = Modifier, ) { val ctx = LocalContext.current @@ -346,7 +355,7 @@ fun LongPressMenuHeader( val subtitle = getSubtitleAnnotatedString( item = item, - showLink = onUploaderClickAction != null, + showLink = onUploaderClick != null, linkColor = MaterialTheme.customColors.onSurfaceVariantLink, ctx = ctx, ) @@ -356,11 +365,13 @@ fun LongPressMenuHeader( Text( text = subtitle, style = MaterialTheme.typography.bodyMedium, - modifier = if (onUploaderClickAction == null) { + modifier = if (onUploaderClick == null) { Modifier } else { - Modifier.clickable { onUploaderClickAction(ctx) } - }.basicMarquee(iterations = Int.MAX_VALUE) + Modifier.clickable(onClick = onUploaderClick) + } + .fillMaxWidth() + .basicMarquee(iterations = Int.MAX_VALUE) ) } } From c9f8e149289e970da01c5fc069c2ed2e138a24b3 Mon Sep 17 00:00:00 2001 From: Stypox Date: Tue, 11 Feb 2025 18:37:00 +0100 Subject: [PATCH 14/41] Add download long press menu action --- .../newpipe/ui/components/menu/LongPressAction.kt | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) 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 793978860ca..3376e73e381 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 @@ -21,6 +21,7 @@ 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.download.DownloadDialog import org.schabi.newpipe.extractor.InfoItem import org.schabi.newpipe.extractor.stream.StreamInfoItem import org.schabi.newpipe.ktx.findFragmentActivity @@ -107,7 +108,16 @@ data class LongPressAction( return buildPlayerActionList { SinglePlayQueue(item) } + buildShareActionList(item) + listOf( - Type.Download.buildAction { context -> /* TODO */ }, + 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, From 0bc3efd8dc78e4c29cc8db5cc62a9a357cf2922a Mon Sep 17 00:00:00 2001 From: Stypox Date: Tue, 11 Feb 2025 18:42:36 +0100 Subject: [PATCH 15/41] Move LongPressable builders to LongPressable class --- .../list/playlist/PlaylistFragment.java | 8 ++++- .../ui/components/menu/LongPressAction.kt | 1 + .../ui/components/menu/LongPressMenu.kt | 35 ++++++------------- .../ui/components/menu/LongPressable.kt | 26 ++++++++++++++ 4 files changed, 44 insertions(+), 26 deletions(-) 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 10c90a6c4e0..269d85c7a5d 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 @@ -49,6 +49,8 @@ import org.schabi.newpipe.local.playlist.RemotePlaylistManager; import org.schabi.newpipe.player.playqueue.PlayQueue; import org.schabi.newpipe.player.playqueue.PlaylistPlayQueue; +import org.schabi.newpipe.ui.components.menu.LongPressAction; +import org.schabi.newpipe.ui.components.menu.LongPressable; import org.schabi.newpipe.util.ExtractorHelper; import org.schabi.newpipe.util.Localization; import org.schabi.newpipe.util.NavigationHelper; @@ -152,7 +154,11 @@ private PlayQueue getPlayQueueStartingAt(final StreamInfoItem infoItem) { @Override protected void showInfoItemDialog(final StreamInfoItem item) { activity.addContentView( - getLongPressMenuView(requireContext(), item), + getLongPressMenuView( + requireContext(), + LongPressable.from(item), + LongPressAction.buildActionList(item, false) + ), new ViewGroup.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT 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 3376e73e381..ffaafda4dcc 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 @@ -100,6 +100,7 @@ data class LongPressAction( ) } + @JvmStatic fun buildActionList( item: StreamInfoItem, isKodiEnabled: Boolean, 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 f01e58501ed..66c7a262959 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 @@ -64,7 +64,6 @@ import androidx.compose.ui.tooling.preview.datasource.LoremIpsum import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage import org.schabi.newpipe.R -import org.schabi.newpipe.extractor.stream.StreamInfoItem import org.schabi.newpipe.ktx.popFirst import org.schabi.newpipe.ui.components.menu.LongPressAction.Type.EnqueueNext import org.schabi.newpipe.ui.components.menu.LongPressAction.Type.ShowChannelDetails @@ -72,33 +71,23 @@ 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, - item: StreamInfoItem, + longPressable: LongPressable, + longPressActions: List, ): ComposeView { return ComposeView(context).apply { setContent { - LongPressMenu( - longPressable = LongPressable( - title = item.name, - url = item.url?.takeIf { it.isNotBlank() }, - thumbnailUrl = ImageStrategy.choosePreferredImage(item.thumbnails), - uploader = item.uploaderName?.takeIf { it.isNotBlank() }, - uploaderUrl = item.uploaderUrl?.takeIf { it.isNotBlank() }, - viewCount = item.viewCount.takeIf { it >= 0 }, - uploadDate = item.uploadDate?.let { Either.right(it.offsetDateTime()) } - ?: item.textualUploadDate?.let { Either.left(it) }, - decoration = item.duration.takeIf { it >= 0 }?.let { - LongPressable.Decoration.Duration(it) - }, - ), - onDismissRequest = { (this.parent as ViewGroup).removeView(this) }, - longPressActions = LongPressAction.buildActionList(item, false), - onEditActions = {}, - ) + AppTheme { + LongPressMenu( + longPressable = longPressable, + onDismissRequest = { (this.parent as ViewGroup).removeView(this) }, + longPressActions = longPressActions, + onEditActions = {}, + ) + } } } } @@ -157,7 +146,6 @@ fun LongPressMenu( .weight((buttonsPerRow - rowIndex).toFloat()), ) break - } else if (actionIndex >= 0) { val action = actions[actionIndex] LongPressMenuButton( @@ -174,7 +162,6 @@ fun LongPressMenu( .weight(1F), ) rowIndex += 1 - } else if (headerWidthInButtons >= buttonsPerRow) { // this branch is taken if the header is going to fit on one line // (i.e. on phones in portrait) @@ -189,7 +176,6 @@ fun LongPressMenu( .weight(headerWidthInButtons.toFloat()), ) rowIndex += headerWidthInButtons - } else { // this branch is taken if the header will have some buttons to its // right (i.e. on tablets or on phones in landscape) @@ -203,7 +189,6 @@ fun LongPressMenu( .weight(headerWidthInButtons.toFloat()), ) rowIndex += headerWidthInButtons - } actionIndex += 1 } 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 b4b9cae1ae4..ada15e6cdd5 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 @@ -1,7 +1,10 @@ package org.schabi.newpipe.ui.components.menu import androidx.compose.runtime.Stable +import org.schabi.newpipe.extractor.stream.StreamInfoItem +import org.schabi.newpipe.extractor.stream.StreamType import org.schabi.newpipe.util.Either +import org.schabi.newpipe.util.image.ImageStrategy import java.time.OffsetDateTime @Stable @@ -20,4 +23,27 @@ data class LongPressable( data object Live : Decoration data class Playlist(val itemCount: Long) : Decoration } + + companion object { + @JvmStatic + fun from(item: StreamInfoItem) = LongPressable( + title = item.name, + url = item.url?.takeIf { it.isNotBlank() }, + thumbnailUrl = ImageStrategy.choosePreferredImage(item.thumbnails), + uploader = item.uploaderName?.takeIf { it.isNotBlank() }, + uploaderUrl = item.uploaderUrl?.takeIf { it.isNotBlank() }, + viewCount = item.viewCount.takeIf { it >= 0 }, + uploadDate = item.uploadDate?.let { Either.right(it.offsetDateTime()) } + ?: item.textualUploadDate?.let { Either.left(it) }, + decoration = if (item.streamType == StreamType.LIVE_STREAM || + item.streamType == StreamType.AUDIO_LIVE_STREAM + ) { + LongPressable.Decoration.Live + } else { + item.duration.takeIf { it >= 0 }?.let { + LongPressable.Decoration.Duration(it) + } + }, + ) + } } From 820438b0cb8e1a9ff277d85439ac62749a469e07 Mon Sep 17 00:00:00 2001 From: Stypox Date: Wed, 12 Feb 2025 01:03:34 +0100 Subject: [PATCH 16/41] Replace InfoItemDialog with LongPressMenu --- .../fragments/list/BaseListFragment.java | 14 +- .../list/playlist/PlaylistFragment.java | 38 +---- .../schabi/newpipe/local/feed/FeedFragment.kt | 20 +-- .../history/StatisticsPlaylistFragment.java | 59 ++------ .../local/playlist/LocalPlaylistFragment.java | 48 ++---- .../newpipe/ui/components/items/ItemList.kt | 22 +-- .../components/items/stream/StreamListItem.kt | 31 +++- .../ui/components/items/stream/StreamMenu.kt | 137 ------------------ .../ui/components/menu/LongPressAction.kt | 61 +++++++- .../ui/components/menu/LongPressMenu.kt | 20 ++- .../ui/components/menu/LongPressable.kt | 37 +++-- 11 files changed, 166 insertions(+), 321 deletions(-) delete mode 100644 app/src/main/java/org/schabi/newpipe/ui/components/items/stream/StreamMenu.kt diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListFragment.java index 8a117a47a9a..7a1ee30952d 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListFragment.java @@ -2,6 +2,7 @@ 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.openLongPressMenuInActivity; import android.content.Context; import android.content.SharedPreferences; @@ -27,7 +28,8 @@ import org.schabi.newpipe.fragments.OnScrollBelowItemsListener; import org.schabi.newpipe.info_list.InfoListAdapter; import org.schabi.newpipe.info_list.ItemViewMode; -import org.schabi.newpipe.info_list.dialog.InfoItemDialog; +import org.schabi.newpipe.ui.components.menu.LongPressAction; +import org.schabi.newpipe.ui.components.menu.LongPressable; import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.OnClickGesture; import org.schabi.newpipe.util.StateSaver; @@ -387,11 +389,11 @@ protected void onScrollToBottom() { } protected void showInfoItemDialog(final StreamInfoItem item) { - try { - new InfoItemDialog.Builder(getActivity(), getContext(), this, item).create().show(); - } catch (final IllegalArgumentException e) { - InfoItemDialog.Builder.reportErrorDuringInitialization(e, item); - } + openLongPressMenuInActivity( + requireActivity(), + LongPressable.fromStreamInfoItem(item), + LongPressAction.fromStreamInfoItem(item) + ); } /*////////////////////////////////////////////////////////////////////////// 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 269d85c7a5d..c3755ab654c 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,10 +3,9 @@ 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.ui.components.menu.LongPressMenuKt.openLongPressMenuInActivity; import static org.schabi.newpipe.util.ServiceHelper.getServiceById; -import android.content.Context; import android.os.Bundle; import android.text.TextUtils; import android.util.Log; @@ -43,8 +42,6 @@ import org.schabi.newpipe.extractor.stream.Description; import org.schabi.newpipe.extractor.stream.StreamInfoItem; import org.schabi.newpipe.fragments.list.BaseListInfoFragment; -import org.schabi.newpipe.info_list.dialog.InfoItemDialog; -import org.schabi.newpipe.info_list.dialog.StreamDialogDefaultEntry; import org.schabi.newpipe.local.dialog.PlaylistDialog; import org.schabi.newpipe.local.playlist.RemotePlaylistManager; import org.schabi.newpipe.player.playqueue.PlayQueue; @@ -153,35 +150,12 @@ private PlayQueue getPlayQueueStartingAt(final StreamInfoItem infoItem) { @Override protected void showInfoItemDialog(final StreamInfoItem item) { - activity.addContentView( - getLongPressMenuView( - requireContext(), - LongPressable.from(item), - LongPressAction.buildActionList(item, false) - ), - new ViewGroup.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.MATCH_PARENT - ) + openLongPressMenuInActivity( + activity, + LongPressable.fromStreamInfoItem(item), + // TODO handle play queue starting at + LongPressAction.fromStreamInfoItem(item) ); - if (Context.class.getSimpleName().startsWith("C")) { - return; - } - final Context context = getContext(); - try { - final InfoItemDialog.Builder dialogBuilder = - new InfoItemDialog.Builder(getActivity(), context, this, item); - - dialogBuilder - .setAction( - StreamDialogDefaultEntry.START_HERE_ON_BACKGROUND, - (f, infoItem) -> NavigationHelper.playOnBackgroundPlayer( - context, getPlayQueueStartingAt(infoItem), true)) - .create() - .show(); - } catch (final IllegalArgumentException e) { - InfoItemDialog.Builder.reportErrorDuringInitialization(e, item); - } } @Override diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/FeedFragment.kt b/app/src/main/java/org/schabi/newpipe/local/feed/FeedFragment.kt index 038f2bed19c..6b30d14a928 100644 --- a/app/src/main/java/org/schabi/newpipe/local/feed/FeedFragment.kt +++ b/app/src/main/java/org/schabi/newpipe/local/feed/FeedFragment.kt @@ -20,7 +20,6 @@ package org.schabi.newpipe.local.feed import android.annotation.SuppressLint -import android.app.Activity import android.content.Context import android.content.Intent import android.content.SharedPreferences @@ -63,17 +62,18 @@ import org.schabi.newpipe.error.ErrorUtil import org.schabi.newpipe.error.UserAction import org.schabi.newpipe.extractor.exceptions.AccountTerminatedException import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException -import org.schabi.newpipe.extractor.stream.StreamInfoItem import org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty import org.schabi.newpipe.fragments.BaseStateFragment import org.schabi.newpipe.info_list.ItemViewMode -import org.schabi.newpipe.info_list.dialog.InfoItemDialog import org.schabi.newpipe.ktx.animate import org.schabi.newpipe.ktx.animateHideRecyclerViewAllowingScrolling import org.schabi.newpipe.ktx.slideUp import org.schabi.newpipe.local.feed.item.StreamItem import org.schabi.newpipe.local.feed.service.FeedLoadService import org.schabi.newpipe.local.subscription.SubscriptionManager +import org.schabi.newpipe.ui.components.menu.LongPressAction +import org.schabi.newpipe.ui.components.menu.LongPressable +import org.schabi.newpipe.ui.components.menu.openLongPressMenuInActivity import org.schabi.newpipe.ui.emptystate.setEmptyStateComposable import org.schabi.newpipe.util.DeviceUtils import org.schabi.newpipe.util.Localization @@ -379,14 +379,6 @@ class FeedFragment : BaseStateFragment() { feedBinding.loadingProgressBar.max = progressState.maxProgress } - private fun showInfoItemDialog(item: StreamInfoItem) { - val context = context - val activity: Activity? = getActivity() - if (context == null || context.resources == null || activity == null) return - - InfoItemDialog.Builder(activity, context, this, item).create().show() - } - private val listenerStreamItem = object : OnItemClickListener, OnItemLongClickListener { override fun onItemClick(item: Item<*>, view: View) { if (item is StreamItem && !isRefreshing) { @@ -400,7 +392,11 @@ class FeedFragment : BaseStateFragment() { override fun onItemLongClick(item: Item<*>, view: View): Boolean { if (item is StreamItem && !isRefreshing) { - showInfoItemDialog(item.streamWithState.stream.toStreamInfoItem()) + openLongPressMenuInActivity( + requireActivity(), + LongPressable.fromStreamEntity(item.streamWithState.stream), + LongPressAction.fromStreamEntity(item.streamWithState.stream), + ) return true } return false diff --git a/app/src/main/java/org/schabi/newpipe/local/history/StatisticsPlaylistFragment.java b/app/src/main/java/org/schabi/newpipe/local/history/StatisticsPlaylistFragment.java index 3302e387ec5..de7865263ef 100644 --- a/app/src/main/java/org/schabi/newpipe/local/history/StatisticsPlaylistFragment.java +++ b/app/src/main/java/org/schabi/newpipe/local/history/StatisticsPlaylistFragment.java @@ -1,6 +1,7 @@ package org.schabi.newpipe.local.history; -import android.content.Context; +import static org.schabi.newpipe.ui.components.menu.LongPressMenuKt.openLongPressMenuInActivity; + import android.os.Bundle; import android.os.Parcelable; import android.view.LayoutInflater; @@ -9,14 +10,12 @@ import android.view.MenuItem; import android.view.View; import android.view.ViewGroup; -import android.widget.Toast; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.viewbinding.ViewBinding; import com.evernote.android.state.State; -import com.google.android.material.snackbar.Snackbar; import org.reactivestreams.Subscriber; import org.reactivestreams.Subscription; @@ -30,12 +29,12 @@ import org.schabi.newpipe.error.UserAction; import org.schabi.newpipe.extractor.stream.StreamInfoItem; import org.schabi.newpipe.fragments.list.playlist.PlaylistControlViewHolder; -import org.schabi.newpipe.info_list.dialog.InfoItemDialog; -import org.schabi.newpipe.info_list.dialog.StreamDialogDefaultEntry; import org.schabi.newpipe.local.BaseLocalListFragment; import org.schabi.newpipe.player.playqueue.PlayQueue; import org.schabi.newpipe.player.playqueue.SinglePlayQueue; import org.schabi.newpipe.settings.HistorySettingsFragment; +import org.schabi.newpipe.ui.components.menu.LongPressAction; +import org.schabi.newpipe.ui.components.menu.LongPressable; import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.OnClickGesture; import org.schabi.newpipe.util.PlayButtonHelper; @@ -48,7 +47,6 @@ import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; import io.reactivex.rxjava3.disposables.CompositeDisposable; -import io.reactivex.rxjava3.disposables.Disposable; public class StatisticsPlaylistFragment extends BaseLocalListFragment, Void> @@ -318,50 +316,11 @@ private PlayQueue getPlayQueueStartingAt(final StreamStatisticsEntry infoItem) { } private void showInfoItemDialog(final StreamStatisticsEntry item) { - final Context context = getContext(); - final StreamInfoItem infoItem = item.toStreamInfoItem(); - - try { - final InfoItemDialog.Builder dialogBuilder = - new InfoItemDialog.Builder(getActivity(), context, this, infoItem); - - // set entries in the middle; the others are added automatically - dialogBuilder - .addEntry(StreamDialogDefaultEntry.DELETE) - .setAction( - StreamDialogDefaultEntry.DELETE, - (f, i) -> deleteEntry( - Math.max(itemListAdapter.getItemsList().indexOf(item), 0))) - .create() - .show(); - } catch (final IllegalArgumentException e) { - InfoItemDialog.Builder.reportErrorDuringInitialization(e, infoItem); - } - } - - private void deleteEntry(final int index) { - final LocalItem infoItem = itemListAdapter.getItemsList().get(index); - if (infoItem instanceof StreamStatisticsEntry) { - final StreamStatisticsEntry entry = (StreamStatisticsEntry) infoItem; - final Disposable onDelete = recordManager - .deleteStreamHistoryAndState(entry.getStreamId()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe( - () -> { - if (getView() != null) { - Snackbar.make(getView(), R.string.one_item_deleted, - Snackbar.LENGTH_SHORT).show(); - } else { - Toast.makeText(getContext(), - R.string.one_item_deleted, - Toast.LENGTH_SHORT).show(); - } - }, - throwable -> showSnackBarError(new ErrorInfo(throwable, - UserAction.DELETE_FROM_HISTORY, "Deleting item"))); - - disposables.add(onDelete); - } + openLongPressMenuInActivity( + requireActivity(), + LongPressable.fromStreamEntity(item.getStreamEntity()), + LongPressAction.fromStreamStatisticsEntry(item) + ); } @Override diff --git a/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistFragment.java b/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistFragment.java index f5562549cf5..d36c819d070 100644 --- a/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistFragment.java +++ b/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistFragment.java @@ -6,6 +6,7 @@ import static org.schabi.newpipe.local.playlist.PlayListShareMode.JUST_URLS; import static org.schabi.newpipe.local.playlist.PlayListShareMode.WITH_TITLES; import static org.schabi.newpipe.local.playlist.PlayListShareMode.YOUTUBE_TEMP_PLAYLIST; +import static org.schabi.newpipe.ui.components.menu.LongPressMenuKt.openLongPressMenuInActivity; import static org.schabi.newpipe.util.ThemeHelper.shouldUseGridLayout; @@ -49,12 +50,12 @@ import org.schabi.newpipe.extractor.stream.StreamInfoItem; import org.schabi.newpipe.fragments.MainFragment; import org.schabi.newpipe.fragments.list.playlist.PlaylistControlViewHolder; -import org.schabi.newpipe.info_list.dialog.InfoItemDialog; -import org.schabi.newpipe.info_list.dialog.StreamDialogDefaultEntry; import org.schabi.newpipe.local.BaseLocalListFragment; 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.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.OnClickGesture; @@ -797,39 +798,16 @@ private PlayQueue getPlayQueueStartingAt(final PlaylistStreamEntry infoItem) { } protected void showInfoItemDialog(final PlaylistStreamEntry item) { - final StreamInfoItem infoItem = item.toStreamInfoItem(); - - try { - final Context context = getContext(); - final InfoItemDialog.Builder dialogBuilder = - new InfoItemDialog.Builder(getActivity(), context, this, infoItem); - - // add entries in the middle - dialogBuilder.addAllEntries( - StreamDialogDefaultEntry.SET_AS_PLAYLIST_THUMBNAIL, - StreamDialogDefaultEntry.DELETE - ); - - // set custom actions - // all entries modified below have already been added within the builder - dialogBuilder - .setAction( - StreamDialogDefaultEntry.START_HERE_ON_BACKGROUND, - (f, i) -> NavigationHelper.playOnBackgroundPlayer( - context, getPlayQueueStartingAt(item), true)) - .setAction( - StreamDialogDefaultEntry.SET_AS_PLAYLIST_THUMBNAIL, - (f, i) -> - changeThumbnailStreamId(item.getStreamEntity().getUid(), - true)) - .setAction( - StreamDialogDefaultEntry.DELETE, - (f, i) -> deleteItem(item)) - .create() - .show(); - } catch (final IllegalArgumentException e) { - InfoItemDialog.Builder.reportErrorDuringInitialization(e, infoItem); - } + openLongPressMenuInActivity( + requireActivity(), + LongPressable.fromStreamEntity(item.getStreamEntity()), + // TODO getPlayQueueStartingAt(), resumePlayback=true + LongPressAction.fromPlaylistStreamEntry( + item, + () -> deleteItem(item), + () -> changeThumbnailStreamId(item.getStreamEntity().getUid(), true) + ) + ); } private void setInitialData(final long pid, final String title) { diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/items/ItemList.kt b/app/src/main/java/org/schabi/newpipe/ui/components/items/ItemList.kt index 4562e17aff7..b2c9bf95a0d 100644 --- a/app/src/main/java/org/schabi/newpipe/ui/components/items/ItemList.kt +++ b/app/src/main/java/org/schabi/newpipe/ui/components/items/ItemList.kt @@ -5,10 +5,7 @@ import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalContext @@ -50,20 +47,6 @@ fun ItemList( } } - // Handle long clicks for stream items - // TODO: Adjust the menu display depending on where it was triggered - var selectedStream by remember { mutableStateOf(null) } - val onLongClick = remember { - { stream: StreamInfoItem -> - selectedStream = stream - } - } - val onDismissPopup = remember { - { - selectedStream = null - } - } - val showProgress = DependentPreferenceHelper.getPositionsInListsEnabled(context) val nestedScrollModifier = Modifier.nestedScroll(rememberNestedScrollInteropConnection()) @@ -80,10 +63,7 @@ fun ItemList( val item = items[it] if (item is StreamInfoItem) { - val isSelected = selectedStream == item - StreamListItem( - item, showProgress, isSelected, onClick, onLongClick, onDismissPopup - ) + StreamListItem(item, showProgress, onClick) } else if (item is PlaylistInfoItem) { PlaylistListItem(item, onClick) } diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/items/stream/StreamListItem.kt b/app/src/main/java/org/schabi/newpipe/ui/components/items/stream/StreamListItem.kt index 84fff3e74cf..f4fbfb716db 100644 --- a/app/src/main/java/org/schabi/newpipe/ui/components/items/stream/StreamListItem.kt +++ b/app/src/main/java/org/schabi/newpipe/ui/components/items/stream/StreamListItem.kt @@ -10,10 +10,15 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextOverflow @@ -21,22 +26,26 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import org.schabi.newpipe.extractor.stream.StreamInfoItem +import org.schabi.newpipe.ui.components.menu.LongPressAction +import org.schabi.newpipe.ui.components.menu.LongPressMenu +import org.schabi.newpipe.ui.components.menu.LongPressable import org.schabi.newpipe.ui.theme.AppTheme -@OptIn(ExperimentalFoundationApi::class) +@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) @Composable fun StreamListItem( stream: StreamInfoItem, showProgress: Boolean, - isSelected: Boolean, onClick: (StreamInfoItem) -> Unit = {}, - onLongClick: (StreamInfoItem) -> Unit = {}, - onDismissPopup: () -> Unit = {} ) { - // Box serves as an anchor for the dropdown menu + var showLongPressMenu by rememberSaveable { mutableStateOf(false) } + Box( modifier = Modifier - .combinedClickable(onLongClick = { onLongClick(stream) }, onClick = { onClick(stream) }) + .combinedClickable( + onLongClick = { showLongPressMenu = true }, + onClick = { onClick(stream) } + ) .fillMaxWidth() .padding(12.dp) ) { @@ -67,7 +76,13 @@ fun StreamListItem( } } - StreamMenu(stream, isSelected, onDismissPopup) + if (showLongPressMenu) { + LongPressMenu( + longPressable = LongPressable.fromStreamInfoItem(stream), + longPressActions = LongPressAction.fromStreamInfoItem(stream), + onDismissRequest = { showLongPressMenu = false }, + ) + } } } @@ -79,7 +94,7 @@ private fun StreamListItemPreview( ) { AppTheme { Surface { - StreamListItem(stream, showProgress = false, isSelected = false) + StreamListItem(stream, showProgress = false) } } } diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/items/stream/StreamMenu.kt b/app/src/main/java/org/schabi/newpipe/ui/components/items/stream/StreamMenu.kt deleted file mode 100644 index 7619515e71d..00000000000 --- a/app/src/main/java/org/schabi/newpipe/ui/components/items/stream/StreamMenu.kt +++ /dev/null @@ -1,137 +0,0 @@ -package org.schabi.newpipe.ui.components.items.stream - -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.lifecycle.viewmodel.compose.viewModel -import org.schabi.newpipe.R -import org.schabi.newpipe.database.stream.model.StreamEntity -import org.schabi.newpipe.download.DownloadDialog -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.player.helper.PlayerHolder -import org.schabi.newpipe.util.NavigationHelper -import org.schabi.newpipe.util.SparseItemUtil -import org.schabi.newpipe.util.external_communication.ShareUtils -import org.schabi.newpipe.viewmodels.StreamViewModel - -@Composable -fun StreamMenu( - stream: StreamInfoItem, - expanded: Boolean, - onDismissRequest: () -> Unit -) { - val context = LocalContext.current - val streamViewModel = viewModel() - - DropdownMenu(expanded = expanded, onDismissRequest = onDismissRequest) { - if (PlayerHolder.isPlayQueueReady) { - DropdownMenuItem( - text = { Text(text = stringResource(R.string.enqueue_stream)) }, - onClick = { - onDismissRequest() - SparseItemUtil.fetchItemInfoIfSparse(context, stream) { - NavigationHelper.enqueueOnPlayer(context, it) - } - } - ) - - if (PlayerHolder.queuePosition < PlayerHolder.queueSize - 1) { - DropdownMenuItem( - text = { Text(text = stringResource(R.string.enqueue_next_stream)) }, - onClick = { - onDismissRequest() - SparseItemUtil.fetchItemInfoIfSparse(context, stream) { - NavigationHelper.enqueueNextOnPlayer(context, it) - } - } - ) - } - } - - DropdownMenuItem( - text = { Text(text = stringResource(R.string.start_here_on_background)) }, - onClick = { - onDismissRequest() - SparseItemUtil.fetchItemInfoIfSparse(context, stream) { - NavigationHelper.playOnBackgroundPlayer(context, it, true) - } - } - ) - DropdownMenuItem( - text = { Text(text = stringResource(R.string.start_here_on_popup)) }, - onClick = { - onDismissRequest() - SparseItemUtil.fetchItemInfoIfSparse(context, stream) { - NavigationHelper.playOnPopupPlayer(context, it, true) - } - } - ) - DropdownMenuItem( - text = { Text(text = stringResource(R.string.download)) }, - onClick = { - onDismissRequest() - SparseItemUtil.fetchStreamInfoAndSaveToDatabase( - context, stream.serviceId, stream.url - ) { info -> - // TODO: Use an AlertDialog composable instead. - val downloadDialog = DownloadDialog(context, info) - val fragmentManager = context.findFragmentActivity().supportFragmentManager - downloadDialog.show(fragmentManager, "downloadDialog") - } - } - ) - DropdownMenuItem( - text = { Text(text = stringResource(R.string.add_to_playlist)) }, - onClick = { - onDismissRequest() - val list = listOf(StreamEntity(stream)) - PlaylistDialog.createCorrespondingDialog(context, list) { dialog -> - val tag = if (dialog is PlaylistAppendDialog) "append" else "create" - dialog.show( - context.findFragmentActivity().supportFragmentManager, - "StreamDialogEntry@${tag}_playlist" - ) - } - } - ) - DropdownMenuItem( - text = { Text(text = stringResource(R.string.share)) }, - onClick = { - onDismissRequest() - ShareUtils.shareText(context, stream.name, stream.url, stream.thumbnails) - } - ) - DropdownMenuItem( - text = { Text(text = stringResource(R.string.open_in_browser)) }, - onClick = { - onDismissRequest() - ShareUtils.openUrlInBrowser(context, stream.url) - } - ) - DropdownMenuItem( - text = { Text(text = stringResource(R.string.mark_as_watched)) }, - onClick = { - onDismissRequest() - streamViewModel.markAsWatched(stream) - } - ) - DropdownMenuItem( - text = { Text(text = stringResource(R.string.show_channel_details)) }, - onClick = { - onDismissRequest() - SparseItemUtil.fetchUploaderUrlIfSparse( - context, stream.serviceId, stream.url, stream.uploaderUrl - ) { url -> - val activity = context.findFragmentActivity() - NavigationHelper.openChannelFragment(activity, stream, url) - } - } - ) - } -} 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 ffaafda4dcc..7d5ece29289 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,7 +1,7 @@ package org.schabi.newpipe.ui.components.menu import android.content.Context -import android.net.Uri +import android.widget.Toast import androidx.annotation.StringRes import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.PlaylistAdd @@ -12,6 +12,7 @@ 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.Panorama import androidx.compose.material.icons.filled.Person import androidx.compose.material.icons.filled.PictureInPicture import androidx.compose.material.icons.filled.PlayArrow @@ -20,6 +21,8 @@ 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.playlist.PlaylistStreamEntry +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.extractor.InfoItem @@ -32,7 +35,6 @@ 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( @@ -57,6 +59,7 @@ data class LongPressAction( 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), + SetAsPlaylistThumbnail(R.string.set_as_playlist_thumbnail, Icons.Default.Panorama), ; // TODO allow actions to return disposables @@ -101,9 +104,9 @@ data class LongPressAction( } @JvmStatic - fun buildActionList( + fun fromStreamInfoItem( item: StreamInfoItem, - isKodiEnabled: Boolean, + /* TODO isKodiEnabled: Boolean, */ /* TODO wholeListQueue: (() -> PlayQueue)? */ ): List { return buildPlayerActionList { SinglePlayQueue(item) } + @@ -150,11 +153,57 @@ data class LongPressAction( .observeOn(AndroidSchedulers.mainThread()) .subscribe() }, - ) + if (isKodiEnabled) listOf( + ) + /* TODO handle kodi + + if (isKodiEnabled) listOf( Type.PlayWithKodi.buildAction { context -> KoreUtils.playWithKore(context, Uri.parse(item.url)) }, - ) else listOf() + ) else listOf()*/ + } + + @JvmStatic + fun fromStreamEntity( + item: StreamEntity, + ): 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) + return fromStreamInfoItem(item.toStreamInfoItem()) + } + + @JvmStatic + fun fromStreamStatisticsEntry( + item: StreamStatisticsEntry, + ): List { + return fromStreamEntity(item.streamEntity) + + 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() + } + } + ) + } + + @JvmStatic + fun fromPlaylistStreamEntry( + item: PlaylistStreamEntry, + onDelete: Runnable, + onSetAsPlaylistThumbnail: Runnable, + ): List { + return fromStreamEntity(item.streamEntity) + + listOf( + Type.Delete.buildAction { onDelete.run() }, + Type.SetAsPlaylistThumbnail.buildAction { onSetAsPlaylistThumbnail.run() } + ) } } } 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 66c7a262959..c57e271eaa6 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 @@ -2,9 +2,11 @@ package org.schabi.newpipe.ui.components.menu +import android.app.Activity import android.content.Context import android.content.res.Configuration import android.view.ViewGroup +import android.view.ViewGroup.LayoutParams import androidx.compose.foundation.basicMarquee import androidx.compose.foundation.clickable import androidx.compose.foundation.isSystemInDarkTheme @@ -73,6 +75,17 @@ import org.schabi.newpipe.util.Either import org.schabi.newpipe.util.Localization import java.time.OffsetDateTime +fun openLongPressMenuInActivity( + activity: Activity, + longPressable: LongPressable, + longPressActions: List, +) { + activity.addContentView( + getLongPressMenuView(activity, longPressable, longPressActions), + LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) + ) +} + fun getLongPressMenuView( context: Context, longPressable: LongPressable, @@ -83,9 +96,8 @@ fun getLongPressMenuView( AppTheme { LongPressMenu( longPressable = longPressable, - onDismissRequest = { (this.parent as ViewGroup).removeView(this) }, longPressActions = longPressActions, - onEditActions = {}, + onDismissRequest = { (this.parent as ViewGroup).removeView(this) }, ) } } @@ -95,9 +107,9 @@ fun getLongPressMenuView( @Composable fun LongPressMenu( longPressable: LongPressable, - onDismissRequest: () -> Unit, longPressActions: List, - onEditActions: () -> Unit, + onDismissRequest: () -> Unit, + onEditActions: () -> Unit = {}, // TODO handle this menu sheetState: SheetState = rememberModalBottomSheetState(), ) { ModalBottomSheet( 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 ada15e6cdd5..06e5146fd35 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 @@ -1,8 +1,11 @@ package org.schabi.newpipe.ui.components.menu import androidx.compose.runtime.Stable +import org.schabi.newpipe.database.stream.model.StreamEntity 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.util.Either import org.schabi.newpipe.util.image.ImageStrategy import java.time.OffsetDateTime @@ -22,11 +25,20 @@ data class LongPressable( data class Duration(val duration: Long) : Decoration data object Live : Decoration data class Playlist(val itemCount: Long) : Decoration + + companion object { + internal fun from(streamType: StreamType, duration: Long) = + if (streamType == LIVE_STREAM || streamType == AUDIO_LIVE_STREAM) { + Live + } else { + duration.takeIf { it >= 0 }?.let { Duration(it) } + } + } } companion object { @JvmStatic - fun from(item: StreamInfoItem) = LongPressable( + fun fromStreamInfoItem(item: StreamInfoItem) = LongPressable( title = item.name, url = item.url?.takeIf { it.isNotBlank() }, thumbnailUrl = ImageStrategy.choosePreferredImage(item.thumbnails), @@ -35,15 +47,20 @@ data class LongPressable( viewCount = item.viewCount.takeIf { it >= 0 }, uploadDate = item.uploadDate?.let { Either.right(it.offsetDateTime()) } ?: item.textualUploadDate?.let { Either.left(it) }, - decoration = if (item.streamType == StreamType.LIVE_STREAM || - item.streamType == StreamType.AUDIO_LIVE_STREAM - ) { - LongPressable.Decoration.Live - } else { - item.duration.takeIf { it >= 0 }?.let { - LongPressable.Decoration.Duration(it) - } - }, + decoration = Decoration.from(item.streamType, item.duration), + ) + + @JvmStatic + fun fromStreamEntity(item: StreamEntity) = LongPressable( + title = item.title, + url = item.url.takeIf { it.isNotBlank() }, + thumbnailUrl = item.thumbnailUrl, + uploader = item.uploader.takeIf { it.isNotBlank() }, + uploaderUrl = item.uploaderUrl?.takeIf { it.isNotBlank() }, + viewCount = item.viewCount?.takeIf { it >= 0 }, + uploadDate = item.uploadDate?.let { Either.right(it) } + ?: item.textualUploadDate?.let { Either.left(it) }, + decoration = Decoration.from(item.streamType, item.duration), ) } } From 5278ed822566114a462ba4b2141b0bedbb3a1ae3 Mon Sep 17 00:00:00 2001 From: Stypox Date: Wed, 12 Feb 2025 01:06:27 +0100 Subject: [PATCH 17/41] Remove InfoItemDialog --- .../info_list/dialog/InfoItemDialog.java | 356 ------------------ .../dialog/StreamDialogDefaultEntry.java | 158 -------- .../info_list/dialog/StreamDialogEntry.java | 31 -- 3 files changed, 545 deletions(-) delete mode 100644 app/src/main/java/org/schabi/newpipe/info_list/dialog/InfoItemDialog.java delete mode 100644 app/src/main/java/org/schabi/newpipe/info_list/dialog/StreamDialogDefaultEntry.java delete mode 100644 app/src/main/java/org/schabi/newpipe/info_list/dialog/StreamDialogEntry.java diff --git a/app/src/main/java/org/schabi/newpipe/info_list/dialog/InfoItemDialog.java b/app/src/main/java/org/schabi/newpipe/info_list/dialog/InfoItemDialog.java deleted file mode 100644 index cbaae2834b8..00000000000 --- a/app/src/main/java/org/schabi/newpipe/info_list/dialog/InfoItemDialog.java +++ /dev/null @@ -1,356 +0,0 @@ -package org.schabi.newpipe.info_list.dialog; - -import static org.schabi.newpipe.MainActivity.DEBUG; - -import android.app.Activity; -import android.content.Context; -import android.content.DialogInterface; -import android.os.Build; -import android.util.Log; -import android.view.View; -import android.widget.TextView; - -import androidx.annotation.NonNull; -import androidx.appcompat.app.AlertDialog; -import androidx.fragment.app.Fragment; -import androidx.preference.PreferenceManager; - -import org.schabi.newpipe.App; -import org.schabi.newpipe.R; -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.extractor.stream.StreamType; -import org.schabi.newpipe.player.helper.PlayerHolder; -import org.schabi.newpipe.util.StreamTypeUtil; -import org.schabi.newpipe.util.external_communication.KoreUtils; - -import java.util.ArrayList; -import java.util.List; -import java.util.stream.Stream; - -/** - * Dialog for a {@link StreamInfoItem}. - * The dialog's content are actions that can be performed on the {@link StreamInfoItem}. - * This dialog is mostly used for longpress context menus. - */ -public final class InfoItemDialog { - private static final String TAG = Build.class.getSimpleName(); - /** - * Ideally, {@link InfoItemDialog} would extend {@link AlertDialog}. - * However, extending {@link AlertDialog} requires many additional lines - * and brings more complexity to this class, especially the constructor. - * To circumvent this, an {@link AlertDialog.Builder} is used in the constructor. - * Its result is stored in this class variable to allow access via the {@link #show()} method. - */ - private final AlertDialog dialog; - - private InfoItemDialog(@NonNull final Activity activity, - @NonNull final Fragment fragment, - @NonNull final StreamInfoItem info, - @NonNull final List entries) { - - // Create the dialog's title - final View bannerView = View.inflate(activity, R.layout.dialog_title, null); - bannerView.setSelected(true); - - final TextView titleView = bannerView.findViewById(R.id.itemTitleView); - titleView.setText(info.getName()); - - final TextView detailsView = bannerView.findViewById(R.id.itemAdditionalDetails); - if (info.getUploaderName() != null) { - detailsView.setText(info.getUploaderName()); - detailsView.setVisibility(View.VISIBLE); - } else { - detailsView.setVisibility(View.GONE); - } - - // Get the entry's descriptions which are displayed in the dialog - final String[] items = entries.stream() - .map(entry -> entry.getString(activity)).toArray(String[]::new); - - // Call an entry's action / onClick method when the entry is selected. - final DialogInterface.OnClickListener action = (d, index) -> - entries.get(index).action.onClick(fragment, info); - - dialog = new AlertDialog.Builder(activity) - .setCustomTitle(bannerView) - .setItems(items, action) - .create(); - - } - - public void show() { - dialog.show(); - } - - /** - *

Builder to generate a {@link InfoItemDialog} for a {@link StreamInfoItem}.

- * Use {@link #addEntry(StreamDialogDefaultEntry)} - * and {@link #addAllEntries(StreamDialogDefaultEntry...)} to add options to the dialog. - *
- * Custom actions for entries can be set using - * {@link #setAction(StreamDialogDefaultEntry, StreamDialogEntry.StreamDialogEntryAction)}. - */ - public static class Builder { - @NonNull private final Activity activity; - @NonNull private final Context context; - @NonNull private final StreamInfoItem infoItem; - @NonNull private final Fragment fragment; - @NonNull private final List entries = new ArrayList<>(); - private final boolean addDefaultEntriesAutomatically; - - /** - *

Create a {@link Builder builder} instance for a {@link StreamInfoItem} - * that automatically adds the some default entries - * at the top and bottom of the dialog.

- * The dialog has the following structure: - *
-         *     + - - - - - - - - - - - - - - - - - - - - - -+
-         *     | ENQUEUE                                    |
-         *     | ENQUEUE_NEXT                               |
-         *     | START_ON_BACKGROUND                        |
-         *     | START_ON_POPUP                             |
-         *     + - - - - - - - - - - - - - - - - - - - - - -+
-         *     | entries added manually with                |
-         *     | addEntry() and addAllEntries()             |
-         *     + - - - - - - - - - - - - - - - - - - - - - -+
-         *     | APPEND_PLAYLIST                            |
-         *     | SHARE                                      |
-         *     | OPEN_IN_BROWSER                            |
-         *     | PLAY_WITH_KODI                             |
-         *     | MARK_AS_WATCHED                            |
-         *     | SHOW_CHANNEL_DETAILS                       |
-         *     + - - - - - - - - - - - - - - - - - - - - - -+
-         * 
- * Please note that some entries are not added depending on the user's preferences, - * the item's {@link StreamType} and the current player state. - * - * @param activity - * @param context - * @param fragment - * @param infoItem the item for this dialog; all entries and their actions work with - * this {@link StreamInfoItem} - * @throws IllegalArgumentException if activity, context - * or resources is null - */ - public Builder(final Activity activity, - final Context context, - @NonNull final Fragment fragment, - @NonNull final StreamInfoItem infoItem) { - this(activity, context, fragment, infoItem, true); - } - - /** - *

Create an instance of this {@link Builder} for a {@link StreamInfoItem}.

- *

If {@code addDefaultEntriesAutomatically} is set to {@code true}, - * some default entries are added to the top and bottom of the dialog.

- * The dialog has the following structure: - *
-         *     + - - - - - - - - - - - - - - - - - - - - - -+
-         *     | ENQUEUE                                    |
-         *     | ENQUEUE_NEXT                               |
-         *     | START_ON_BACKGROUND                        |
-         *     | START_ON_POPUP                             |
-         *     + - - - - - - - - - - - - - - - - - - - - - -+
-         *     | entries added manually with                |
-         *     | addEntry() and addAllEntries()             |
-         *     + - - - - - - - - - - - - - - - - - - - - - -+
-         *     | APPEND_PLAYLIST                            |
-         *     | SHARE                                      |
-         *     | OPEN_IN_BROWSER                            |
-         *     | PLAY_WITH_KODI                             |
-         *     | MARK_AS_WATCHED                            |
-         *     | SHOW_CHANNEL_DETAILS                       |
-         *     + - - - - - - - - - - - - - - - - - - - - - -+
-         * 
- * Please note that some entries are not added depending on the user's preferences, - * the item's {@link StreamType} and the current player state. - * - * @param activity - * @param context - * @param fragment - * @param infoItem - * @param addDefaultEntriesAutomatically - * whether default entries added with {@link #addDefaultBeginningEntries()} - * and {@link #addDefaultEndEntries()} are added automatically when generating - * the {@link InfoItemDialog}. - *
- * Entries added with {@link #addEntry(StreamDialogDefaultEntry)} and - * {@link #addAllEntries(StreamDialogDefaultEntry...)} are added in between. - * @throws IllegalArgumentException if activity, context - * or resources is null - */ - public Builder(final Activity activity, - final Context context, - @NonNull final Fragment fragment, - @NonNull final StreamInfoItem infoItem, - final boolean addDefaultEntriesAutomatically) { - if (activity == null || context == null || context.getResources() == null) { - if (DEBUG) { - Log.d(TAG, "activity, context or resources is null: activity = " - + activity + ", context = " + context); - } - throw new IllegalArgumentException("activity, context or resources is null"); - } - this.activity = activity; - this.context = context; - this.fragment = fragment; - this.infoItem = infoItem; - this.addDefaultEntriesAutomatically = addDefaultEntriesAutomatically; - if (addDefaultEntriesAutomatically) { - addDefaultBeginningEntries(); - } - } - - /** - * Adds a new entry and appends it to the current entry list. - * @param entry the entry to add - * @return the current {@link Builder} instance - */ - public Builder addEntry(@NonNull final StreamDialogDefaultEntry entry) { - entries.add(entry.toStreamDialogEntry()); - return this; - } - - /** - * Adds new entries. These are appended to the current entry list. - * @param newEntries the entries to add - * @return the current {@link Builder} instance - */ - public Builder addAllEntries(@NonNull final StreamDialogDefaultEntry... newEntries) { - Stream.of(newEntries).forEach(this::addEntry); - return this; - } - - /** - *

Change an entries' action that is called when the entry is selected.

- *

Warning: Only use this method when the entry has been already added. - * Changing the action of an entry which has not been added to the Builder yet - * does not have an effect.

- * @param entry the entry to change - * @param action the action to perform when the entry is selected - * @return the current {@link Builder} instance - */ - public Builder setAction(@NonNull final StreamDialogDefaultEntry entry, - @NonNull final StreamDialogEntry.StreamDialogEntryAction action) { - for (int i = 0; i < entries.size(); i++) { - if (entries.get(i).resource == entry.resource) { - entries.set(i, new StreamDialogEntry(entry.resource, action)); - return this; - } - } - return this; - } - - /** - * Adds {@link StreamDialogDefaultEntry#ENQUEUE} if the player is open and - * {@link StreamDialogDefaultEntry#ENQUEUE_NEXT} if there are multiple streams - * in the play queue. - * @return the current {@link Builder} instance - */ - public Builder addEnqueueEntriesIfNeeded() { - final PlayerHolder holder = PlayerHolder.INSTANCE; - if (holder.isPlayQueueReady()) { - addEntry(StreamDialogDefaultEntry.ENQUEUE); - - if (holder.getQueuePosition() < holder.getQueueSize() - 1) { - addEntry(StreamDialogDefaultEntry.ENQUEUE_NEXT); - } - } - return this; - } - - /** - * Adds the {@link StreamDialogDefaultEntry#START_HERE_ON_BACKGROUND}. - * If the {@link #infoItem} is not a pure audio (live) stream, - * {@link StreamDialogDefaultEntry#START_HERE_ON_POPUP} is added, too. - * @return the current {@link Builder} instance - */ - public Builder addStartHereEntries() { - addEntry(StreamDialogDefaultEntry.START_HERE_ON_BACKGROUND); - if (!StreamTypeUtil.isAudio(infoItem.getStreamType())) { - addEntry(StreamDialogDefaultEntry.START_HERE_ON_POPUP); - } - return this; - } - - /** - * Adds {@link StreamDialogDefaultEntry#MARK_AS_WATCHED} if the watch history is enabled - * and the stream is not a livestream. - * @return the current {@link Builder} instance - */ - public Builder addMarkAsWatchedEntryIfNeeded() { - final boolean isWatchHistoryEnabled = PreferenceManager - .getDefaultSharedPreferences(context) - .getBoolean(context.getString(R.string.enable_watch_history_key), false); - if (isWatchHistoryEnabled && !StreamTypeUtil.isLiveStream(infoItem.getStreamType())) { - addEntry(StreamDialogDefaultEntry.MARK_AS_WATCHED); - } - return this; - } - - /** - * Adds the {@link StreamDialogDefaultEntry#PLAY_WITH_KODI} entry if it is needed. - * @return the current {@link Builder} instance - */ - public Builder addPlayWithKodiEntryIfNeeded() { - if (KoreUtils.shouldShowPlayWithKodi(context, infoItem.getServiceId())) { - addEntry(StreamDialogDefaultEntry.PLAY_WITH_KODI); - } - return this; - } - - /** - * Add the entries which are usually at the top of the action list. - *
- * This method adds the "enqueue" (see {@link #addEnqueueEntriesIfNeeded()}) - * and "start here" (see {@link #addStartHereEntries()} entries. - * @return the current {@link Builder} instance - */ - public Builder addDefaultBeginningEntries() { - addEnqueueEntriesIfNeeded(); - addStartHereEntries(); - return this; - } - - /** - * Add the entries which are usually at the bottom of the action list. - * @return the current {@link Builder} instance - */ - public Builder addDefaultEndEntries() { - addAllEntries( - StreamDialogDefaultEntry.DOWNLOAD, - StreamDialogDefaultEntry.APPEND_PLAYLIST, - StreamDialogDefaultEntry.SHARE, - StreamDialogDefaultEntry.OPEN_IN_BROWSER - ); - addPlayWithKodiEntryIfNeeded(); - addMarkAsWatchedEntryIfNeeded(); - addEntry(StreamDialogDefaultEntry.SHOW_CHANNEL_DETAILS); - return this; - } - - /** - * Creates the {@link InfoItemDialog}. - * @return a new instance of {@link InfoItemDialog} - */ - public InfoItemDialog create() { - if (addDefaultEntriesAutomatically) { - addDefaultEndEntries(); - } - return new InfoItemDialog(this.activity, this.fragment, this.infoItem, this.entries); - } - - public static void reportErrorDuringInitialization(final Throwable throwable, - final InfoItem item) { - ErrorUtil.showSnackbar(App.getInstance().getBaseContext(), new ErrorInfo( - throwable, - UserAction.OPEN_INFO_ITEM_DIALOG, - "none", - item.getServiceId())); - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/info_list/dialog/StreamDialogDefaultEntry.java b/app/src/main/java/org/schabi/newpipe/info_list/dialog/StreamDialogDefaultEntry.java deleted file mode 100644 index ff3b2478e6e..00000000000 --- a/app/src/main/java/org/schabi/newpipe/info_list/dialog/StreamDialogDefaultEntry.java +++ /dev/null @@ -1,158 +0,0 @@ -package org.schabi.newpipe.info_list.dialog; - -import static org.schabi.newpipe.util.NavigationHelper.openChannelFragment; -import static org.schabi.newpipe.util.SparseItemUtil.fetchItemInfoIfSparse; -import static org.schabi.newpipe.util.SparseItemUtil.fetchStreamInfoAndSaveToDatabase; -import static org.schabi.newpipe.util.SparseItemUtil.fetchUploaderUrlIfSparse; - -import android.net.Uri; - -import androidx.annotation.NonNull; -import androidx.annotation.StringRes; - -import org.schabi.newpipe.R; -import org.schabi.newpipe.database.stream.model.StreamEntity; -import org.schabi.newpipe.download.DownloadDialog; -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.util.NavigationHelper; -import org.schabi.newpipe.util.external_communication.KoreUtils; -import org.schabi.newpipe.util.external_communication.ShareUtils; - -import java.util.List; - -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; - -/** - *

- * This enum provides entries that are accepted - * by the {@link InfoItemDialog.Builder}. - *

- *

- * These entries contain a String {@link #resource} which is displayed in the dialog and - * a default {@link #action} that is executed - * when the entry is selected (via onClick()). - *
- * They action can be overridden by using the Builder's - * {@link InfoItemDialog.Builder#setAction( - * StreamDialogDefaultEntry, StreamDialogEntry.StreamDialogEntryAction)} - * method. - *

- */ -public enum StreamDialogDefaultEntry { - SHOW_CHANNEL_DETAILS(R.string.show_channel_details, (fragment, item) -> { - final var activity = fragment.requireActivity(); - fetchUploaderUrlIfSparse(activity, item.getServiceId(), item.getUrl(), - item.getUploaderUrl(), url -> openChannelFragment(activity, item, url)); - }), - - /** - * Enqueues the stream automatically to the current PlayerType. - */ - ENQUEUE(R.string.enqueue_stream, (fragment, item) -> - fetchItemInfoIfSparse(fragment.requireContext(), item, singlePlayQueue -> - NavigationHelper.enqueueOnPlayer(fragment.getContext(), singlePlayQueue)) - ), - - /** - * Enqueues the stream automatically to the current PlayerType - * after the currently playing stream. - */ - ENQUEUE_NEXT(R.string.enqueue_next_stream, (fragment, item) -> - fetchItemInfoIfSparse(fragment.requireContext(), item, singlePlayQueue -> - NavigationHelper.enqueueNextOnPlayer(fragment.getContext(), singlePlayQueue)) - ), - - START_HERE_ON_BACKGROUND(R.string.start_here_on_background, (fragment, item) -> - fetchItemInfoIfSparse(fragment.requireContext(), item, singlePlayQueue -> - NavigationHelper.playOnBackgroundPlayer( - fragment.getContext(), singlePlayQueue, true))), - - START_HERE_ON_POPUP(R.string.start_here_on_popup, (fragment, item) -> - fetchItemInfoIfSparse(fragment.requireContext(), item, singlePlayQueue -> - NavigationHelper.playOnPopupPlayer(fragment.getContext(), singlePlayQueue, true))), - - SET_AS_PLAYLIST_THUMBNAIL(R.string.set_as_playlist_thumbnail, (fragment, item) -> { - throw new UnsupportedOperationException("This needs to be implemented manually " - + "by using InfoItemDialog.Builder.setAction()"); - }), - - DELETE(R.string.delete, (fragment, item) -> { - throw new UnsupportedOperationException("This needs to be implemented manually " - + "by using InfoItemDialog.Builder.setAction()"); - }), - - /** - * Opens a {@link PlaylistDialog} to either append the stream to a playlist - * or create a new playlist if there are no local playlists. - */ - APPEND_PLAYLIST(R.string.add_to_playlist, (fragment, item) -> - PlaylistDialog.createCorrespondingDialog( - fragment.getContext(), - List.of(new StreamEntity(item)), - dialog -> dialog.show( - fragment.getParentFragmentManager(), - "StreamDialogEntry@" - + (dialog instanceof PlaylistAppendDialog ? "append" : "create") - + "_playlist" - ) - ) - ), - - PLAY_WITH_KODI(R.string.play_with_kodi_title, (fragment, item) -> - KoreUtils.playWithKore(fragment.requireContext(), Uri.parse(item.getUrl()))), - - SHARE(R.string.share, (fragment, item) -> - ShareUtils.shareText(fragment.requireContext(), item.getName(), item.getUrl(), - item.getThumbnails())), - - /** - * Opens a {@link DownloadDialog} after fetching some stream info. - * If the user quits the current fragment, it will not open a DownloadDialog. - */ - DOWNLOAD(R.string.download, (fragment, item) -> - fetchStreamInfoAndSaveToDatabase(fragment.requireContext(), item.getServiceId(), - item.getUrl(), info -> { - // Ensure the fragment is attached and its state hasn't been saved to avoid - // showing dialog during lifecycle changes or when the activity is paused, - // e.g. by selecting the download option and opening a different fragment. - if (fragment.isAdded() && !fragment.isStateSaved()) { - final DownloadDialog downloadDialog = - new DownloadDialog(fragment.requireContext(), info); - downloadDialog.show(fragment.getChildFragmentManager(), - "downloadDialog"); - } - }) - ), - - OPEN_IN_BROWSER(R.string.open_in_browser, (fragment, item) -> - ShareUtils.openUrlInBrowser(fragment.requireContext(), item.getUrl())), - - - MARK_AS_WATCHED(R.string.mark_as_watched, (fragment, item) -> - new HistoryRecordManager(fragment.getContext()) - .markAsWatched(item) - .onErrorComplete() - .observeOn(AndroidSchedulers.mainThread()) - .subscribe() - ); - - - @StringRes - public final int resource; - @NonNull - public final StreamDialogEntry.StreamDialogEntryAction action; - - StreamDialogDefaultEntry(@StringRes final int resource, - @NonNull final StreamDialogEntry.StreamDialogEntryAction action) { - this.resource = resource; - this.action = action; - } - - @NonNull - public StreamDialogEntry toStreamDialogEntry() { - return new StreamDialogEntry(resource, action); - } - -} diff --git a/app/src/main/java/org/schabi/newpipe/info_list/dialog/StreamDialogEntry.java b/app/src/main/java/org/schabi/newpipe/info_list/dialog/StreamDialogEntry.java deleted file mode 100644 index 9d82e3b5829..00000000000 --- a/app/src/main/java/org/schabi/newpipe/info_list/dialog/StreamDialogEntry.java +++ /dev/null @@ -1,31 +0,0 @@ -package org.schabi.newpipe.info_list.dialog; - -import android.content.Context; - -import androidx.annotation.NonNull; -import androidx.annotation.StringRes; -import androidx.fragment.app.Fragment; - -import org.schabi.newpipe.extractor.stream.StreamInfoItem; - -public class StreamDialogEntry { - - @StringRes - public final int resource; - @NonNull - public final StreamDialogEntryAction action; - - public StreamDialogEntry(@StringRes final int resource, - @NonNull final StreamDialogEntryAction action) { - this.resource = resource; - this.action = action; - } - - public String getString(@NonNull final Context context) { - return context.getString(resource); - } - - public interface StreamDialogEntryAction { - void onClick(Fragment fragment, StreamInfoItem infoItem); - } -} From 0f37d1281fbf22ea695e37f8215e1709608a0849 Mon Sep 17 00:00:00 2001 From: Stypox Date: Wed, 12 Feb 2025 01:23:06 +0100 Subject: [PATCH 18/41] Slight adjustments to long press menu --- .../ui/components/menu/LongPressMenu.kt | 44 ++++++++++++++++--- 1 file changed, 38 insertions(+), 6 deletions(-) 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 c57e271eaa6..200b131f2f1 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 @@ -22,9 +22,11 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding 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.PlaylistPlay +import androidx.compose.material.icons.filled.Panorama import androidx.compose.material.icons.filled.Settings import androidx.compose.material3.BottomSheetDefaults import androidx.compose.material3.ExperimentalMaterial3Api @@ -110,7 +112,7 @@ fun LongPressMenu( longPressActions: List, onDismissRequest: () -> Unit, onEditActions: () -> Unit = {}, // TODO handle this menu - sheetState: SheetState = rememberModalBottomSheetState(), + sheetState: SheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), ) { ModalBottomSheet( onDismissRequest, @@ -122,8 +124,8 @@ fun LongPressMenu( .fillMaxWidth() .padding(start = 6.dp, end = 6.dp, bottom = 16.dp) ) { - val minButtonWidth = 80.dp - val buttonHeight = 85.dp + val minButtonWidth = 86.dp + val buttonHeight = 86.dp val headerWidthInButtons = 5 // the header is 5 times as wide as the buttons val buttonsPerRow = (maxWidth / minButtonWidth).toInt() @@ -282,11 +284,13 @@ fun LongPressMenuHeader( ) { Text( text = Localization.getDurationString(decoration.duration), - modifier = Modifier.padding(vertical = 2.dp, horizontal = 4.dp) + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(vertical = 2.dp, horizontal = 4.dp), ) } } } + is LongPressable.Decoration.Live -> { // only show "Live" if there is a thumbnail if (item.thumbnailUrl != null) { @@ -300,6 +304,7 @@ fun LongPressMenuHeader( ) { Text( text = stringResource(R.string.duration_live).uppercase(), + style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(vertical = 2.dp, horizontal = 4.dp) ) } @@ -343,11 +348,17 @@ fun LongPressMenuHeader( Column( modifier = Modifier.padding(vertical = 12.dp, horizontal = 12.dp), ) { + val marquee = Modifier.basicMarquee( + // wait some time before starting animations, to not distract the user + initialDelayMillis = 4000, + iterations = Int.MAX_VALUE + ) + Text( text = item.title, style = MaterialTheme.typography.titleMedium, maxLines = 1, - modifier = Modifier.basicMarquee(iterations = Int.MAX_VALUE), + modifier = marquee, ) val subtitle = getSubtitleAnnotatedString( @@ -368,7 +379,7 @@ fun LongPressMenuHeader( Modifier.clickable(onClick = onUploaderClick) } .fillMaxWidth() - .basicMarquee(iterations = Int.MAX_VALUE) + .then(marquee) ) } } @@ -466,6 +477,17 @@ fun LongPressMenuButton( } } +@Preview +@Composable +private fun LongPressMenuButtonPreview() { + LongPressMenuButton( + icon = Icons.Default.Panorama, + text = "Set as playlist thumbnail", + onClick = { }, + modifier = Modifier.width(86.dp) + ) +} + private class LongPressablePreviews : CollectionPreviewParameterProvider( listOf( LongPressable( @@ -498,6 +520,16 @@ private class LongPressablePreviews : CollectionPreviewParameterProvider Date: Thu, 13 Feb 2025 23:36:27 +0100 Subject: [PATCH 19/41] Add more previews to LongPressMenu --- .../ui/components/menu/LongPressMenu.kt | 50 +++++++++++++------ 1 file changed, 36 insertions(+), 14 deletions(-) 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 200b131f2f1..8cc3ec86189 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 @@ -14,6 +14,8 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -22,12 +24,10 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding 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.PlaylistPlay -import androidx.compose.material.icons.filled.Panorama -import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.Tune import androidx.compose.material3.BottomSheetDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon @@ -213,9 +213,8 @@ fun LongPressMenu( } } -@Preview @Composable -fun LongPressMenuDragHandle(onEditActions: () -> Unit = {}) { +fun LongPressMenuDragHandle(onEditActions: () -> Unit) { Box( modifier = Modifier.fillMaxWidth() ) { @@ -229,7 +228,7 @@ fun LongPressMenuDragHandle(onEditActions: () -> Unit = {}) { // show a small button here, it's not an important button and it shouldn't // capture the user attention Icon( - imageVector = Icons.Default.Settings, + imageVector = Icons.Default.Tune, contentDescription = stringResource(R.string.edit), // same color and height as the DragHandle tint = MaterialTheme.colorScheme.onSurfaceVariant, @@ -241,6 +240,17 @@ fun LongPressMenuDragHandle(onEditActions: () -> Unit = {}) { } } +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO or Configuration.UI_MODE_TYPE_NORMAL) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES or Configuration.UI_MODE_TYPE_NORMAL) +@Composable +private fun LongPressMenuDragHandlePreview() { + AppTheme { + Surface(color = MaterialTheme.colorScheme.surfaceContainerLow) { + LongPressMenuDragHandle {} + } + } +} + @Composable fun LongPressMenuHeader( item: LongPressable, @@ -443,6 +453,8 @@ fun LongPressMenuButton( enabled: Boolean = true, modifier: Modifier = Modifier, ) { + // TODO possibly make it so that when you long-press on the button, the label appears on-screen + // as a small popup, so in case the label text is cut off the users can still read it in full OutlinedButton( onClick = onClick, enabled = enabled, @@ -477,15 +489,25 @@ fun LongPressMenuButton( } } -@Preview +@ExperimentalLayoutApi +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO or Configuration.UI_MODE_TYPE_NORMAL) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES or Configuration.UI_MODE_TYPE_NORMAL) @Composable -private fun LongPressMenuButtonPreview() { - LongPressMenuButton( - icon = Icons.Default.Panorama, - text = "Set as playlist thumbnail", - onClick = { }, - modifier = Modifier.width(86.dp) - ) +private fun LongPressMenuButtonPreviews() { + AppTheme { + Surface(color = MaterialTheme.colorScheme.surfaceContainerLow) { + FlowRow { + for (entry in LongPressAction.Type.entries) { + LongPressMenuButton( + icon = entry.icon, + text = stringResource(entry.label), + onClick = { }, + modifier = Modifier.size(86.dp) + ) + } + } + } + } } private class LongPressablePreviews : CollectionPreviewParameterProvider( From 2cf03b6a1583fe504f66e03196931ab8e3690f07 Mon Sep 17 00:00:00 2001 From: Stypox Date: Fri, 14 Feb 2025 14:08:08 +0100 Subject: [PATCH 20/41] Use LongPressMenu in BookmarkFragment (wip) --- .../local/bookmark/BookmarkFragment.java | 64 +++++++++---------- .../ui/components/menu/LongPressAction.kt | 49 +++++++++++++- .../ui/components/menu/LongPressable.kt | 27 ++++++++ 3 files changed, 106 insertions(+), 34 deletions(-) diff --git a/app/src/main/java/org/schabi/newpipe/local/bookmark/BookmarkFragment.java b/app/src/main/java/org/schabi/newpipe/local/bookmark/BookmarkFragment.java index 49962533257..c36fa8549a8 100644 --- a/app/src/main/java/org/schabi/newpipe/local/bookmark/BookmarkFragment.java +++ b/app/src/main/java/org/schabi/newpipe/local/bookmark/BookmarkFragment.java @@ -1,8 +1,8 @@ package org.schabi.newpipe.local.bookmark; import static org.schabi.newpipe.local.bookmark.MergedPlaylistManager.getMergedOrderedPlaylists; +import static org.schabi.newpipe.ui.components.menu.LongPressMenuKt.openLongPressMenuInActivity; -import android.content.DialogInterface; import android.os.Bundle; import android.os.Parcelable; import android.text.InputType; @@ -39,6 +39,8 @@ import org.schabi.newpipe.local.holder.RemoteBookmarkPlaylistItemHolder; import org.schabi.newpipe.local.playlist.LocalPlaylistManager; import org.schabi.newpipe.local.playlist.RemotePlaylistManager; +import org.schabi.newpipe.ui.components.menu.LongPressAction; +import org.schabi.newpipe.ui.components.menu.LongPressable; import org.schabi.newpipe.ui.emptystate.EmptyStateSpec; import org.schabi.newpipe.ui.emptystate.EmptyStateUtil; import org.schabi.newpipe.util.NavigationHelper; @@ -162,7 +164,7 @@ public void held(final LocalItem selectedItem) { if (selectedItem instanceof PlaylistMetadataEntry) { showLocalDialog((PlaylistMetadataEntry) selectedItem); } else if (selectedItem instanceof PlaylistRemoteEntity) { - showRemoteDeleteDialog((PlaylistRemoteEntity) selectedItem); + showRemoteDialog((PlaylistRemoteEntity) selectedItem); } } @@ -491,42 +493,31 @@ public void onSwiped(@NonNull final RecyclerView.ViewHolder viewHolder, // Utils /////////////////////////////////////////////////////////////////////////// - private void showRemoteDeleteDialog(final PlaylistRemoteEntity item) { - showDeleteDialog(item.getName(), item); + private void showRemoteDialog(final PlaylistRemoteEntity item) { + openLongPressMenuInActivity( + requireActivity(), + LongPressable.fromPlaylistRemoteEntity(item), + LongPressAction.fromPlaylistRemoteEntity( + item, + () -> showDeleteDialog(item.getName(), item) + ) + ); } private void showLocalDialog(final PlaylistMetadataEntry selectedItem) { - final String rename = getString(R.string.rename); - final String delete = getString(R.string.delete); - final String unsetThumbnail = getString(R.string.unset_playlist_thumbnail); final boolean isThumbnailPermanent = localPlaylistManager .getIsPlaylistThumbnailPermanent(selectedItem.getUid()); - final ArrayList items = new ArrayList<>(); - items.add(rename); - items.add(delete); - if (isThumbnailPermanent) { - items.add(unsetThumbnail); - } - - final DialogInterface.OnClickListener action = (d, index) -> { - if (items.get(index).equals(rename)) { - showRenameDialog(selectedItem); - } else if (items.get(index).equals(delete)) { - showDeleteDialog(selectedItem.name, selectedItem); - } else if (isThumbnailPermanent && items.get(index).equals(unsetThumbnail)) { - final long thumbnailStreamId = localPlaylistManager - .getAutomaticPlaylistThumbnailStreamId(selectedItem.getUid()); - localPlaylistManager - .changePlaylistThumbnail(selectedItem.getUid(), thumbnailStreamId, false) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(); - } - }; - - new AlertDialog.Builder(activity) - .setItems(items.toArray(new String[0]), action) - .show(); + openLongPressMenuInActivity( + requireActivity(), + LongPressable.fromPlaylistMetadataEntry(selectedItem), + LongPressAction.fromPlaylistMetadataEntry( + selectedItem, + () -> showRenameDialog(selectedItem), + () -> showDeleteDialog(selectedItem.name, selectedItem), + isThumbnailPermanent ? () -> unsetPermanentThumbnail(selectedItem) : null + ) + ); } private void showRenameDialog(final PlaylistMetadataEntry selectedItem) { @@ -559,4 +550,13 @@ private void showDeleteDialog(final String name, final PlaylistLocalItem item) { .setNegativeButton(R.string.cancel, null) .show(); } + + private void unsetPermanentThumbnail(final PlaylistMetadataEntry item) { + final long thumbnailStreamId = localPlaylistManager + .getAutomaticPlaylistThumbnailStreamId(item.getUid()); + localPlaylistManager + .changePlaylistThumbnail(item.getUid(), thumbnailStreamId, false) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(); + } } 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 7d5ece29289..170fe1bcae6 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 @@ -10,9 +10,11 @@ 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.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.OpenInBrowser -import androidx.compose.material.icons.filled.Panorama import androidx.compose.material.icons.filled.Person import androidx.compose.material.icons.filled.PictureInPicture import androidx.compose.material.icons.filled.PlayArrow @@ -21,7 +23,9 @@ 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.playlist.PlaylistMetadataEntry import org.schabi.newpipe.database.playlist.PlaylistStreamEntry +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 @@ -59,7 +63,9 @@ data class LongPressAction( 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), - SetAsPlaylistThumbnail(R.string.set_as_playlist_thumbnail, Icons.Default.Panorama), + Rename(R.string.rename, Icons.Default.Edit), + SetAsPlaylistThumbnail(R.string.set_as_playlist_thumbnail, Icons.Default.Image), + UnsetPlaylistThumbnail(R.string.unset_playlist_thumbnail, Icons.Default.HideImage), ; // TODO allow actions to return disposables @@ -103,6 +109,17 @@ data class LongPressAction( ) } + private fun buildShareActionList(name: String, url: String, thumbnailUrl: String?): List { + return listOf( + Type.Share.buildAction { context -> + ShareUtils.shareText(context, name, url, thumbnailUrl) + }, + Type.OpenInBrowser.buildAction { context -> + ShareUtils.openUrlInBrowser(context, url) + }, + ) + } + @JvmStatic fun fromStreamInfoItem( item: StreamInfoItem, @@ -196,6 +213,7 @@ data class LongPressAction( @JvmStatic fun fromPlaylistStreamEntry( item: PlaylistStreamEntry, + // TODO possibly embed these two actions here onDelete: Runnable, onSetAsPlaylistThumbnail: Runnable, ): List { @@ -205,5 +223,32 @@ data class LongPressAction( Type.SetAsPlaylistThumbnail.buildAction { onSetAsPlaylistThumbnail.run() } ) } + + @JvmStatic + fun fromPlaylistMetadataEntry( + item: PlaylistMetadataEntry, + onRename: Runnable, + onDelete: Runnable, + unsetPlaylistThumbnail: Runnable?, + ): List { + return listOf( + Type.Rename.buildAction { onRename.run() }, + Type.Delete.buildAction { onDelete.run() }, + Type.UnsetPlaylistThumbnail.buildAction( + enabled = { unsetPlaylistThumbnail != null } + ) { unsetPlaylistThumbnail?.run() } + ) + } + + @JvmStatic + fun fromPlaylistRemoteEntity( + item: PlaylistRemoteEntity, + onDelete: Runnable, + ): List { + return buildShareActionList(item.name, item.url, item.thumbnailUrl) + + listOf( + Type.Delete.buildAction { onDelete.run() }, + ) + } } } 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 06e5146fd35..e9bd1fd2b88 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 @@ -1,6 +1,8 @@ package org.schabi.newpipe.ui.components.menu import androidx.compose.runtime.Stable +import org.schabi.newpipe.database.playlist.PlaylistMetadataEntry +import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity import org.schabi.newpipe.database.stream.model.StreamEntity import org.schabi.newpipe.extractor.stream.StreamInfoItem import org.schabi.newpipe.extractor.stream.StreamType @@ -62,5 +64,30 @@ data class LongPressable( ?: item.textualUploadDate?.let { Either.left(it) }, decoration = Decoration.from(item.streamType, item.duration), ) + + @JvmStatic + fun fromPlaylistMetadataEntry(item: PlaylistMetadataEntry) = LongPressable( + // many fields are null because this is a local playlist + title = item.name, + url = null, + thumbnailUrl = item.thumbnailUrl, + uploader = null, + uploaderUrl = null, + viewCount = null, + uploadDate = null, + decoration = Decoration.Playlist(item.streamCount), + ) + + @JvmStatic + fun fromPlaylistRemoteEntity(item: PlaylistRemoteEntity) = LongPressable( + title = item.name, + url = item.url, + thumbnailUrl = item.thumbnailUrl, + uploader = item.uploader, + uploaderUrl = null, + viewCount = null, + uploadDate = null, + decoration = Decoration.Playlist(item.streamCount), + ) } } From ed2a03b1f438636935164f785152207c79db3279 Mon Sep 17 00:00:00 2001 From: Stypox Date: Fri, 14 Feb 2025 14:13:41 +0100 Subject: [PATCH 21/41] Tune transparencies of decorations in long press menu --- .../org/schabi/newpipe/ui/components/menu/LongPressMenu.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 8cc3ec86189..899e5650203 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 @@ -285,7 +285,7 @@ fun LongPressMenuHeader( // only show duration if there is a thumbnail if (item.thumbnailUrl != null) { Surface( - color = Color.Black.copy(alpha = 0.6f), + color = Color.Black.copy(alpha = 0.5f), contentColor = Color.White, modifier = Modifier .align(Alignment.BottomEnd) @@ -323,7 +323,7 @@ fun LongPressMenuHeader( is LongPressable.Decoration.Playlist -> { Surface( - color = Color.Black.copy(alpha = 0.6f), + color = Color.Black.copy(alpha = 0.4f), contentColor = Color.White, modifier = Modifier .align(Alignment.TopEnd) From 222bfa5ecea822817c9b5fc73cf38af00e2692e9 Mon Sep 17 00:00:00 2001 From: Stypox Date: Fri, 14 Feb 2025 14:36:21 +0100 Subject: [PATCH 22/41] Use faded marquee text in long press menu header --- .../ui/components/menu/LongPressMenu.kt | 16 +++--- .../newpipe/util/text/FadedMarqueeModifier.kt | 54 +++++++++++++++++++ 2 files changed, 60 insertions(+), 10 deletions(-) create mode 100644 app/src/main/java/org/schabi/newpipe/util/text/FadedMarqueeModifier.kt 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 899e5650203..6e29507296c 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,7 +7,6 @@ import android.content.Context import android.content.res.Configuration import android.view.ViewGroup import android.view.ViewGroup.LayoutParams -import androidx.compose.foundation.basicMarquee import androidx.compose.foundation.clickable import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement @@ -75,6 +74,7 @@ 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.text.fadedMarquee import java.time.OffsetDateTime fun openLongPressMenuInActivity( @@ -356,19 +356,15 @@ fun LongPressMenuHeader( } Column( - modifier = Modifier.padding(vertical = 12.dp, horizontal = 12.dp), + modifier = Modifier.padding(vertical = 12.dp), ) { - val marquee = Modifier.basicMarquee( - // wait some time before starting animations, to not distract the user - initialDelayMillis = 4000, - iterations = Int.MAX_VALUE - ) - Text( text = item.title, style = MaterialTheme.typography.titleMedium, maxLines = 1, - modifier = marquee, + modifier = Modifier + .fillMaxWidth() + .fadedMarquee(edgeWidth = 12.dp), ) val subtitle = getSubtitleAnnotatedString( @@ -389,7 +385,7 @@ fun LongPressMenuHeader( Modifier.clickable(onClick = onUploaderClick) } .fillMaxWidth() - .then(marquee) + .fadedMarquee(edgeWidth = 12.dp) ) } } diff --git a/app/src/main/java/org/schabi/newpipe/util/text/FadedMarqueeModifier.kt b/app/src/main/java/org/schabi/newpipe/util/text/FadedMarqueeModifier.kt new file mode 100644 index 00000000000..e9d78c92ce9 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/text/FadedMarqueeModifier.kt @@ -0,0 +1,54 @@ +package org.schabi.newpipe.util.text + +import androidx.compose.foundation.MarqueeSpacing +import androidx.compose.foundation.basicMarquee +import androidx.compose.foundation.layout.padding +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.CompositingStrategy +import androidx.compose.ui.graphics.drawscope.ContentDrawScope +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.unit.Dp + +/** + * Note: the values in [basicMarquee] are hardcoded, but feel free to expose them as parameters + * in case that will be needed in the future. + * + * Taken from sample [androidx.compose.foundation.samples.BasicMarqueeWithFadedEdgesSample]. + */ +fun Modifier.fadedMarquee(edgeWidth: Dp): Modifier { + fun ContentDrawScope.drawFadedEdge(leftEdge: Boolean) { + val edgeWidthPx = edgeWidth.toPx() + drawRect( + topLeft = Offset(if (leftEdge) 0f else size.width - edgeWidthPx, 0f), + size = Size(edgeWidthPx, size.height), + brush = Brush.horizontalGradient( + colors = listOf(Color.Transparent, Color.Black), + startX = if (leftEdge) 0f else size.width, + endX = if (leftEdge) edgeWidthPx else size.width - edgeWidthPx + ), + blendMode = BlendMode.DstIn + ) + } + + return this + .graphicsLayer { compositingStrategy = CompositingStrategy.Offscreen } + .drawWithContent { + drawContent() + drawFadedEdge(leftEdge = true) + drawFadedEdge(leftEdge = false) + } + .basicMarquee( + repeatDelayMillis = 2000, + // wait some time before starting animations, to not distract the user + initialDelayMillis = 4000, + iterations = Int.MAX_VALUE, + spacing = MarqueeSpacing(edgeWidth) + ) + .padding(start = edgeWidth) +} From 5dba8223b613a427dac24ef0e0157925e810d860 Mon Sep 17 00:00:00 2001 From: Stypox Date: Fri, 14 Mar 2025 12:57:26 +0100 Subject: [PATCH 23/41] Add long press actions to channels and playlists info items --- .../fragments/list/BaseListFragment.java | 85 ++++++++++++------- .../player/playqueue/ChannelTabPlayQueue.java | 41 +++++++-- .../player/playqueue/PlaylistPlayQueue.java | 6 ++ .../ui/components/menu/LongPressAction.kt | 29 ++++++- .../ui/components/menu/LongPressable.kt | 26 ++++++ 5 files changed, 151 insertions(+), 36 deletions(-) diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListFragment.java index 7a1ee30952d..bafe1d554e2 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListFragment.java @@ -23,6 +23,8 @@ import org.schabi.newpipe.R; import org.schabi.newpipe.error.ErrorUtil; import org.schabi.newpipe.extractor.InfoItem; +import org.schabi.newpipe.extractor.channel.ChannelInfoItem; +import org.schabi.newpipe.extractor.playlist.PlaylistInfoItem; import org.schabi.newpipe.extractor.stream.StreamInfoItem; import org.schabi.newpipe.fragments.BaseStateFragment; import org.schabi.newpipe.fragments.OnScrollBelowItemsListener; @@ -258,7 +260,10 @@ protected void initListeners() { infoListAdapter.setOnStreamSelectedListener(new OnClickGesture<>() { @Override public void selected(final StreamInfoItem selectedItem) { - onStreamSelected(selectedItem); + onItemSelected(selectedItem); + NavigationHelper.openVideoDetailFragment(requireContext(), getFM(), + selectedItem.getServiceId(), selectedItem.getUrl(), selectedItem.getName(), + null, false); } @Override @@ -267,23 +272,50 @@ public void held(final StreamInfoItem selectedItem) { } }); - infoListAdapter.setOnChannelSelectedListener(selectedItem -> { - try { - onItemSelected(selectedItem); - NavigationHelper.openChannelFragment(getFM(), selectedItem.getServiceId(), - selectedItem.getUrl(), selectedItem.getName()); - } catch (final Exception e) { - ErrorUtil.showUiErrorSnackbar(this, "Opening channel fragment", e); + infoListAdapter.setOnChannelSelectedListener(new OnClickGesture<>() { + @Override + public void selected(final ChannelInfoItem selectedItem) { + try { + onItemSelected(selectedItem); + NavigationHelper.openChannelFragment(getFM(), selectedItem.getServiceId(), + selectedItem.getUrl(), selectedItem.getName()); + } catch (final Exception e) { + ErrorUtil.showUiErrorSnackbar(BaseListFragment.this, "Opening channel fragment", + e); + } + } + + @Override + public void held(final ChannelInfoItem selectedItem) { + openLongPressMenuInActivity( + requireActivity(), + LongPressable.fromChannelInfoItem(selectedItem), + LongPressAction.fromChannelInfoItem(selectedItem) + ); } }); - infoListAdapter.setOnPlaylistSelectedListener(selectedItem -> { - try { - onItemSelected(selectedItem); - NavigationHelper.openPlaylistFragment(getFM(), selectedItem.getServiceId(), - selectedItem.getUrl(), selectedItem.getName()); - } catch (final Exception e) { - ErrorUtil.showUiErrorSnackbar(this, "Opening playlist fragment", e); + infoListAdapter.setOnPlaylistSelectedListener(new OnClickGesture<>() { + @Override + public void selected(final PlaylistInfoItem selectedItem) { + try { + BaseListFragment.this.onItemSelected(selectedItem); + NavigationHelper.openPlaylistFragment(BaseListFragment.this.getFM(), + selectedItem.getServiceId(), + selectedItem.getUrl(), selectedItem.getName()); + } catch (final Exception e) { + ErrorUtil.showUiErrorSnackbar(BaseListFragment.this, + "Opening playlist fragment", e); + } + } + + @Override + public void held(final PlaylistInfoItem selectedItem) { + openLongPressMenuInActivity( + requireActivity(), + LongPressable.fromPlaylistInfoItem(selectedItem), + LongPressAction.fromPlaylistInfoItem(selectedItem) + ); } }); @@ -293,6 +325,14 @@ public void held(final StreamInfoItem selectedItem) { useNormalItemListScrollListener(); } + protected void showInfoItemDialog(final StreamInfoItem item) { + openLongPressMenuInActivity( + requireActivity(), + LongPressable.fromStreamInfoItem(item), + LongPressAction.fromStreamInfoItem(item) + ); + } + /** * Removes all listeners and adds the normal scroll listener to the {@link #itemsList}. */ @@ -375,27 +415,12 @@ public void onScrolledDown(final RecyclerView recyclerView) { } } - private void onStreamSelected(final StreamInfoItem selectedItem) { - onItemSelected(selectedItem); - NavigationHelper.openVideoDetailFragment(requireContext(), getFM(), - selectedItem.getServiceId(), selectedItem.getUrl(), selectedItem.getName(), - null, false); - } - protected void onScrollToBottom() { if (hasMoreItems() && !isLoading.get()) { loadMoreItems(); } } - protected void showInfoItemDialog(final StreamInfoItem item) { - openLongPressMenuInActivity( - requireActivity(), - LongPressable.fromStreamInfoItem(item), - LongPressAction.fromStreamInfoItem(item) - ); - } - /*////////////////////////////////////////////////////////////////////////// // Menu //////////////////////////////////////////////////////////////////////////*/ diff --git a/app/src/main/java/org/schabi/newpipe/player/playqueue/ChannelTabPlayQueue.java b/app/src/main/java/org/schabi/newpipe/player/playqueue/ChannelTabPlayQueue.java index a9eb2a19c7e..77b253283d0 100644 --- a/app/src/main/java/org/schabi/newpipe/player/playqueue/ChannelTabPlayQueue.java +++ b/app/src/main/java/org/schabi/newpipe/player/playqueue/ChannelTabPlayQueue.java @@ -1,10 +1,14 @@ package org.schabi.newpipe.player.playqueue; +import androidx.annotation.Nullable; + import org.schabi.newpipe.extractor.Page; import org.schabi.newpipe.extractor.channel.tabs.ChannelTabInfo; +import org.schabi.newpipe.extractor.exceptions.ExtractionException; import org.schabi.newpipe.extractor.linkhandler.ListLinkHandler; import org.schabi.newpipe.extractor.stream.StreamInfoItem; +import org.schabi.newpipe.util.ChannelTabHelper; import org.schabi.newpipe.util.ExtractorHelper; import java.util.Collections; @@ -15,7 +19,8 @@ public final class ChannelTabPlayQueue extends AbstractInfoPlayQueue { - final ListLinkHandler linkHandler; + @Nullable + ListLinkHandler linkHandler; public ChannelTabPlayQueue(final int serviceId, final ListLinkHandler linkHandler, @@ -31,6 +36,13 @@ public ChannelTabPlayQueue(final int serviceId, this(serviceId, linkHandler, null, Collections.emptyList(), 0); } + // Plays the first + public ChannelTabPlayQueue(final int serviceId, + final String channelUrl) { + super(serviceId, channelUrl, null, Collections.emptyList(), 0); + linkHandler = null; + } + @Override protected String getTag() { return "ChannelTabPlayQueue@" + Integer.toHexString(hashCode()); @@ -39,10 +51,29 @@ protected String getTag() { @Override public void fetch() { if (isInitial) { - ExtractorHelper.getChannelTab(this.serviceId, this.linkHandler, false) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(getHeadListObserver()); + if (linkHandler == null) { + ExtractorHelper.getChannelInfo(this.serviceId, this.baseUrl, false) + .flatMap(channelInfo -> { + linkHandler = channelInfo.getTabs() + .stream() + .filter(ChannelTabHelper::isStreamsTab) + .findFirst() + .orElseThrow(() -> new ExtractionException( + "No playable channel tab found")); + + return ExtractorHelper + .getChannelTab(this.serviceId, this.linkHandler, false); + }) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(getHeadListObserver()); + + } else { + ExtractorHelper.getChannelTab(this.serviceId, this.linkHandler, false) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(getHeadListObserver()); + } } else { ExtractorHelper.getMoreChannelTabItems(this.serviceId, this.linkHandler, this.nextPage) .subscribeOn(Schedulers.io()) diff --git a/app/src/main/java/org/schabi/newpipe/player/playqueue/PlaylistPlayQueue.java b/app/src/main/java/org/schabi/newpipe/player/playqueue/PlaylistPlayQueue.java index 32316f3936d..ee87a64f36c 100644 --- a/app/src/main/java/org/schabi/newpipe/player/playqueue/PlaylistPlayQueue.java +++ b/app/src/main/java/org/schabi/newpipe/player/playqueue/PlaylistPlayQueue.java @@ -5,6 +5,7 @@ import org.schabi.newpipe.extractor.stream.StreamInfoItem; import org.schabi.newpipe.util.ExtractorHelper; +import java.util.Collections; import java.util.List; import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; @@ -28,6 +29,11 @@ public PlaylistPlayQueue(final int serviceId, super(serviceId, url, nextPage, streams, index); } + public PlaylistPlayQueue(final int serviceId, + final String url) { + this(serviceId, url, null, Collections.emptyList(), 0); + } + @Override protected String getTag() { return "PlaylistPlayQueue@" + Integer.toHexString(hashCode()); 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 170fe1bcae6..f59e0acc2e2 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 @@ -30,12 +30,16 @@ 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.extractor.InfoItem +import org.schabi.newpipe.extractor.channel.ChannelInfoItem +import org.schabi.newpipe.extractor.playlist.PlaylistInfoItem 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.ChannelTabPlayQueue import org.schabi.newpipe.player.playqueue.PlayQueue +import org.schabi.newpipe.player.playqueue.PlaylistPlayQueue import org.schabi.newpipe.player.playqueue.SinglePlayQueue import org.schabi.newpipe.util.NavigationHelper import org.schabi.newpipe.util.SparseItemUtil @@ -245,10 +249,33 @@ data class LongPressAction( item: PlaylistRemoteEntity, onDelete: Runnable, ): List { - return buildShareActionList(item.name, item.url, item.thumbnailUrl) + + return buildPlayerActionList { PlaylistPlayQueue(item.serviceId, item.url) } + + buildShareActionList(item.name, item.url, item.thumbnailUrl) + listOf( Type.Delete.buildAction { onDelete.run() }, ) } + + @JvmStatic + fun fromChannelInfoItem(item: ChannelInfoItem): List { + return buildPlayerActionList { ChannelTabPlayQueue(item.serviceId, item.url) } + + buildShareActionList(item) + + listOf( + Type.ShowChannelDetails.buildAction { context -> + NavigationHelper.openChannelFragment( + context.findFragmentActivity().supportFragmentManager, + item.serviceId, + item.url, + item.name, + ) + }, + ) + } + + @JvmStatic + fun fromPlaylistInfoItem(item: PlaylistInfoItem): List { + return buildPlayerActionList { PlaylistPlayQueue(item.serviceId, item.url) } + + buildShareActionList(item) + } } } 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 e9bd1fd2b88..10ebe7f8da2 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 @@ -4,6 +4,8 @@ import androidx.compose.runtime.Stable import org.schabi.newpipe.database.playlist.PlaylistMetadataEntry import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity import org.schabi.newpipe.database.stream.model.StreamEntity +import org.schabi.newpipe.extractor.channel.ChannelInfoItem +import org.schabi.newpipe.extractor.playlist.PlaylistInfoItem import org.schabi.newpipe.extractor.stream.StreamInfoItem import org.schabi.newpipe.extractor.stream.StreamType import org.schabi.newpipe.extractor.stream.StreamType.AUDIO_LIVE_STREAM @@ -89,5 +91,29 @@ data class LongPressable( uploadDate = null, decoration = Decoration.Playlist(item.streamCount), ) + + @JvmStatic + fun fromChannelInfoItem(item: ChannelInfoItem) = LongPressable( + title = item.name, + url = item.url?.takeIf { it.isNotBlank() }, + thumbnailUrl = ImageStrategy.choosePreferredImage(item.thumbnails), + uploader = null, + uploaderUrl = item.url?.takeIf { it.isNotBlank() }, + viewCount = null, + uploadDate = null, + decoration = null, + ) + + @JvmStatic + fun fromPlaylistInfoItem(item: PlaylistInfoItem) = LongPressable( + title = item.name, + url = item.url?.takeIf { it.isNotBlank() }, + thumbnailUrl = ImageStrategy.choosePreferredImage(item.thumbnails), + uploader = item.uploaderName.takeIf { it.isNotBlank() }, + uploaderUrl = item.uploaderUrl?.takeIf { it.isNotBlank() }, + viewCount = null, + uploadDate = null, + decoration = Decoration.Playlist(item.streamCount), + ) } } From 2016c2e10ea5ecc59710c25fe285381c7236f01c Mon Sep 17 00:00:00 2001 From: Stypox Date: Sun, 17 Aug 2025 13:16:15 +0200 Subject: [PATCH 24/41] Add OpenInNew icon next to channel name --- .../ui/components/menu/LongPressMenu.kt | 32 ++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) 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 6e29507296c..69053ffaac5 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 @@ -24,7 +24,10 @@ import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.text.InlineTextContent +import androidx.compose.foundation.text.appendInlineContent import androidx.compose.material.icons.Icons +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 @@ -54,6 +57,8 @@ import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.Placeholder +import androidx.compose.ui.text.PlaceholderVerticalAlign import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight @@ -379,13 +384,14 @@ fun LongPressMenuHeader( Text( text = subtitle, style = MaterialTheme.typography.bodyMedium, + inlineContent = getSubtitleInlineContent(), modifier = if (onUploaderClick == null) { Modifier } else { Modifier.clickable(onClick = onUploaderClick) } .fillMaxWidth() - .fadedMarquee(edgeWidth = 12.dp) + .fadedMarquee(edgeWidth = 12.dp), ) } } @@ -413,6 +419,9 @@ fun getSubtitleAnnotatedString( } else { append(item.uploader) } + append(" ") + // see getSubtitleInlineContent() + appendInlineContent("open_in_new", "↗") } shouldAddSeparator = true } else if (!item.uploader.isNullOrBlank()) { @@ -441,6 +450,27 @@ fun getSubtitleAnnotatedString( } } +/** + * [getSubtitleAnnotatedString] returns a string that might make use of the OpenInNew icon, and we + * provide it to [Text] through its `inlineContent` parameter. + */ +@Composable +fun getSubtitleInlineContent() = mapOf( + "open_in_new" to InlineTextContent( + placeholder = Placeholder( + width = MaterialTheme.typography.bodyMedium.fontSize, + height = MaterialTheme.typography.bodyMedium.fontSize, + placeholderVerticalAlign = PlaceholderVerticalAlign.Center, + ) + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.OpenInNew, + contentDescription = null, + tint = MaterialTheme.customColors.onSurfaceVariantLink, + ) + } +) + @Composable fun LongPressMenuButton( icon: ImageVector, From 47b7f5be22bd5b5dd3cf3e5eadef284fd311c6bc Mon Sep 17 00:00:00 2001 From: Stypox Date: Wed, 27 Aug 2025 19:50:27 +0200 Subject: [PATCH 25/41] Fix some lints --- .../org/schabi/newpipe/ui/components/menu/LongPressMenu.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 69053ffaac5..40e0daed653 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 @@ -132,7 +132,7 @@ fun LongPressMenu( val minButtonWidth = 86.dp val buttonHeight = 86.dp val headerWidthInButtons = 5 // the header is 5 times as wide as the buttons - val buttonsPerRow = (maxWidth / minButtonWidth).toInt() + val buttonsPerRow = (this.maxWidth / minButtonWidth).toInt() // the channel icon goes in the menu header, so do not show a button for it val actions = longPressActions.toMutableList() @@ -476,8 +476,8 @@ fun LongPressMenuButton( icon: ImageVector, text: String, onClick: () -> Unit, - enabled: Boolean = true, modifier: Modifier = Modifier, + enabled: Boolean = true, ) { // TODO possibly make it so that when you long-press on the button, the label appears on-screen // as a small popup, so in case the label text is cut off the users can still read it in full From 2de8b09df7cd977fea70dd009803ba3609b18472 Mon Sep 17 00:00:00 2001 From: Stypox Date: Thu, 28 Aug 2025 17:39:56 +0200 Subject: [PATCH 26/41] Add icons for play/background/popup from here --- .../ui/components/menu/LongPressAction.kt | 6 ++ .../menu/icons/BackgroundFromHere.kt | 70 ++++++++++++++++++ .../ui/components/menu/icons/PlayFromHere.kt | 56 ++++++++++++++ .../ui/components/menu/icons/PopupFromHere.kt | 74 +++++++++++++++++++ app/src/main/res/values/strings.xml | 3 + 5 files changed, 209 insertions(+) create mode 100644 app/src/main/java/org/schabi/newpipe/ui/components/menu/icons/BackgroundFromHere.kt create mode 100644 app/src/main/java/org/schabi/newpipe/ui/components/menu/icons/PlayFromHere.kt create mode 100644 app/src/main/java/org/schabi/newpipe/ui/components/menu/icons/PopupFromHere.kt 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 f59e0acc2e2..53cf43c9f5c 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 @@ -41,6 +41,9 @@ import org.schabi.newpipe.player.playqueue.ChannelTabPlayQueue import org.schabi.newpipe.player.playqueue.PlayQueue 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.ShareUtils @@ -57,8 +60,11 @@ data class LongPressAction( 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), + BackgroundFromHere(R.string.background_from_here, Icons.Default.BackgroundFromHere), Popup(R.string.controls_popup_title, Icons.Default.PictureInPicture), + PopupFromHere(R.string.popup_from_here, Icons.Default.PopupFromHere), Play(R.string.play, Icons.Default.PlayArrow), + PlayFromHere(R.string.play_from_here, Icons.Default.PlayFromHere), 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), diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/menu/icons/BackgroundFromHere.kt b/app/src/main/java/org/schabi/newpipe/ui/components/menu/icons/BackgroundFromHere.kt new file mode 100644 index 00000000000..fd5972fcc27 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/ui/components/menu/icons/BackgroundFromHere.kt @@ -0,0 +1,70 @@ +@file:Suppress("UnusedReceiverParameter") + +package org.schabi.newpipe.ui.components.menu.icons + +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.materialIcon +import androidx.compose.material.icons.materialPath +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +/** + * Obtained by combining Filled.Headset and Filled.PlaylistPlay + */ +val Icons.Filled.BackgroundFromHere: ImageVector by lazy { + materialIcon(name = "Filled.HeadsetPlus") { + materialPath { + moveTo(7.200f, 0.000f) + curveToRelative(-3.976f, 0.000f, -7.200f, 3.224f, -7.200f, 7.200f) + verticalLineToRelative(5.600f) + curveToRelative(0.000f, 1.328f, 1.072f, 2.400f, 2.400f, 2.400f) + horizontalLineToRelative(2.400f) + verticalLineToRelative(-6.400f) + horizontalLineTo(1.600f) + verticalLineToRelative(-1.600f) + curveToRelative(0.000f, -3.096f, 2.504f, -5.600f, 5.600f, -5.600f) + reflectiveCurveToRelative(5.600f, 2.504f, 5.600f, 5.600f) + verticalLineToRelative(1.600f) + horizontalLineToRelative(-3.200f) + verticalLineToRelative(6.400f) + horizontalLineToRelative(2.400f) + curveToRelative(1.328f, 0.000f, 2.400f, -1.072f, 2.400f, -2.400f) + verticalLineToRelative(-5.600f) + curveToRelative(0.000f, -3.976f, -3.224f, -7.200f, -7.200f, -7.200f) + close() + } + materialPath { + moveTo(15.817f, 16.202f) + lineToRelative(-0.916f, 0.916f) + lineToRelative(2.977f, 2.983f) + lineToRelative(-2.977f, 2.983f) + lineToRelative(0.916f, 0.916f) + lineToRelative(3.900f, -3.900f) + close() + } + materialPath { + moveTo(20.100f, 16.202f) + lineToRelative(-0.916f, 0.916f) + lineToRelative(2.977f, 2.983f) + lineToRelative(-2.977f, 2.983f) + lineToRelative(0.916f, 0.916f) + lineToRelative(3.900f, -3.900f) + close() + } + } +} + +@Preview(showBackground = true, backgroundColor = 0xFFFFFFFF) +@Composable +private fun BackgroundFromHerePreview() { + Icon( + imageVector = Icons.Filled.BackgroundFromHere, + contentDescription = null, + modifier = Modifier.size(240.dp), + ) +} diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/menu/icons/PlayFromHere.kt b/app/src/main/java/org/schabi/newpipe/ui/components/menu/icons/PlayFromHere.kt new file mode 100644 index 00000000000..160317f4a8f --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/ui/components/menu/icons/PlayFromHere.kt @@ -0,0 +1,56 @@ +@file:Suppress("UnusedReceiverParameter") + +package org.schabi.newpipe.ui.components.menu.icons + +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.materialIcon +import androidx.compose.material.icons.materialPath +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +/** + * Obtained by combining Filled.PlayArrow and Filled.PlaylistPlay + */ +val Icons.Filled.PlayFromHere: ImageVector by lazy { + materialIcon(name = "Filled.HeadsetPlus") { + materialPath { + moveTo(5.000f, 3.000f) + verticalLineToRelative(11.200f) + lineToRelative(8.800f, -5.600f) + close() + } + materialPath { + moveTo(15.817f, 16.202f) + lineToRelative(-0.916f, 0.916f) + lineToRelative(2.977f, 2.983f) + lineToRelative(-2.977f, 2.983f) + lineToRelative(0.916f, 0.916f) + lineToRelative(3.900f, -3.900f) + close() + } + materialPath { + moveTo(20.100f, 16.202f) + lineToRelative(-0.916f, 0.916f) + lineToRelative(2.977f, 2.983f) + lineToRelative(-2.977f, 2.983f) + lineToRelative(0.916f, 0.916f) + lineToRelative(3.900f, -3.900f) + close() + } + } +} + +@Preview(showBackground = true, backgroundColor = 0xFFFFFFFF) +@Composable +private fun PlayFromHerePreview() { + Icon( + imageVector = Icons.Filled.PlayFromHere, + contentDescription = null, + modifier = Modifier.size(240.dp), + ) +} diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/menu/icons/PopupFromHere.kt b/app/src/main/java/org/schabi/newpipe/ui/components/menu/icons/PopupFromHere.kt new file mode 100644 index 00000000000..47159386839 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/ui/components/menu/icons/PopupFromHere.kt @@ -0,0 +1,74 @@ +@file:Suppress("UnusedReceiverParameter") + +package org.schabi.newpipe.ui.components.menu.icons + +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.materialIcon +import androidx.compose.material.icons.materialPath +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +/** + * Obtained by combining Filled.PictureInPicture and Filled.PlaylistPlay + */ +val Icons.Filled.PopupFromHere: ImageVector by lazy { + materialIcon(name = "Filled.HeadsetPlus") { + materialPath { + moveTo(14.320f, 3.200f) + horizontalLineToRelative(-6.400f) + verticalLineToRelative(4.800f) + horizontalLineToRelative(6.400f) + lineTo(14.320f, 3.200f) + close() + moveTo(15.920f, 0.000f) + lineTo(1.520f, 0.000f) + curveToRelative(-0.880f, 0.000f, -1.600f, 0.720f, -1.600f, 1.600f) + verticalLineToRelative(11.200f) + curveToRelative(0.000f, 0.880f, 0.720f, 1.584f, 1.600f, 1.584f) + horizontalLineToRelative(14.400f) + curveToRelative(0.880f, 0.000f, 1.600f, -0.704f, 1.600f, -1.584f) + lineTo(17.520f, 1.600f) + curveToRelative(0.000f, -0.880f, -0.720f, -1.600f, -1.600f, -1.600f) + close() + moveTo(15.920f, 12.808f) + lineTo(1.520f, 12.808f) + lineTo(1.520f, 1.584f) + horizontalLineToRelative(14.400f) + verticalLineToRelative(11.224f) + close() + } + materialPath { + moveTo(15.817f, 16.202f) + lineToRelative(-0.916f, 0.916f) + lineToRelative(2.977f, 2.983f) + lineToRelative(-2.977f, 2.983f) + lineToRelative(0.916f, 0.916f) + lineToRelative(3.900f, -3.900f) + close() + } + materialPath { + moveTo(20.100f, 16.202f) + lineToRelative(-0.916f, 0.916f) + lineToRelative(2.977f, 2.983f) + lineToRelative(-2.977f, 2.983f) + lineToRelative(0.916f, 0.916f) + lineToRelative(3.900f, -3.900f) + close() + } + } +} + +@Preview(showBackground = true, backgroundColor = 0xFFFFFFFF) +@Composable +private fun PopupFromHerePreview() { + Icon( + imageVector = Icons.Filled.PopupFromHere, + contentDescription = null, + modifier = Modifier.size(240.dp), + ) +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4a736122cad..65cb817d022 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -900,4 +900,7 @@ HTTP error 403 received from server while playing, likely caused by an IP ban or streaming URL deobfuscation issues %1$s refused to provide data, asking for a login to confirm the requester is not a bot.\n\nYour IP might have been temporarily banned by %1$s, you can wait some time or switch to a different IP (for example by turning on/off a VPN, or by switching from WiFi to mobile data). This content is not available for the currently selected content country.\n\nChange your selection from \"Settings > Content > Default content country\". + Background\nfrom here + Popup\nfrom here + Play\nfrom here From 6cb876756a5b21fa1c29bc6e1c9b9ae6681e9319 Mon Sep 17 00:00:00 2001 From: Stypox Date: Thu, 28 Aug 2025 18:26:50 +0200 Subject: [PATCH 27/41] Uniform localizing view counts --- .../fragments/detail/VideoDetailFragment.kt | 11 +-- .../holder/StreamInfoItemHolder.java | 13 +--- .../newpipe/local/feed/item/StreamItem.kt | 23 +++---- .../local/history/HistoryEntryAdapter.java | 2 +- .../LocalStatisticStreamItemHolder.java | 2 +- .../ui/components/items/stream/StreamUtils.kt | 14 ++-- .../ui/components/menu/LongPressMenu.kt | 11 ++- .../ui/components/menu/LongPressable.kt | 7 ++ .../org/schabi/newpipe/util/Localization.java | 68 ++++++++++++------- 9 files changed, 80 insertions(+), 71 deletions(-) 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 03662d1bcbf..b23bc12207c 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 @@ -1379,14 +1379,9 @@ class VideoDetailFragment : } if (info.viewCount >= 0) { - binding.detailViewCountView.text = - if (info.streamType == StreamType.AUDIO_LIVE_STREAM) { - Localization.listeningCount(activity, info.viewCount) - } else if (info.streamType == StreamType.LIVE_STREAM) { - Localization.localizeWatchingCount(activity, info.viewCount) - } else { - Localization.localizeViewCount(activity, info.viewCount) - } + binding.detailViewCountView.text = Localization.localizeViewCount( + activity, false, info.streamType, info.viewCount + ) binding.detailViewCountView.visibility = View.VISIBLE } else { binding.detailViewCountView.visibility = View.GONE diff --git a/app/src/main/java/org/schabi/newpipe/info_list/holder/StreamInfoItemHolder.java b/app/src/main/java/org/schabi/newpipe/info_list/holder/StreamInfoItemHolder.java index 80f62eed3d1..84ee2742a89 100644 --- a/app/src/main/java/org/schabi/newpipe/info_list/holder/StreamInfoItemHolder.java +++ b/app/src/main/java/org/schabi/newpipe/info_list/holder/StreamInfoItemHolder.java @@ -7,7 +7,6 @@ import org.schabi.newpipe.R; import org.schabi.newpipe.extractor.InfoItem; import org.schabi.newpipe.extractor.stream.StreamInfoItem; -import org.schabi.newpipe.extractor.stream.StreamType; import org.schabi.newpipe.info_list.InfoItemBuilder; import org.schabi.newpipe.local.history.HistoryRecordManager; import org.schabi.newpipe.util.Localization; @@ -65,16 +64,8 @@ public void updateFromItem(final InfoItem infoItem, private String getStreamInfoDetailLine(final StreamInfoItem infoItem) { String viewsAndDate = ""; if (infoItem.getViewCount() >= 0) { - if (infoItem.getStreamType().equals(StreamType.AUDIO_LIVE_STREAM)) { - viewsAndDate = Localization - .listeningCount(itemBuilder.getContext(), infoItem.getViewCount()); - } else if (infoItem.getStreamType().equals(StreamType.LIVE_STREAM)) { - viewsAndDate = Localization - .shortWatchingCount(itemBuilder.getContext(), infoItem.getViewCount()); - } else { - viewsAndDate = Localization - .shortViewCount(itemBuilder.getContext(), infoItem.getViewCount()); - } + viewsAndDate = Localization.localizeViewCount(itemBuilder.getContext(), true, + infoItem.getStreamType(), infoItem.getViewCount()); } final String uploadDate = Localization.relativeTimeOrTextual(itemBuilder.getContext(), diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/item/StreamItem.kt b/app/src/main/java/org/schabi/newpipe/local/feed/item/StreamItem.kt index 030bb7a7668..6c23408241b 100644 --- a/app/src/main/java/org/schabi/newpipe/local/feed/item/StreamItem.kt +++ b/app/src/main/java/org/schabi/newpipe/local/feed/item/StreamItem.kt @@ -1,7 +1,6 @@ package org.schabi.newpipe.local.feed.item import android.content.Context -import android.text.TextUtils import android.view.View import androidx.core.content.ContextCompat import androidx.preference.PreferenceManager @@ -117,22 +116,16 @@ data class StreamItem( } private fun getStreamInfoDetailLine(context: Context): String { - var viewsAndDate = "" - val viewCount = stream.viewCount - if (viewCount != null && viewCount >= 0) { - viewsAndDate = when (stream.streamType) { - AUDIO_LIVE_STREAM -> Localization.listeningCount(context, viewCount) - LIVE_STREAM -> Localization.shortWatchingCount(context, viewCount) - else -> Localization.shortViewCount(context, viewCount) - } - } + val views = stream.viewCount + ?.takeIf { it >= 0 } + ?.let { Localization.localizeViewCount(context, true, stream.streamType, it) } + ?: "" + val uploadDate = getFormattedRelativeUploadDate(context) return when { - !TextUtils.isEmpty(uploadDate) -> when { - viewsAndDate.isEmpty() -> uploadDate!! - else -> Localization.concatenateStrings(viewsAndDate, uploadDate) - } - else -> viewsAndDate + uploadDate.isNullOrEmpty() -> views + views.isEmpty() -> uploadDate + else -> Localization.concatenateStrings(views, uploadDate) } } diff --git a/app/src/main/java/org/schabi/newpipe/local/history/HistoryEntryAdapter.java b/app/src/main/java/org/schabi/newpipe/local/history/HistoryEntryAdapter.java index 709a16b68b6..e7e0b7416e8 100644 --- a/app/src/main/java/org/schabi/newpipe/local/history/HistoryEntryAdapter.java +++ b/app/src/main/java/org/schabi/newpipe/local/history/HistoryEntryAdapter.java @@ -55,7 +55,7 @@ protected String getFormattedDate(final Date date) { } protected String getFormattedViewString(final long viewCount) { - return Localization.shortViewCount(mContext, viewCount); + return Localization.localizeWatchCount(mContext, viewCount); } @Override diff --git a/app/src/main/java/org/schabi/newpipe/local/holder/LocalStatisticStreamItemHolder.java b/app/src/main/java/org/schabi/newpipe/local/holder/LocalStatisticStreamItemHolder.java index f26a76ad9f7..dd8edfa66f4 100644 --- a/app/src/main/java/org/schabi/newpipe/local/holder/LocalStatisticStreamItemHolder.java +++ b/app/src/main/java/org/schabi/newpipe/local/holder/LocalStatisticStreamItemHolder.java @@ -73,7 +73,7 @@ private String getStreamInfoDetailLine(final StreamStatisticsEntry entry, final DateTimeFormatter dateTimeFormatter) { return Localization.concatenateStrings( // watchCount - Localization.shortViewCount(itemBuilder.getContext(), entry.getWatchCount()), + Localization.localizeWatchCount(itemBuilder.getContext(), entry.getWatchCount()), dateTimeFormatter.format(entry.getLatestAccessDate()), // serviceName ServiceHelper.getNameOfServiceById(entry.getStreamEntity().getServiceId())); diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/items/stream/StreamUtils.kt b/app/src/main/java/org/schabi/newpipe/ui/components/items/stream/StreamUtils.kt index cdfe613edf3..eae1bc2e524 100644 --- a/app/src/main/java/org/schabi/newpipe/ui/components/items/stream/StreamUtils.kt +++ b/app/src/main/java/org/schabi/newpipe/ui/components/items/stream/StreamUtils.kt @@ -36,16 +36,10 @@ internal fun getStreamInfoDetail(stream: StreamInfoItem): String { val context = LocalContext.current return rememberSaveable(stream) { - val count = stream.viewCount - val views = if (count >= 0) { - when (stream.streamType) { - StreamType.AUDIO_LIVE_STREAM -> Localization.listeningCount(context, count) - StreamType.LIVE_STREAM -> Localization.shortWatchingCount(context, count) - else -> Localization.shortViewCount(context, count) - } - } else { - "" - } + val views = stream.viewCount + .takeIf { it >= 0 } + ?.let { Localization.localizeViewCount(context, true, stream.streamType, it) } + ?: "" val date = Localization.relativeTimeOrTextual(context, stream.uploadDate, stream.textualUploadDate) 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 40e0daed653..1419031437f 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 @@ -72,6 +72,7 @@ import androidx.compose.ui.tooling.preview.datasource.LoremIpsum import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage import org.schabi.newpipe.R +import org.schabi.newpipe.extractor.stream.StreamType import org.schabi.newpipe.ktx.popFirst import org.schabi.newpipe.ui.components.menu.LongPressAction.Type.EnqueueNext import org.schabi.newpipe.ui.components.menu.LongPressAction.Type.ShowChannelDetails @@ -441,7 +442,9 @@ fun getSubtitleAnnotatedString( append(uploadDate) } - val viewCount = item.viewCount?.let { Localization.localizeViewCount(ctx, it) } + val viewCount = item.viewCount?.let { + Localization.localizeViewCount(ctx, true, item.streamType, it) + } if (!viewCount.isNullOrBlank()) { if (shouldAddSeparator) { append(Localization.DOT_SEPARATOR) @@ -545,6 +548,7 @@ private class LongPressablePreviews : CollectionPreviewParameterProvider?, val decoration: Decoration?, ) { @@ -49,6 +50,7 @@ data class LongPressable( uploader = item.uploaderName?.takeIf { it.isNotBlank() }, uploaderUrl = item.uploaderUrl?.takeIf { it.isNotBlank() }, viewCount = item.viewCount.takeIf { it >= 0 }, + streamType = item.streamType, uploadDate = item.uploadDate?.let { Either.right(it.offsetDateTime()) } ?: item.textualUploadDate?.let { Either.left(it) }, decoration = Decoration.from(item.streamType, item.duration), @@ -62,6 +64,7 @@ data class LongPressable( uploader = item.uploader.takeIf { it.isNotBlank() }, uploaderUrl = item.uploaderUrl?.takeIf { it.isNotBlank() }, viewCount = item.viewCount?.takeIf { it >= 0 }, + streamType = item.streamType, uploadDate = item.uploadDate?.let { Either.right(it) } ?: item.textualUploadDate?.let { Either.left(it) }, decoration = Decoration.from(item.streamType, item.duration), @@ -76,6 +79,7 @@ data class LongPressable( uploader = null, uploaderUrl = null, viewCount = null, + streamType = null, uploadDate = null, decoration = Decoration.Playlist(item.streamCount), ) @@ -88,6 +92,7 @@ data class LongPressable( uploader = item.uploader, uploaderUrl = null, viewCount = null, + streamType = null, uploadDate = null, decoration = Decoration.Playlist(item.streamCount), ) @@ -100,6 +105,7 @@ data class LongPressable( uploader = null, uploaderUrl = item.url?.takeIf { it.isNotBlank() }, viewCount = null, + streamType = null, uploadDate = null, decoration = null, ) @@ -112,6 +118,7 @@ data class LongPressable( uploader = item.uploaderName.takeIf { it.isNotBlank() }, uploaderUrl = item.uploaderUrl?.takeIf { it.isNotBlank() }, viewCount = null, + streamType = null, uploadDate = null, decoration = Decoration.Playlist(item.streamCount), ) diff --git a/app/src/main/java/org/schabi/newpipe/util/Localization.java b/app/src/main/java/org/schabi/newpipe/util/Localization.java index f5bcc40d3ea..f44157ad404 100644 --- a/app/src/main/java/org/schabi/newpipe/util/Localization.java +++ b/app/src/main/java/org/schabi/newpipe/util/Localization.java @@ -31,6 +31,7 @@ import org.schabi.newpipe.extractor.localization.DateWrapper; import org.schabi.newpipe.extractor.stream.AudioStream; import org.schabi.newpipe.extractor.stream.AudioTrackType; +import org.schabi.newpipe.extractor.stream.StreamType; import java.math.BigDecimal; import java.math.RoundingMode; @@ -183,9 +184,50 @@ public static String localizeUploadDate(@NonNull final Context context, return context.getString(R.string.upload_date_text, formatDate(offsetDateTime)); } - public static String localizeViewCount(@NonNull final Context context, final long viewCount) { + /** + * Localizes the number of views of a stream reported by the service, + * with different words based on the stream type. + * + * @param context the Android context + * @param shortForm whether the number of views should be formatted in a short approximated form + * @param streamType influences the accompanying text, i.e. views/watching/listening + * @param viewCount the number of views reported by the service to localize + * @return the formatted and localized view count + */ + public static String localizeViewCount(@NonNull final Context context, + final boolean shortForm, + @Nullable final StreamType streamType, + final long viewCount) { + final String localizedNumber; + if (shortForm) { + localizedNumber = shortCount(context, viewCount); + } else { + localizedNumber = localizeNumber(viewCount); + } + + if (streamType == StreamType.AUDIO_LIVE_STREAM) { + return getQuantity(context, R.plurals.listening, R.string.no_one_listening, viewCount, + localizedNumber); + } else if (streamType == StreamType.LIVE_STREAM) { + return getQuantity(context, R.plurals.watching, R.string.no_one_watching, viewCount, + localizedNumber); + } else { + return getQuantity(context, R.plurals.views, R.string.no_views, viewCount, + localizedNumber); + } + } + + /** + * Localizes the number of times the user watched a video that they have in the history. + * + * @param context the Android context + * @param viewCount the number of times (stored in the database) the user watched a video + * @return the formatted and localized watch count + */ + public static String localizeWatchCount(@NonNull final Context context, + final long viewCount) { return getQuantity(context, R.plurals.views, R.string.no_views, viewCount, - localizeNumber(viewCount)); + shortCount(context, viewCount)); } public static String localizeStreamCount(@NonNull final Context context, @@ -217,12 +259,6 @@ public static String localizeStreamCountMini(@NonNull final Context context, } } - public static String localizeWatchingCount(@NonNull final Context context, - final long watchingCount) { - return getQuantity(context, R.plurals.watching, R.string.no_one_watching, watchingCount, - localizeNumber(watchingCount)); - } - public static String shortCount(@NonNull final Context context, final long count) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { return CompactDecimalFormat.getInstance(getAppLocale(), @@ -250,22 +286,6 @@ public static String shortCount(@NonNull final Context context, final long count } } - public static String listeningCount(@NonNull final Context context, final long listeningCount) { - return getQuantity(context, R.plurals.listening, R.string.no_one_listening, listeningCount, - shortCount(context, listeningCount)); - } - - public static String shortWatchingCount(@NonNull final Context context, - final long watchingCount) { - return getQuantity(context, R.plurals.watching, R.string.no_one_watching, watchingCount, - shortCount(context, watchingCount)); - } - - public static String shortViewCount(@NonNull final Context context, final long viewCount) { - return getQuantity(context, R.plurals.views, R.string.no_views, viewCount, - shortCount(context, viewCount)); - } - public static String shortSubscriberCount(@NonNull final Context context, final long subscriberCount) { return getQuantity(context, R.plurals.subscribers, R.string.no_subscribers, subscriberCount, From fb7afa19beac97e0e0af9cbf8802022d0df055c6 Mon Sep 17 00:00:00 2001 From: Stypox Date: Thu, 28 Aug 2025 18:52:46 +0200 Subject: [PATCH 28/41] Implement background/popup/play from here --- .../fragments/list/BaseListFragment.java | 3 +- .../list/playlist/PlaylistFragment.java | 3 +- .../schabi/newpipe/local/feed/FeedFragment.kt | 3 +- .../history/StatisticsPlaylistFragment.java | 2 +- .../local/playlist/LocalPlaylistFragment.java | 2 +- .../components/items/stream/StreamListItem.kt | 3 +- .../ui/components/menu/LongPressAction.kt | 30 ++++++++++++++++--- 7 files changed, 35 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListFragment.java index bafe1d554e2..93dc211122d 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListFragment.java @@ -329,7 +329,8 @@ protected void showInfoItemDialog(final StreamInfoItem item) { openLongPressMenuInActivity( requireActivity(), LongPressable.fromStreamInfoItem(item), - LongPressAction.fromStreamInfoItem(item) + // TODO generalize obtaining queue from here when fully migrating to Compose + LongPressAction.fromStreamInfoItem(item, null) ); } 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 c3755ab654c..634e2520a47 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 @@ -153,8 +153,7 @@ protected void showInfoItemDialog(final StreamInfoItem item) { openLongPressMenuInActivity( activity, LongPressable.fromStreamInfoItem(item), - // TODO handle play queue starting at - LongPressAction.fromStreamInfoItem(item) + LongPressAction.fromStreamInfoItem(item, () -> getPlayQueueStartingAt(item)) ); } diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/FeedFragment.kt b/app/src/main/java/org/schabi/newpipe/local/feed/FeedFragment.kt index 6b30d14a928..6c520b9638c 100644 --- a/app/src/main/java/org/schabi/newpipe/local/feed/FeedFragment.kt +++ b/app/src/main/java/org/schabi/newpipe/local/feed/FeedFragment.kt @@ -395,7 +395,8 @@ class FeedFragment : BaseStateFragment() { openLongPressMenuInActivity( requireActivity(), LongPressable.fromStreamEntity(item.streamWithState.stream), - LongPressAction.fromStreamEntity(item.streamWithState.stream), + // TODO queueFromHere: allow playing the whole feed starting from one stream + LongPressAction.fromStreamEntity(item.streamWithState.stream, null), ) return true } diff --git a/app/src/main/java/org/schabi/newpipe/local/history/StatisticsPlaylistFragment.java b/app/src/main/java/org/schabi/newpipe/local/history/StatisticsPlaylistFragment.java index de7865263ef..af4e8d1088c 100644 --- a/app/src/main/java/org/schabi/newpipe/local/history/StatisticsPlaylistFragment.java +++ b/app/src/main/java/org/schabi/newpipe/local/history/StatisticsPlaylistFragment.java @@ -319,7 +319,7 @@ private void showInfoItemDialog(final StreamStatisticsEntry item) { openLongPressMenuInActivity( requireActivity(), LongPressable.fromStreamEntity(item.getStreamEntity()), - LongPressAction.fromStreamStatisticsEntry(item) + LongPressAction.fromStreamStatisticsEntry(item, () -> getPlayQueueStartingAt(item)) ); } diff --git a/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistFragment.java b/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistFragment.java index d36c819d070..6b7da26a9aa 100644 --- a/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistFragment.java +++ b/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistFragment.java @@ -801,9 +801,9 @@ protected void showInfoItemDialog(final PlaylistStreamEntry item) { openLongPressMenuInActivity( requireActivity(), LongPressable.fromStreamEntity(item.getStreamEntity()), - // TODO getPlayQueueStartingAt(), resumePlayback=true LongPressAction.fromPlaylistStreamEntry( item, + () -> getPlayQueueStartingAt(item), () -> deleteItem(item), () -> changeThumbnailStreamId(item.getStreamEntity().getUid(), true) ) diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/items/stream/StreamListItem.kt b/app/src/main/java/org/schabi/newpipe/ui/components/items/stream/StreamListItem.kt index f4fbfb716db..3078a4aff12 100644 --- a/app/src/main/java/org/schabi/newpipe/ui/components/items/stream/StreamListItem.kt +++ b/app/src/main/java/org/schabi/newpipe/ui/components/items/stream/StreamListItem.kt @@ -79,7 +79,8 @@ fun StreamListItem( if (showLongPressMenu) { LongPressMenu( longPressable = LongPressable.fromStreamInfoItem(stream), - longPressActions = LongPressAction.fromStreamInfoItem(stream), + // TODO queueFromHere: allow playing the whole list starting from one stream + longPressActions = LongPressAction.fromStreamInfoItem(stream, null), onDismissRequest = { showLongPressMenu = false }, ) } 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 53cf43c9f5c..85fd9ff7960 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 @@ -108,6 +108,20 @@ data class LongPressAction( ) } + private fun buildPlayerFromHereActionList(queueFromHere: () -> PlayQueue): List { + return listOf( + Type.BackgroundFromHere.buildAction { context -> + NavigationHelper.playOnBackgroundPlayer(context, queueFromHere(), true) + }, + Type.PopupFromHere.buildAction { context -> + NavigationHelper.playOnPopupPlayer(context, queueFromHere(), true) + }, + Type.PlayFromHere.buildAction { context -> + NavigationHelper.playOnMainPlayer(context, queueFromHere(), false) + }, + ) + } + private fun buildShareActionList(item: InfoItem): List { return listOf( Type.Share.buildAction { context -> @@ -130,13 +144,18 @@ data class LongPressAction( ) } + /** + * @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". + */ @JvmStatic fun fromStreamInfoItem( item: StreamInfoItem, + queueFromHere: (() -> PlayQueue)?, /* TODO isKodiEnabled: Boolean, */ - /* TODO wholeListQueue: (() -> PlayQueue)? */ ): List { return buildPlayerActionList { SinglePlayQueue(item) } + + (queueFromHere?.let { buildPlayerFromHereActionList(queueFromHere) } ?: listOf()) + buildShareActionList(item) + listOf( Type.Download.buildAction { context -> @@ -192,18 +211,20 @@ data class LongPressAction( @JvmStatic fun fromStreamEntity( item: StreamEntity, + queueFromHere: (() -> PlayQueue)?, ): 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) - return fromStreamInfoItem(item.toStreamInfoItem()) + return fromStreamInfoItem(item.toStreamInfoItem(), queueFromHere) } @JvmStatic fun fromStreamStatisticsEntry( item: StreamStatisticsEntry, + queueFromHere: (() -> PlayQueue)?, ): List { - return fromStreamEntity(item.streamEntity) + + return fromStreamEntity(item.streamEntity, queueFromHere) + listOf( Type.Delete.buildAction { context -> HistoryRecordManager(context) @@ -223,11 +244,12 @@ data class LongPressAction( @JvmStatic fun fromPlaylistStreamEntry( item: PlaylistStreamEntry, + queueFromHere: (() -> PlayQueue)?, // TODO possibly embed these two actions here onDelete: Runnable, onSetAsPlaylistThumbnail: Runnable, ): List { - return fromStreamEntity(item.streamEntity) + + return fromStreamEntity(item.streamEntity, queueFromHere) + listOf( Type.Delete.buildAction { onDelete.run() }, Type.SetAsPlaylistThumbnail.buildAction { onSetAsPlaylistThumbnail.run() } From 937c59403033658334ebf989393558e125e24fd6 Mon Sep 17 00:00:00 2001 From: Stypox Date: Thu, 28 Aug 2025 19:41:42 +0200 Subject: [PATCH 29/41] Implement "play from here" for feed fragment --- .../schabi/newpipe/local/feed/FeedFragment.kt | 33 ++++++++++++++++--- .../newpipe/local/feed/item/StreamItem.kt | 2 +- 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/FeedFragment.kt b/app/src/main/java/org/schabi/newpipe/local/feed/FeedFragment.kt index 6c520b9638c..2eea8b56d5e 100644 --- a/app/src/main/java/org/schabi/newpipe/local/feed/FeedFragment.kt +++ b/app/src/main/java/org/schabi/newpipe/local/feed/FeedFragment.kt @@ -71,6 +71,7 @@ import org.schabi.newpipe.ktx.slideUp import org.schabi.newpipe.local.feed.item.StreamItem import org.schabi.newpipe.local.feed.service.FeedLoadService import org.schabi.newpipe.local.subscription.SubscriptionManager +import org.schabi.newpipe.player.playqueue.SinglePlayQueue import org.schabi.newpipe.ui.components.menu.LongPressAction import org.schabi.newpipe.ui.components.menu.LongPressable import org.schabi.newpipe.ui.components.menu.openLongPressMenuInActivity @@ -382,7 +383,7 @@ class FeedFragment : BaseStateFragment() { private val listenerStreamItem = object : OnItemClickListener, OnItemLongClickListener { override fun onItemClick(item: Item<*>, view: View) { if (item is StreamItem && !isRefreshing) { - val stream = item.streamWithState.stream + val stream = item.stream NavigationHelper.openVideoDetailFragment( requireContext(), fm, stream.serviceId, stream.url, stream.title, null, false @@ -394,9 +395,31 @@ class FeedFragment : BaseStateFragment() { if (item is StreamItem && !isRefreshing) { openLongPressMenuInActivity( requireActivity(), - LongPressable.fromStreamEntity(item.streamWithState.stream), - // TODO queueFromHere: allow playing the whole feed starting from one stream - LongPressAction.fromStreamEntity(item.streamWithState.stream, null), + LongPressable.fromStreamEntity(item.stream), + LongPressAction.fromStreamEntity( + item = item.stream, + queueFromHere = { + val items = (viewModel.stateLiveData.value as? FeedState.LoadedState) + ?.items + + if (items != null) { + val index = items.indexOf(item) + if (index >= 0) { + return@fromStreamEntity SinglePlayQueue( + items.map { it.stream.toStreamInfoItem() }, + index + ) + } + } + + // when long-pressing on an item the state should be LoadedState and the + // item list should contain the long-pressed item, so the following + // statement should be unreachable, but let's return a SinglePlayQueue + // just in case + Log.w(TAG, "Could not get full list of items on long press") + return@fromStreamEntity SinglePlayQueue(item.stream.toStreamInfoItem()) + }, + ), ) return true } @@ -566,7 +589,7 @@ class FeedFragment : BaseStateFragment() { } if (doCheck) { // If the uploadDate is null or true we should highlight the item - if (item.streamWithState.stream.uploadDate?.isAfter(updateTime) != false) { + if (item.stream.uploadDate?.isAfter(updateTime) != false) { highlightCount++ typeface = Typeface.DEFAULT_BOLD diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/item/StreamItem.kt b/app/src/main/java/org/schabi/newpipe/local/feed/item/StreamItem.kt index 6c23408241b..8be1659cace 100644 --- a/app/src/main/java/org/schabi/newpipe/local/feed/item/StreamItem.kt +++ b/app/src/main/java/org/schabi/newpipe/local/feed/item/StreamItem.kt @@ -30,7 +30,7 @@ data class StreamItem( const val UPDATE_RELATIVE_TIME = 1 } - private val stream: StreamEntity = streamWithState.stream + val stream: StreamEntity = streamWithState.stream private val stateProgressTime: Long? = streamWithState.stateProgressMillis /** From 19042381c8a6690dc8c15cd41497d24b3606e33a Mon Sep 17 00:00:00 2001 From: Stypox Date: Thu, 28 Aug 2025 19:56:18 +0200 Subject: [PATCH 30/41] Implement "play from here" for channels --- .../list/channel/ChannelTabFragment.java | 26 +++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelTabFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelTabFragment.java index feb23b6ac9f..855b289073c 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelTabFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelTabFragment.java @@ -1,5 +1,7 @@ package org.schabi.newpipe.fragments.list.channel; +import static org.schabi.newpipe.ui.components.menu.LongPressMenuKt.openLongPressMenuInActivity; + import android.os.Bundle; import android.util.Log; import android.view.LayoutInflater; @@ -26,12 +28,15 @@ import org.schabi.newpipe.fragments.list.playlist.PlaylistControlViewHolder; import org.schabi.newpipe.player.playqueue.ChannelTabPlayQueue; import org.schabi.newpipe.player.playqueue.PlayQueue; +import org.schabi.newpipe.ui.components.menu.LongPressAction; +import org.schabi.newpipe.ui.components.menu.LongPressable; import org.schabi.newpipe.ui.emptystate.EmptyStateUtil; import org.schabi.newpipe.util.ChannelTabHelper; import org.schabi.newpipe.util.ExtractorHelper; import org.schabi.newpipe.util.PlayButtonHelper; import java.util.List; +import java.util.function.Function; import java.util.function.Supplier; import java.util.stream.Collectors; @@ -165,13 +170,30 @@ public void handleResult(@NonNull final ChannelTabInfo result) { } @Override - public PlayQueue getPlayQueue() { + protected void showInfoItemDialog(final StreamInfoItem item) { + openLongPressMenuInActivity( + requireActivity(), + LongPressable.fromStreamInfoItem(item), + LongPressAction.fromStreamInfoItem(item, () -> getPlayQueueStartingAt(item)) + ); + } + + private PlayQueue getPlayQueueStartingAt(final StreamInfoItem infoItem) { + return getPlayQueue(streamItems -> Math.max(streamItems.indexOf(infoItem), 0)); + } + + public PlayQueue getPlayQueue(final Function, Integer> index) { final List streamItems = infoListAdapter.getItemsList().stream() .filter(StreamInfoItem.class::isInstance) .map(StreamInfoItem.class::cast) .collect(Collectors.toList()); return new ChannelTabPlayQueue(currentInfo.getServiceId(), tabHandler, - currentInfo.getNextPage(), streamItems, 0); + currentInfo.getNextPage(), streamItems, index.apply(streamItems)); + } + + @Override + public PlayQueue getPlayQueue() { + return getPlayQueue(streamItems -> 0); } } From 476b5f1f8ac601e3e5953b09481fe2d72da110a6 Mon Sep 17 00:00:00 2001 From: Stypox Date: Fri, 29 Aug 2025 13:00:19 +0200 Subject: [PATCH 31/41] Improve icons for background/popup/play from here TODO: remove the commented out alternatives --- .../menu/icons/BackgroundFromHere.kt | 125 +++++++++++++--- .../ui/components/menu/icons/PlayFromHere.kt | 83 ++++++++++- .../ui/components/menu/icons/PopupFromHere.kt | 133 +++++++++++++++--- 3 files changed, 291 insertions(+), 50 deletions(-) diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/menu/icons/BackgroundFromHere.kt b/app/src/main/java/org/schabi/newpipe/ui/components/menu/icons/BackgroundFromHere.kt index fd5972fcc27..573aa445c13 100644 --- a/app/src/main/java/org/schabi/newpipe/ui/components/menu/icons/BackgroundFromHere.kt +++ b/app/src/main/java/org/schabi/newpipe/ui/components/menu/icons/BackgroundFromHere.kt @@ -13,33 +13,114 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +/* + materialPath { + moveTo(12.0f, 4.0f) + lineToRelative(-1.41f, 1.41f) + lineToRelative(5.59f, 5.59f) + horizontalLineToRelative(-12.17f) + verticalLineToRelative(2.0f) + horizontalLineToRelative(12.17f) + lineToRelative(-5.59f, 5.59f) + lineToRelative(1.41f, 1.41f) + lineToRelative(8.0f, -8.0f) + close() + } + */ + /** - * Obtained by combining Filled.Headset and Filled.PlaylistPlay + * Obtained by combining [androidx.compose.material.icons.filled.Headset] + * and the tiny arrow in [androidx.compose.material.icons.filled.ContentPasteGo]. */ val Icons.Filled.BackgroundFromHere: ImageVector by lazy { - materialIcon(name = "Filled.HeadsetPlus") { + materialIcon(name = "Filled.BackgroundFromHere") { materialPath { - moveTo(7.200f, 0.000f) - curveToRelative(-3.976f, 0.000f, -7.200f, 3.224f, -7.200f, 7.200f) - verticalLineToRelative(5.600f) - curveToRelative(0.000f, 1.328f, 1.072f, 2.400f, 2.400f, 2.400f) - horizontalLineToRelative(2.400f) - verticalLineToRelative(-6.400f) - horizontalLineTo(1.600f) - verticalLineToRelative(-1.600f) - curveToRelative(0.000f, -3.096f, 2.504f, -5.600f, 5.600f, -5.600f) - reflectiveCurveToRelative(5.600f, 2.504f, 5.600f, 5.600f) - verticalLineToRelative(1.600f) - horizontalLineToRelative(-3.200f) - verticalLineToRelative(6.400f) - horizontalLineToRelative(2.400f) - curveToRelative(1.328f, 0.000f, 2.400f, -1.072f, 2.400f, -2.400f) - verticalLineToRelative(-5.600f) - curveToRelative(0.000f, -3.976f, -3.224f, -7.200f, -7.200f, -7.200f) + moveTo(12.0f, 1.0f) + curveToRelative(-4.97f, 0.0f, -9.0f, 4.03f, -9.0f, 9.0f) + verticalLineToRelative(7.0f) + curveToRelative(0.0f, 1.66f, 1.34f, 3.0f, 3.0f, 3.0f) + horizontalLineToRelative(3.0f) + verticalLineToRelative(-8.0f) + horizontalLineTo(5.0f) + verticalLineToRelative(-2.0f) + curveToRelative(0.0f, -3.87f, 3.13f, -7.0f, 7.0f, -7.0f) + reflectiveCurveToRelative(7.0f, 3.13f, 7.0f, 7.0f) + horizontalLineToRelative(2.0f) + curveToRelative(0.0f, -4.97f, -4.03f, -9.0f, -9.0f, -9.0f) close() } materialPath { - moveTo(15.817f, 16.202f) + moveTo(19f, 11.5f) + lineToRelative(-1.42f, 1.41f) + lineToRelative(1.58f, 1.58f) + lineToRelative(-6.17f, 0.0f) + lineToRelative(0.0f, 2.0f) + lineToRelative(6.17f, 0.0f) + lineToRelative(-1.58f, 1.59f) + lineToRelative(1.42f, 1.41f) + lineToRelative(3.99f, -4.0f) + close() + } + /*materialPath { + moveTo(18.6f, 11.00f) + lineToRelative(-1.064f, 1.064f) + lineToRelative(2.586f, 2.586f) + horizontalLineToRelative(-5.622f) + verticalLineToRelative(-2.086f) + horizontalLineToRelative(-1.5f) + verticalLineToRelative(5.672f) + horizontalLineToRelative(1.5f) + verticalLineToRelative(-2.086f) + horizontalLineToRelative(5.622f) + lineToRelative(-2.586f, 2.586f) + lineToRelative(1.064f, 1.064f) + lineToRelative(4.400f, -4.400f) + close() + }*/ + /*materialPath { + moveTo(18.6f, 11.00f) + lineToRelative(-1.064f, 1.064f) + lineToRelative(3.336f, 3.336f) + lineToRelative(-3.336f, 3.336f) + lineToRelative(1.064f, 1.064f) + lineToRelative(4.400f, -4.400f) + close() + } + materialPath { + moveTo(14f, 11.00f) + lineToRelative(-1.064f, 1.064f) + lineToRelative(3.336f, 3.336f) + lineToRelative(-3.336f, 3.336f) + lineToRelative(1.064f, 1.064f) + lineToRelative(4.400f, -4.400f) + close() + }*/ + /*materialPath { + moveTo(18.6f, 11.00f) + lineToRelative(-1.064f, 1.064f) + lineToRelative(2.586f, 2.586f) + horizontalLineToRelative(-7.122f) + verticalLineToRelative(1.500f) + horizontalLineToRelative(7.122f) + lineToRelative(-2.586f, 2.586f) + lineToRelative(1.064f, 1.064f) + lineToRelative(4.400f, -4.400f) + close() + }*/ + /*materialPath { + moveTo(17.200f, 11.200f) + lineToRelative(-0.775f, 0.775f) + lineToRelative(3.075f, 3.075f) + horizontalLineToRelative(-6.694f) + verticalLineToRelative(1.100f) + horizontalLineToRelative(6.694f) + lineToRelative(-3.075f, 3.075f) + lineToRelative(0.775f, 0.775f) + lineToRelative(4.400f, -4.400f) + close() + }*/ + /*materialPath { + moveTo(12.817f, 12.202f) lineToRelative(-0.916f, 0.916f) lineToRelative(2.977f, 2.983f) lineToRelative(-2.977f, 2.983f) @@ -48,14 +129,14 @@ val Icons.Filled.BackgroundFromHere: ImageVector by lazy { close() } materialPath { - moveTo(20.100f, 16.202f) + moveTo(17.100f, 12.202f) lineToRelative(-0.916f, 0.916f) lineToRelative(2.977f, 2.983f) lineToRelative(-2.977f, 2.983f) lineToRelative(0.916f, 0.916f) lineToRelative(3.900f, -3.900f) close() - } + }*/ } } diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/menu/icons/PlayFromHere.kt b/app/src/main/java/org/schabi/newpipe/ui/components/menu/icons/PlayFromHere.kt index 160317f4a8f..0af14bbe333 100644 --- a/app/src/main/java/org/schabi/newpipe/ui/components/menu/icons/PlayFromHere.kt +++ b/app/src/main/java/org/schabi/newpipe/ui/components/menu/icons/PlayFromHere.kt @@ -14,17 +14,88 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp /** - * Obtained by combining Filled.PlayArrow and Filled.PlaylistPlay + * Obtained by combining [androidx.compose.material.icons.filled.PlayArrow] + * and the tiny arrow in [androidx.compose.material.icons.filled.ContentPasteGo]. */ val Icons.Filled.PlayFromHere: ImageVector by lazy { - materialIcon(name = "Filled.HeadsetPlus") { + materialIcon(name = "Filled.PlayFromHere") { materialPath { - moveTo(5.000f, 3.000f) - verticalLineToRelative(11.200f) - lineToRelative(8.800f, -5.600f) + moveTo(2.5f, 2.5f) + verticalLineToRelative(14.0f) + lineToRelative(11.0f, -7.0f) close() } materialPath { + moveTo(19f, 11.5f) + lineToRelative(-1.42f, 1.41f) + lineToRelative(1.58f, 1.58f) + lineToRelative(-6.17f, 0.0f) + lineToRelative(0.0f, 2.0f) + lineToRelative(6.17f, 0.0f) + lineToRelative(-1.58f, 1.59f) + lineToRelative(1.42f, 1.41f) + lineToRelative(3.99f, -4.0f) + close() + } + /*materialPath { + moveTo(18.6f, 11.00f) + lineToRelative(-1.064f, 1.064f) + lineToRelative(2.586f, 2.586f) + horizontalLineToRelative(-5.622f) + verticalLineToRelative(-2.086f) + horizontalLineToRelative(-1.5f) + verticalLineToRelative(5.672f) + horizontalLineToRelative(1.5f) + verticalLineToRelative(-2.086f) + horizontalLineToRelative(5.622f) + lineToRelative(-2.586f, 2.586f) + lineToRelative(1.064f, 1.064f) + lineToRelative(4.400f, -4.400f) + close() + }*/ + /*materialPath { + moveTo(18.6f, 11.00f) + lineToRelative(-1.064f, 1.064f) + lineToRelative(3.336f, 3.336f) + lineToRelative(-3.336f, 3.336f) + lineToRelative(1.064f, 1.064f) + lineToRelative(4.400f, -4.400f) + close() + } + materialPath { + moveTo(14f, 11.00f) + lineToRelative(-1.064f, 1.064f) + lineToRelative(3.336f, 3.336f) + lineToRelative(-3.336f, 3.336f) + lineToRelative(1.064f, 1.064f) + lineToRelative(4.400f, -4.400f) + close() + }*/ + /*materialPath { + moveTo(18.6f, 11.00f) + lineToRelative(-1.064f, 1.064f) + lineToRelative(2.586f, 2.586f) + horizontalLineToRelative(-7.122f) + verticalLineToRelative(1.500f) + horizontalLineToRelative(7.122f) + lineToRelative(-2.586f, 2.586f) + lineToRelative(1.064f, 1.064f) + lineToRelative(4.400f, -4.400f) + close() + }*/ + /*materialPath { + moveTo(17.200f, 11.200f) + lineToRelative(-0.775f, 0.775f) + lineToRelative(3.075f, 3.075f) + horizontalLineToRelative(-6.694f) + verticalLineToRelative(1.100f) + horizontalLineToRelative(6.694f) + lineToRelative(-3.075f, 3.075f) + lineToRelative(0.775f, 0.775f) + lineToRelative(4.400f, -4.400f) + close() + }*/ + /*materialPath { moveTo(15.817f, 16.202f) lineToRelative(-0.916f, 0.916f) lineToRelative(2.977f, 2.983f) @@ -41,7 +112,7 @@ val Icons.Filled.PlayFromHere: ImageVector by lazy { lineToRelative(0.916f, 0.916f) lineToRelative(3.900f, -3.900f) close() - } + }*/ } } diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/menu/icons/PopupFromHere.kt b/app/src/main/java/org/schabi/newpipe/ui/components/menu/icons/PopupFromHere.kt index 47159386839..b33648a96ac 100644 --- a/app/src/main/java/org/schabi/newpipe/ui/components/menu/icons/PopupFromHere.kt +++ b/app/src/main/java/org/schabi/newpipe/ui/components/menu/icons/PopupFromHere.kt @@ -14,34 +14,123 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp /** - * Obtained by combining Filled.PictureInPicture and Filled.PlaylistPlay + * Obtained by combining [androidx.compose.material.icons.filled.PictureInPicture] + * and the tiny arrow in [androidx.compose.material.icons.filled.ContentPasteGo]. */ val Icons.Filled.PopupFromHere: ImageVector by lazy { - materialIcon(name = "Filled.HeadsetPlus") { + materialIcon(name = "Filled.PopupFromHere") { materialPath { - moveTo(14.320f, 3.200f) - horizontalLineToRelative(-6.400f) - verticalLineToRelative(4.800f) - horizontalLineToRelative(6.400f) - lineTo(14.320f, 3.200f) + moveTo(19.0f, 5.0f) + horizontalLineToRelative(-8.0f) + verticalLineToRelative(5.0f) + horizontalLineToRelative(8.0f) + verticalLineToRelative(-5.0f) close() - moveTo(15.920f, 0.000f) - lineTo(1.520f, 0.000f) - curveToRelative(-0.880f, 0.000f, -1.600f, 0.720f, -1.600f, 1.600f) - verticalLineToRelative(11.200f) - curveToRelative(0.000f, 0.880f, 0.720f, 1.584f, 1.600f, 1.584f) - horizontalLineToRelative(14.400f) - curveToRelative(0.880f, 0.000f, 1.600f, -0.704f, 1.600f, -1.584f) - lineTo(17.520f, 1.600f) - curveToRelative(0.000f, -0.880f, -0.720f, -1.600f, -1.600f, -1.600f) + moveTo(21.0f, 1.0f) + horizontalLineToRelative(-18.0f) + curveToRelative(-1.1f, 0.0f, -2.0f, 0.9f, -2.0f, 2.0f) + verticalLineToRelative(14.0f) + curveToRelative(0.0f, 1.1f, 0.9f, 2.0f, 2.0f, 2.0f) + horizontalLineToRelative(8.5f) + verticalLineToRelative(-2.0f) + horizontalLineToRelative(-8.5f) + verticalLineToRelative(-14.0f) + horizontalLineToRelative(18.0f) + verticalLineToRelative(7.0f) + horizontalLineToRelative(2.0f) + verticalLineToRelative(-7.0f) + curveToRelative(0.0f, -1.1f, -0.9f, -2.0f, -2.0f, -2.0f) close() - moveTo(15.920f, 12.808f) - lineTo(1.520f, 12.808f) - lineTo(1.520f, 1.584f) - horizontalLineToRelative(14.400f) - verticalLineToRelative(11.224f) + /*moveTo(21.0f, 17.01f) + horizontalLineToRelative(-18.0f) + verticalLineToRelative(-14.03f) + horizontalLineToRelative(18.0f) + verticalLineToRelative(14.03f) + close()*/ + } + materialPath { + moveTo(19f, 11.5f) + lineToRelative(-1.42f, 1.41f) + lineToRelative(1.58f, 1.58f) + lineToRelative(-6.17f, 0.0f) + lineToRelative(0.0f, 2.0f) + lineToRelative(6.17f, 0.0f) + lineToRelative(-1.58f, 1.59f) + lineToRelative(1.42f, 1.41f) + lineToRelative(3.99f, -4.0f) + close() + } + /*materialPath { + moveTo(18.6f, 11.00f) + lineToRelative(-1.064f, 1.064f) + lineToRelative(2.586f, 2.586f) + horizontalLineToRelative(-5.622f) + verticalLineToRelative(-2.086f) + horizontalLineToRelative(-1.5f) + verticalLineToRelative(5.672f) + horizontalLineToRelative(1.5f) + verticalLineToRelative(-2.086f) + horizontalLineToRelative(5.622f) + lineToRelative(-2.586f, 2.586f) + lineToRelative(1.064f, 1.064f) + lineToRelative(4.400f, -4.400f) + close() + }*/ + /*materialPath { + moveTo(18.6f, 11.00f) + lineToRelative(-1.064f, 1.064f) + lineToRelative(3.336f, 3.336f) + lineToRelative(-3.336f, 3.336f) + lineToRelative(1.064f, 1.064f) + lineToRelative(4.400f, -4.400f) close() } + materialPath { + moveTo(14f, 11.00f) + lineToRelative(-1.064f, 1.064f) + lineToRelative(3.336f, 3.336f) + lineToRelative(-3.336f, 3.336f) + lineToRelative(1.064f, 1.064f) + lineToRelative(4.400f, -4.400f) + close() + }*/ + /*materialPath { QUESTO È PERFETTO + moveTo(18.6f, 11.00f) + lineToRelative(-1.064f, 1.064f) + lineToRelative(2.586f, 2.586f) + horizontalLineToRelative(-7.122f) + verticalLineToRelative(1.500f) + horizontalLineToRelative(7.122f) + lineToRelative(-2.586f, 2.586f) + lineToRelative(1.064f, 1.064f) + lineToRelative(4.400f, -4.400f) + close() + }*/ + /*materialPath { + moveTo(18.600f, 11.200f) + lineToRelative(-0.775f, 0.775f) + lineToRelative(3.075f, 3.075f) + horizontalLineToRelative(-6.694f) + verticalLineToRelative(1.100f) + horizontalLineToRelative(6.694f) + lineToRelative(-3.075f, 3.075f) + lineToRelative(0.775f, 0.775f) + lineToRelative(4.400f, -4.400f) + close() + }*/ + /*materialPath { + moveTo(18.600f, 11.200f) + lineToRelative(-1.41f, 1.41f) + lineToRelative(1.99f, 1.99f) + horizontalLineToRelative(-6f) + verticalLineToRelative(2.00f) + horizontalLineToRelative(6f) + lineToRelative(-1.99f, 1.99f) + lineToRelative(1.41f, 1.41f) + lineToRelative(4.400f, -4.400f) + close() + }*/ + /* materialPath { moveTo(15.817f, 16.202f) lineToRelative(-0.916f, 0.916f) @@ -59,7 +148,7 @@ val Icons.Filled.PopupFromHere: ImageVector by lazy { lineToRelative(0.916f, 0.916f) lineToRelative(3.900f, -3.900f) close() - } + }*/ } } From 37b4d8a1a942ac2a1947f1be8f47ba53a282ce11 Mon Sep 17 00:00:00 2001 From: Stypox Date: Fri, 29 Aug 2025 13:26:54 +0200 Subject: [PATCH 32/41] Implement long pressing on subscriptions --- .../fragments/list/BaseListFragment.java | 2 +- .../subscription/SubscriptionFragment.kt | 38 +++++-------------- .../ui/components/menu/LongPressAction.kt | 15 +++++++- 3 files changed, 24 insertions(+), 31 deletions(-) diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListFragment.java index 93dc211122d..9aecd487d70 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListFragment.java @@ -290,7 +290,7 @@ public void held(final ChannelInfoItem selectedItem) { openLongPressMenuInActivity( requireActivity(), LongPressable.fromChannelInfoItem(selectedItem), - LongPressAction.fromChannelInfoItem(selectedItem) + LongPressAction.fromChannelInfoItem(selectedItem, null) ); } }); diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionFragment.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionFragment.kt index 8c5d05394d1..fff67074b64 100644 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionFragment.kt +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionFragment.kt @@ -2,7 +2,6 @@ package org.schabi.newpipe.local.subscription import android.app.Activity import android.content.Context -import android.content.DialogInterface import android.os.Bundle import android.os.Parcelable import android.view.LayoutInflater @@ -17,7 +16,6 @@ import android.widget.Toast import androidx.activity.result.ActivityResult import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult import androidx.annotation.StringRes -import androidx.appcompat.app.AlertDialog import androidx.lifecycle.ViewModelProvider import androidx.recyclerview.widget.GridLayoutManager import com.evernote.android.state.State @@ -28,7 +26,6 @@ import com.xwray.groupie.viewbinding.GroupieViewHolder import io.reactivex.rxjava3.disposables.CompositeDisposable import org.schabi.newpipe.R import org.schabi.newpipe.database.feed.model.FeedGroupEntity.Companion.GROUP_ALL_ID -import org.schabi.newpipe.databinding.DialogTitleBinding import org.schabi.newpipe.databinding.FeedItemCarouselBinding import org.schabi.newpipe.databinding.FragmentSubscriptionBinding import org.schabi.newpipe.error.ErrorInfo @@ -53,12 +50,14 @@ import org.schabi.newpipe.local.subscription.workers.SubscriptionExportWorker import org.schabi.newpipe.local.subscription.workers.SubscriptionImportInput import org.schabi.newpipe.streams.io.NoFileManagerSafeGuard import org.schabi.newpipe.streams.io.StoredFileHelper +import org.schabi.newpipe.ui.components.menu.LongPressAction +import org.schabi.newpipe.ui.components.menu.LongPressable +import org.schabi.newpipe.ui.components.menu.openLongPressMenuInActivity import org.schabi.newpipe.ui.emptystate.setEmptyStateComposable import org.schabi.newpipe.util.NavigationHelper import org.schabi.newpipe.util.OnClickGesture import org.schabi.newpipe.util.ServiceHelper import org.schabi.newpipe.util.ThemeHelper.getGridSpanCountChannels -import org.schabi.newpipe.util.external_communication.ShareUtils import java.text.SimpleDateFormat import java.util.Date import java.util.Locale @@ -329,31 +328,14 @@ class SubscriptionFragment : BaseStateFragment() { } private fun showLongTapDialog(selectedItem: ChannelInfoItem) { - val commands = arrayOf( - getString(R.string.share), - getString(R.string.open_in_browser), - getString(R.string.unsubscribe) + openLongPressMenuInActivity( + requireActivity(), + LongPressable.fromChannelInfoItem(selectedItem), + LongPressAction.fromChannelInfoItem( + item = selectedItem, + onUnsubscribe = { deleteChannel(selectedItem) } + ) ) - - val actions = DialogInterface.OnClickListener { _, i -> - when (i) { - 0 -> ShareUtils.shareText( - requireContext(), selectedItem.name, selectedItem.url, selectedItem.thumbnails - ) - 1 -> ShareUtils.openUrlInBrowser(requireContext(), selectedItem.url) - 2 -> deleteChannel(selectedItem) - } - } - - val dialogTitleBinding = DialogTitleBinding.inflate(LayoutInflater.from(requireContext())) - dialogTitleBinding.root.isSelected = true - dialogTitleBinding.itemTitleView.text = selectedItem.name - dialogTitleBinding.itemAdditionalDetails.visibility = View.GONE - - AlertDialog.Builder(requireContext()) - .setCustomTitle(dialogTitleBinding.root) - .setItems(commands, actions) - .show() } private fun deleteChannel(selectedItem: ChannelInfoItem) { 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 85fd9ff7960..e24d628e490 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 @@ -76,6 +76,7 @@ data class LongPressAction( Rename(R.string.rename, Icons.Default.Edit), SetAsPlaylistThumbnail(R.string.set_as_playlist_thumbnail, Icons.Default.Image), UnsetPlaylistThumbnail(R.string.unset_playlist_thumbnail, Icons.Default.HideImage), + Unsubscribe(R.string.unsubscribe, Icons.Default.Delete), ; // TODO allow actions to return disposables @@ -285,7 +286,10 @@ data class LongPressAction( } @JvmStatic - fun fromChannelInfoItem(item: ChannelInfoItem): List { + fun fromChannelInfoItem( + item: ChannelInfoItem, + onUnsubscribe: Runnable?, + ): List { return buildPlayerActionList { ChannelTabPlayQueue(item.serviceId, item.url) } + buildShareActionList(item) + listOf( @@ -297,7 +301,14 @@ data class LongPressAction( item.name, ) }, - ) + ) + + ( + onUnsubscribe + ?.let { onUnsubscribe -> + listOf(Type.Unsubscribe.buildAction { onUnsubscribe.run() }) + } + ?: listOf() + ) } @JvmStatic From 249e265af914d00ac39120a96fe5b6e6053ed19f Mon Sep 17 00:00:00 2001 From: Stypox Date: Sun, 19 Oct 2025 11:15:32 +0200 Subject: [PATCH 33/41] Consider duration 0 as duration not known --- .../java/org/schabi/newpipe/ui/components/menu/LongPressable.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 75881a65782..23911ef86ec 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 @@ -36,7 +36,7 @@ data class LongPressable( if (streamType == LIVE_STREAM || streamType == AUDIO_LIVE_STREAM) { Live } else { - duration.takeIf { it >= 0 }?.let { Duration(it) } + duration.takeIf { it > 0 }?.let { Duration(it) } } } } From ff13219b483fe41604e07c9e49f922b3fe1b83f0 Mon Sep 17 00:00:00 2001 From: Stypox Date: Sun, 19 Oct 2025 11:21:34 +0200 Subject: [PATCH 34/41] Address Isira review comment --- .../newpipe/ui/components/menu/LongPressAction.kt | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) 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 e24d628e490..3fc904b991f 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 @@ -292,7 +292,7 @@ data class LongPressAction( ): List { return buildPlayerActionList { ChannelTabPlayQueue(item.serviceId, item.url) } + buildShareActionList(item) + - listOf( + listOfNotNull( Type.ShowChannelDetails.buildAction { context -> NavigationHelper.openChannelFragment( context.findFragmentActivity().supportFragmentManager, @@ -301,14 +301,8 @@ data class LongPressAction( item.name, ) }, - ) + - ( - onUnsubscribe - ?.let { onUnsubscribe -> - listOf(Type.Unsubscribe.buildAction { onUnsubscribe.run() }) - } - ?: listOf() - ) + onUnsubscribe?.let { r -> Type.Unsubscribe.buildAction { r.run() } } + ) } @JvmStatic From b26ab27d66b3831052b2931abc46e338e6b0e008 Mon Sep 17 00:00:00 2001 From: Stypox Date: Tue, 21 Oct 2025 23:52:03 +0200 Subject: [PATCH 35/41] Extract FixedHeightCenteredText from LongPressMenu --- .../ui/components/menu/LongPressMenu.kt | 30 +++++--------- .../util/text/FixedHeightCenteredText.kt | 39 +++++++++++++++++++ 2 files changed, 49 insertions(+), 20 deletions(-) create mode 100644 app/src/main/java/org/schabi/newpipe/util/text/FixedHeightCenteredText.kt 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 1419031437f..e959ce615b1 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 @@ -62,7 +62,6 @@ import androidx.compose.ui.text.PlaceholderVerticalAlign import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.withStyle import androidx.compose.ui.tooling.preview.Preview @@ -80,6 +79,7 @@ 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.text.FixedHeightCenteredText import org.schabi.newpipe.util.text.fadedMarquee import java.time.OffsetDateTime @@ -112,6 +112,8 @@ fun getLongPressMenuView( } } +internal val MinButtonWidth = 86.dp + @Composable fun LongPressMenu( longPressable: LongPressable, @@ -130,10 +132,9 @@ fun LongPressMenu( .fillMaxWidth() .padding(start = 6.dp, end = 6.dp, bottom = 16.dp) ) { - val minButtonWidth = 86.dp - val buttonHeight = 86.dp + val buttonHeight = MinButtonWidth // landscape aspect ratio, square in the limit val headerWidthInButtons = 5 // the header is 5 times as wide as the buttons - val buttonsPerRow = (this.maxWidth / minButtonWidth).toInt() + val buttonsPerRow = (this.maxWidth / MinButtonWidth).toInt() // the channel icon goes in the menu header, so do not show a button for it val actions = longPressActions.toMutableList() @@ -498,22 +499,11 @@ fun LongPressMenuButton( contentDescription = null, modifier = Modifier.size(32.dp), ) - Box { - // this allows making the box always the same height (i.e. the height of two text - // lines), while making the text appear centered if it is just a single line - Text( - text = "", - style = MaterialTheme.typography.bodySmall, - minLines = 2, - ) - Text( - text = text, - style = MaterialTheme.typography.bodySmall, - maxLines = 2, - textAlign = TextAlign.Center, - modifier = Modifier.align(Alignment.Center) - ) - } + FixedHeightCenteredText( + text = text, + lines = 2, + style = MaterialTheme.typography.bodySmall, + ) } } } diff --git a/app/src/main/java/org/schabi/newpipe/util/text/FixedHeightCenteredText.kt b/app/src/main/java/org/schabi/newpipe/util/text/FixedHeightCenteredText.kt new file mode 100644 index 00000000000..57de2426968 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/text/FixedHeightCenteredText.kt @@ -0,0 +1,39 @@ +package org.schabi.newpipe.util.text + +import androidx.compose.foundation.layout.Box +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.TextAlign + +/** + * Like [Text] but with a fixed bounding box of [lines] text lines, and with text always centered + * within it even when its actual length uses less than [lines] lines. + */ +@Composable +fun FixedHeightCenteredText( + text: String, + lines: Int, + modifier: Modifier = Modifier, + style: TextStyle = LocalTextStyle.current, +) { + Box(modifier = modifier) { + // this allows making the box always the same height (i.e. the height of [lines] text + // lines), while making the text appear centered if it is just a single line + Text( + text = "", + style = style, + minLines = lines, + ) + Text( + text = text, + style = style, + maxLines = lines, + textAlign = TextAlign.Center, + modifier = Modifier.align(Alignment.Center) + ) + } +} From e2253264cf45439fc6e6debface5d28f22fce374 Mon Sep 17 00:00:00 2001 From: Stypox Date: Tue, 21 Oct 2025 23:53:15 +0200 Subject: [PATCH 36/41] Implement LongPressMenuEditor UI (still not persisted) --- .../schabi/newpipe/player/PlayerService.kt | 4 +- .../schabi/newpipe/ui/DetectDragModifier.kt | 50 +++ .../ui/components/menu/LongPressAction.kt | 14 +- .../ui/components/menu/LongPressMenuEditor.kt | 395 ++++++++++++++++++ app/src/main/res/values/strings.xml | 5 + gradle/libs.versions.toml | 2 +- 6 files changed, 465 insertions(+), 5 deletions(-) create mode 100644 app/src/main/java/org/schabi/newpipe/ui/DetectDragModifier.kt create mode 100644 app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressMenuEditor.kt diff --git a/app/src/main/java/org/schabi/newpipe/player/PlayerService.kt b/app/src/main/java/org/schabi/newpipe/player/PlayerService.kt index b8f07fd7142..abcc50b7ab4 100644 --- a/app/src/main/java/org/schabi/newpipe/player/PlayerService.kt +++ b/app/src/main/java/org/schabi/newpipe/player/PlayerService.kt @@ -148,8 +148,8 @@ class PlayerService : MediaBrowserServiceCompat() { // a (dummy) foreground notification, otherwise we'd incur in // "Context.startForegroundService() did not then call Service.startForeground()". Then // we stop the service again. - Log.d(TAG, "onStartCommand() got a useless intent, closing the service"); - NotificationUtil.startForegroundWithDummyNotification(this); + Log.d(TAG, "onStartCommand() got a useless intent, closing the service") + NotificationUtil.startForegroundWithDummyNotification(this) return START_NOT_STICKY } diff --git a/app/src/main/java/org/schabi/newpipe/ui/DetectDragModifier.kt b/app/src/main/java/org/schabi/newpipe/ui/DetectDragModifier.kt new file mode 100644 index 00000000000..813734c94d2 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/ui/DetectDragModifier.kt @@ -0,0 +1,50 @@ +package org.schabi.newpipe.ui + +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.pointerInput +import androidx.compose.ui.unit.IntOffset + +/** + * Detects a drag gesture **without** trying to filter out any misclicks. This is useful in menus + * where items are dragged around, where the usual misclick guardrails would cause unexpected lags + * or strange behaviors when dragging stuff around quickly. For other use cases, use + * [androidx.compose.foundation.gestures.detectDragGestures] or + * [androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress]. + * + * @param beginDragGesture called when the user first touches the screen (down event) with the + * pointer position, should return `true` if the receiver wants to handle this gesture, `false` + * otherwise. + * @param handleDragGestureChange called with the current pointer position, every time the user + * moves the finger after [beginDragGesture] has returned `true`. + * @param endDragGesture called when the drag gesture finishes after [beginDragGesture] has returned + * `true`. + */ +fun Modifier.detectDragGestures( + beginDragGesture: (IntOffset) -> Boolean, + handleDragGestureChange: (IntOffset) -> Unit, + endDragGesture: () -> Unit +): Modifier { + return this.pointerInput(Unit) { + awaitEachGesture { + val down = awaitFirstDown() + val pointerId = down.id + if (!beginDragGesture(down.position.toIntOffset())) { + return@awaitEachGesture + } + while (true) { + val change = awaitPointerEvent().changes.find { it.id == pointerId } + if (change == null || !change.pressed) { + break + } + handleDragGestureChange(change.position.toIntOffset()) + change.consume() + } + endDragGesture() + } + } +} + +private fun Offset.toIntOffset() = IntOffset(this.x.toInt(), this.y.toInt()) 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 3fc904b991f..45473d2324e 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 @@ -60,10 +60,10 @@ data class LongPressAction( 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), - BackgroundFromHere(R.string.background_from_here, Icons.Default.BackgroundFromHere), Popup(R.string.controls_popup_title, Icons.Default.PictureInPicture), - PopupFromHere(R.string.popup_from_here, Icons.Default.PopupFromHere), Play(R.string.play, Icons.Default.PlayArrow), + BackgroundFromHere(R.string.background_from_here, Icons.Default.BackgroundFromHere), + PopupFromHere(R.string.popup_from_here, Icons.Default.PopupFromHere), PlayFromHere(R.string.play_from_here, Icons.Default.PlayFromHere), PlayWithKodi(R.string.play_with_kodi_title, Icons.Default.Cast), Download(R.string.download, Icons.Default.Download), @@ -86,6 +86,16 @@ data class LongPressAction( enabled: (isPlayerRunning: Boolean) -> Boolean = { true }, action: (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: Array = arrayOf( + Enqueue, EnqueueNext, Background, Popup, BackgroundFromHere, Download, + AddToPlaylist, Share, OpenInBrowser, MarkAsWatched, Delete, + Rename, SetAsPlaylistThumbnail, UnsetPlaylistThumbnail, Unsubscribe + ) + } } companion object { diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressMenuEditor.kt b/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressMenuEditor.kt new file mode 100644 index 00000000000..a0a1cbf2399 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressMenuEditor.kt @@ -0,0 +1,395 @@ +/* + * Copyright (C) 2022-2025 The FlorisBoard Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:OptIn(ExperimentalMaterial3Api::class) + +package org.schabi.newpipe.ui.components.menu + +import androidx.annotation.StringRes +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyGridItemInfo +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.itemsIndexed +import androidx.compose.foundation.lazy.grid.rememberLazyGridState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArtTrack +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.DragHandle +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +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.runtime.toMutableStateList +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.toSize +import org.schabi.newpipe.R +import org.schabi.newpipe.ui.components.menu.LongPressAction.Type.Companion.DefaultEnabledActions +import org.schabi.newpipe.ui.detectDragGestures +import org.schabi.newpipe.ui.theme.AppTheme +import org.schabi.newpipe.util.text.FixedHeightCenteredText + +private const val ItemNotFound = -1 + +@Composable +fun LongPressMenuEditor() { + // We get the current arrangement once and do not observe on purpose + // TODO load from settings + var headerEnabled by remember { mutableStateOf(true) } + val actionArrangement = remember { DefaultEnabledActions } + val enabledActions = remember(actionArrangement) { + actionArrangement + .map { ActionOrMarker.Action(it) } + .ifEmpty { listOf(ActionOrMarker.NoneMarker) } + .toMutableStateList() + } + val hiddenActions = remember(actionArrangement) { + LongPressAction.Type.entries + .filter { !actionArrangement.contains(it) } + .map { ActionOrMarker.Action(it) } + .ifEmpty { listOf(ActionOrMarker.NoneMarker) } + .toMutableStateList() + } + + val gridState = rememberLazyGridState() + var activeDragAction by remember { mutableStateOf(null) } + var activeDragPosition by remember { mutableStateOf(IntOffset.Zero) } + var activeDragSize by remember { mutableStateOf(IntSize.Zero) } + + fun findItemForOffsetOrClosestInRow(offset: IntOffset): LazyGridItemInfo? { + var closestItemInRow: LazyGridItemInfo? = null + // Using manual for loop with indices instead of firstOrNull() because this method gets + // called a lot and firstOrNull allocates an iterator for each call + for (index in gridState.layoutInfo.visibleItemsInfo.indices) { + val item = gridState.layoutInfo.visibleItemsInfo[index] + if (offset.y in item.offset.y..(item.offset.y + item.size.height)) { + if (offset.x in item.offset.x..(item.offset.x + item.size.width)) { + return item + } + closestItemInRow = item + } + } + return closestItemInRow + } + + fun isIndexOfHeader(i: Int): Boolean { + return if (headerEnabled) i == 1 else i == enabledActions.size + 2 + } + + fun indexOfEnabledAction(i: Int): Int { + val base = if (headerEnabled) 2 else 1 + return if (i >= base && i < (enabledActions.size + base)) i - base else ItemNotFound + } + + fun indexOfHiddenAction(i: Int): Int { + val base = enabledActions.size + 3 + return if (i >= base && i < (hiddenActions.size + base)) i - base else ItemNotFound + } + + fun removeAllMarkers() { + enabledActions.remove(ActionOrMarker.DragMarker) + if (enabledActions.isEmpty()) { + enabledActions.add(ActionOrMarker.NoneMarker) + } + hiddenActions.remove(ActionOrMarker.DragMarker) + if (hiddenActions.isEmpty()) { + hiddenActions.add(ActionOrMarker.NoneMarker) + } + } + + fun beginDragGesture(pos: IntOffset): Boolean { + val item = findItemForOffsetOrClosestInRow(pos) ?: return false + val i = item.index + val enabledActionIndex = indexOfEnabledAction(i) + val hiddenActionIndex = indexOfHiddenAction(i) + if (isIndexOfHeader(i)) { + activeDragAction = ActionOrMarker.Header + } else if (enabledActionIndex != ItemNotFound && enabledActions[enabledActionIndex] != ActionOrMarker.NoneMarker) { + activeDragAction = enabledActions[enabledActionIndex] + enabledActions[enabledActionIndex] = ActionOrMarker.DragMarker + } else if (hiddenActionIndex != ItemNotFound && hiddenActions[hiddenActionIndex] != ActionOrMarker.NoneMarker) { + activeDragAction = hiddenActions[hiddenActionIndex] + hiddenActions[hiddenActionIndex] = ActionOrMarker.DragMarker + } else { + return false + } + activeDragPosition = pos + activeDragSize = item.size + return true + } + + fun handleDragGestureChange(pos: IntOffset) { + if (activeDragAction == null) return + activeDragPosition = pos + val item = findItemForOffsetOrClosestInRow(pos) ?: return + val i = item.index + + if (activeDragAction == ActionOrMarker.Header) { + headerEnabled = i < enabledActions.size + 2 + return + } + + val enabledActionIndex = indexOfEnabledAction(i) + val hiddenActionIndex = indexOfHiddenAction(i) + if (enabledActionIndex != ItemNotFound && enabledActions[enabledActionIndex] != ActionOrMarker.DragMarker) { + if (enabledActions[enabledActionIndex] == ActionOrMarker.NoneMarker) { + removeAllMarkers() + enabledActions[enabledActionIndex] = ActionOrMarker.DragMarker + } else { + removeAllMarkers() + enabledActions.add(enabledActionIndex, ActionOrMarker.DragMarker) + } + } else if (hiddenActionIndex != ItemNotFound && hiddenActions[hiddenActionIndex] != ActionOrMarker.DragMarker) { + if (hiddenActions[hiddenActionIndex] == ActionOrMarker.NoneMarker) { + removeAllMarkers() + hiddenActions[hiddenActionIndex] = ActionOrMarker.DragMarker + } else { + removeAllMarkers() + hiddenActions.add(hiddenActionIndex, ActionOrMarker.DragMarker) + } + } + } + + fun completeDragGestureAndCleanUp() { + val action = activeDragAction + if (action != null && action != ActionOrMarker.Header) { + val i = enabledActions.indexOf(ActionOrMarker.DragMarker) + if (i >= 0) { + enabledActions[i] = action + } else { + val j = hiddenActions.indexOf(ActionOrMarker.DragMarker) + if (j >= 0) { + hiddenActions[j] = action + } + } + } + activeDragAction = null + activeDragPosition = IntOffset.Zero + activeDragSize = IntSize.Zero + } + + DisposableEffect(Unit) { + onDispose { + completeDragGestureAndCleanUp() + // TODO save to settings + } + } + + LazyVerticalGrid( + modifier = Modifier + .safeDrawingPadding() + .detectDragGestures( + beginDragGesture = ::beginDragGesture, + handleDragGestureChange = ::handleDragGestureChange, + endDragGesture = ::completeDragGestureAndCleanUp, + ), + // same width as the LongPressMenu + columns = GridCells.Adaptive(MinButtonWidth), + state = gridState, + ) { + item(span = { GridItemSpan(maxLineSpan) }) { + Subheader( + title = R.string.long_press_menu_enabled_actions, + description = R.string.long_press_menu_enabled_actions_description, + ) + } + if (headerEnabled) { + item(span = { GridItemSpan(2) }) { + ActionOrMarkerUi( + modifier = Modifier.animateItem(), + // if the header is being dragged, show a DragMarker in its place + action = if (activeDragAction == ActionOrMarker.Header) + ActionOrMarker.DragMarker + else + ActionOrMarker.Header, + ) + } + } + itemsIndexed(enabledActions, key = { _, action -> action.stableUniqueKey() }) { _, action -> + ActionOrMarkerUi(modifier = Modifier.animateItem(), action = action) + } + item(span = { GridItemSpan(maxLineSpan) }) { + Subheader( + title = R.string.long_press_menu_hidden_actions, + description = R.string.long_press_menu_hidden_actions_description, + ) + } + if (!headerEnabled) { + item(span = { GridItemSpan(2) }) { + ActionOrMarkerUi( + modifier = Modifier.animateItem(), + // if the header is being dragged, show a DragMarker in its place + action = if (activeDragAction == ActionOrMarker.Header) + ActionOrMarker.DragMarker + else + ActionOrMarker.Header, + ) + } + } + itemsIndexed(hiddenActions, key = { _, action -> action.stableUniqueKey() }) { _, action -> + ActionOrMarkerUi(modifier = Modifier.animateItem(), action = action) + } + } + if (activeDragAction != null) { + val size = with(LocalDensity.current) { + remember(activeDragSize) { activeDragSize.toSize().toDpSize() } + } + ActionOrMarkerUi( + modifier = Modifier + .size(size) + .offset { activeDragPosition } + .offset(-size.width / 2, -size.height / 2), + action = activeDragAction!!, + ) + } +} + +sealed interface ActionOrMarker { + object NoneMarker : ActionOrMarker + object DragMarker : ActionOrMarker + object Header : ActionOrMarker + data class Action(val type: LongPressAction.Type) : ActionOrMarker + + fun stableUniqueKey(): Any { + return when (this) { + is Action -> this.type.ordinal + DragMarker -> LongPressAction.Type.entries.size + NoneMarker -> LongPressAction.Type.entries.size + 1 + Header -> LongPressAction.Type.entries.size + 2 + } + } +} + +@Composable +private fun Subheader(@StringRes title: Int, @StringRes description: Int) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp) + ) { + Text( + text = stringResource(title), + style = MaterialTheme.typography.titleMedium + ) + Text( + text = stringResource(description), + fontStyle = FontStyle.Italic, + style = MaterialTheme.typography.bodyMedium, + ) + } +} + +@Composable +private fun ActionOrMarkerUi(action: ActionOrMarker, modifier: Modifier = Modifier) { + Surface( + color = when (action) { + ActionOrMarker.Header -> MaterialTheme.colorScheme.surfaceVariant + else -> Color.Transparent + }, + contentColor = when (action) { + is ActionOrMarker.Action -> MaterialTheme.colorScheme.primary + ActionOrMarker.DragMarker -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f) + // 0.38f is the same alpha that the Material3 library applies for disabled buttons + ActionOrMarker.NoneMarker -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) + ActionOrMarker.Header -> MaterialTheme.colorScheme.onSurfaceVariant + }, + shape = MaterialTheme.shapes.large, + modifier = modifier.padding( + horizontal = if (action == ActionOrMarker.Header) 12.dp else 3.dp, + vertical = 5.dp, + ), + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier, + ) { + Icon( + imageVector = when (action) { + is ActionOrMarker.Action -> action.type.icon + ActionOrMarker.DragMarker -> Icons.Default.DragHandle + ActionOrMarker.NoneMarker -> Icons.Default.Close + ActionOrMarker.Header -> Icons.Default.ArtTrack + }, + contentDescription = null, + modifier = Modifier.size(32.dp), + ) + FixedHeightCenteredText( + text = stringResource( + when (action) { + is ActionOrMarker.Action -> action.type.label + ActionOrMarker.DragMarker -> R.string.detail_drag_description + ActionOrMarker.NoneMarker -> R.string.none + ActionOrMarker.Header -> R.string.header + } + ), + lines = 2, + style = MaterialTheme.typography.bodySmall, + ) + } + } +} + +@Preview +@Composable +private fun LongPressMenuEditorPreview() { + AppTheme { + Surface { + LongPressMenuEditor() + } + } +} + +private class ActionOrMarkerPreviewProvider : CollectionPreviewParameterProvider( + listOf(ActionOrMarker.Header, ActionOrMarker.DragMarker, ActionOrMarker.NoneMarker) + + LongPressAction.Type.entries.take(3).map { ActionOrMarker.Action(it) } +) + +@Preview +@Composable +private fun QuickActionButtonPreview( + @PreviewParameter(ActionOrMarkerPreviewProvider::class) actionOrMarker: ActionOrMarker +) { + AppTheme { + Surface { + ActionOrMarkerUi(actionOrMarker, Modifier.width(MinButtonWidth)) + } + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 65cb817d022..c56f870ac15 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -903,4 +903,9 @@ Background\nfrom here Popup\nfrom here Play\nfrom here + Enabled actions: + Reorder the actions by dragging them around + Hidden actions: + Drag the header or the actions to this section to hide them + Header diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ab8103909d8..d1095882229 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -58,7 +58,7 @@ teamnewpipe-nanojson = "e9d656ddb49a412a5a0a5d5ef20ca7ef09549996" # the corresponding commit hash, since JitPack sometimes deletes artifacts. # If there’s already a git hash, just add more of it to the end (or remove a letter) # to cause jitpack to regenerate the artifact. -teamnewpipe-newpipe-extractor = "0023b22095a2d62a60cdfc87f4b5cd85c8b266c3" +teamnewpipe-newpipe-extractor = "3af73262cc60cf555fd5f1d691f6c58e2db38ef5" webkit = "1.9.0" work = "2.10.0" From 813a4f550de84730a48266c42c3e14be9b14d68c Mon Sep 17 00:00:00 2001 From: Stypox Date: Wed, 22 Oct 2025 00:33:10 +0200 Subject: [PATCH 37/41] Add Back content description to toolbar back buttons --- app/src/main/java/org/schabi/newpipe/ui/Toolbar.kt | 5 +++-- .../newpipe/ui/components/common/ScaffoldWithToolbar.kt | 4 +++- app/src/main/res/values/strings.xml | 1 + 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/org/schabi/newpipe/ui/Toolbar.kt b/app/src/main/java/org/schabi/newpipe/ui/Toolbar.kt index de0c9754035..40a1458af7c 100644 --- a/app/src/main/java/org/schabi/newpipe/ui/Toolbar.kt +++ b/app/src/main/java/org/schabi/newpipe/ui/Toolbar.kt @@ -37,8 +37,9 @@ fun TextAction(text: String, modifier: Modifier = Modifier) { @Composable fun NavigationIcon() { Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back", - modifier = Modifier.padding(horizontal = SizeTokens.SpacingExtraSmall) + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.back), + modifier = Modifier.padding(horizontal = SizeTokens.SpacingExtraSmall), ) } diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/common/ScaffoldWithToolbar.kt b/app/src/main/java/org/schabi/newpipe/ui/components/common/ScaffoldWithToolbar.kt index 18139c7a68f..f5b8913c97f 100644 --- a/app/src/main/java/org/schabi/newpipe/ui/components/common/ScaffoldWithToolbar.kt +++ b/app/src/main/java/org/schabi/newpipe/ui/components/common/ScaffoldWithToolbar.kt @@ -14,7 +14,9 @@ import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview +import org.schabi.newpipe.R @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -40,7 +42,7 @@ fun ScaffoldWithToolbar( IconButton(onClick = onBackClick) { Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = null + contentDescription = stringResource(R.string.back), ) } }, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c56f870ac15..ccd50304e9c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -908,4 +908,5 @@ Hidden actions: Drag the header or the actions to this section to hide them Header + Back From 771551cc55e076b6abd6d0bcf27230fa5a0703be Mon Sep 17 00:00:00 2001 From: Stypox Date: Wed, 22 Oct 2025 01:11:30 +0200 Subject: [PATCH 38/41] Access editor from long press menu + fix scrolling --- .../schabi/newpipe/ui/DetectDragModifier.kt | 25 +- .../ui/components/menu/LongPressMenu.kt | 214 ++++++++++-------- .../ui/components/menu/LongPressMenuEditor.kt | 25 +- app/src/main/res/values/strings.xml | 1 + 4 files changed, 153 insertions(+), 112 deletions(-) diff --git a/app/src/main/java/org/schabi/newpipe/ui/DetectDragModifier.kt b/app/src/main/java/org/schabi/newpipe/ui/DetectDragModifier.kt index 813734c94d2..1a5c6e5dcb7 100644 --- a/app/src/main/java/org/schabi/newpipe/ui/DetectDragModifier.kt +++ b/app/src/main/java/org/schabi/newpipe/ui/DetectDragModifier.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.input.pointer.positionChange import androidx.compose.ui.unit.IntOffset /** @@ -15,31 +16,31 @@ import androidx.compose.ui.unit.IntOffset * [androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress]. * * @param beginDragGesture called when the user first touches the screen (down event) with the - * pointer position, should return `true` if the receiver wants to handle this gesture, `false` - * otherwise. - * @param handleDragGestureChange called with the current pointer position, every time the user - * moves the finger after [beginDragGesture] has returned `true`. - * @param endDragGesture called when the drag gesture finishes after [beginDragGesture] has returned - * `true`. + * pointer position. + * @param handleDragGestureChange called with the current pointer position and the difference from + * the last position, every time the user moves the finger after [beginDragGesture] has been called. + * @param endDragGesture called when the drag gesture finishes, after [beginDragGesture] has been + * called. */ fun Modifier.detectDragGestures( - beginDragGesture: (IntOffset) -> Boolean, - handleDragGestureChange: (IntOffset) -> Unit, + beginDragGesture: (position: IntOffset) -> Unit, + handleDragGestureChange: (position: IntOffset, positionChange: Offset) -> Unit, endDragGesture: () -> Unit ): Modifier { return this.pointerInput(Unit) { awaitEachGesture { val down = awaitFirstDown() val pointerId = down.id - if (!beginDragGesture(down.position.toIntOffset())) { - return@awaitEachGesture - } + beginDragGesture(down.position.toIntOffset()) while (true) { val change = awaitPointerEvent().changes.find { it.id == pointerId } if (change == null || !change.pressed) { break } - handleDragGestureChange(change.position.toIntOffset()) + handleDragGestureChange( + change.position.toIntOffset(), + change.positionChange(), + ) change.consume() } endDragGesture() 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 e959ce615b1..361631c6ad9 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,7 @@ import android.content.Context import android.content.res.Configuration import android.view.ViewGroup import android.view.ViewGroup.LayoutParams +import androidx.activity.compose.BackHandler import androidx.compose.foundation.clickable import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement @@ -37,16 +38,15 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.SheetState import androidx.compose.material3.Surface import androidx.compose.material3.Text 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.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -73,6 +73,7 @@ import coil3.compose.AsyncImage import org.schabi.newpipe.R import org.schabi.newpipe.extractor.stream.StreamType import org.schabi.newpipe.ktx.popFirst +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.theme.AppTheme @@ -119,100 +120,127 @@ fun LongPressMenu( longPressable: LongPressable, longPressActions: List, onDismissRequest: () -> Unit, - onEditActions: () -> Unit = {}, // TODO handle this menu - sheetState: SheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), ) { - ModalBottomSheet( - onDismissRequest, - sheetState = sheetState, - dragHandle = { LongPressMenuDragHandle(onEditActions) }, - ) { - BoxWithConstraints( - modifier = Modifier - .fillMaxWidth() - .padding(start = 6.dp, end = 6.dp, bottom = 16.dp) + var showEditor by rememberSaveable(key = longPressable.url) { mutableStateOf(false) } + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + if (showEditor) { + // we can't put the editor in a bottom sheet, because it relies on dragging gestures + ScaffoldWithToolbar( + title = stringResource(R.string.long_press_menu_actions_editor), + onBackClick = { showEditor = false }, + ) { paddingValues -> + Box(modifier = Modifier.padding(paddingValues)) { + LongPressMenuEditor() + } + BackHandler { showEditor = false } + } + } else { + ModalBottomSheet( + sheetState = sheetState, + onDismissRequest = onDismissRequest, + dragHandle = { LongPressMenuDragHandle(onEditActions = { showEditor = true }) }, ) { - val buttonHeight = MinButtonWidth // landscape aspect ratio, square in the limit - val headerWidthInButtons = 5 // the header is 5 times as wide as the buttons - val buttonsPerRow = (this.maxWidth / MinButtonWidth).toInt() + LongPressMenuContent( + longPressable = longPressable, + longPressActions = longPressActions, + onDismissRequest = onDismissRequest, + ) + } + } +} - // the channel icon goes in the menu header, so do not show a button for it - val actions = longPressActions.toMutableList() - val ctx = LocalContext.current - val onUploaderClick = actions.popFirst { it.type == ShowChannelDetails } - ?.let { showChannelDetailsAction -> - { - showChannelDetailsAction.action(ctx) - onDismissRequest() - } +@Composable +private fun LongPressMenuContent( + longPressable: LongPressable, + longPressActions: List, + onDismissRequest: () -> Unit, +) { + BoxWithConstraints( + modifier = Modifier + .fillMaxWidth() + .padding(start = 6.dp, end = 6.dp, bottom = 16.dp) + ) { + val buttonHeight = MinButtonWidth // landscape aspect ratio, square in the limit + val headerWidthInButtons = 5 // the header is 5 times as wide as the buttons + val buttonsPerRow = (this.maxWidth / MinButtonWidth).toInt() + + // the channel icon goes in the menu header, so do not show a button for it + val actions = longPressActions.toMutableList() + val ctx = LocalContext.current + val onUploaderClick = actions.popFirst { it.type == ShowChannelDetails } + ?.let { showChannelDetailsAction -> + { + showChannelDetailsAction.action(ctx) + onDismissRequest() } + } - Column { - var actionIndex = -1 // -1 indicates the header - while (actionIndex < actions.size) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth(), - ) { - var rowIndex = 0 - while (rowIndex < buttonsPerRow) { - if (actionIndex >= actions.size) { - // no more buttons to show, fill the rest of the row with a - // spacer that has the same weight as the missing buttons, so that - // the other buttons don't grow too wide - Spacer( - modifier = Modifier - .height(buttonHeight) - .fillMaxWidth() - .weight((buttonsPerRow - rowIndex).toFloat()), - ) - break - } else if (actionIndex >= 0) { - val action = actions[actionIndex] - LongPressMenuButton( - icon = action.type.icon, - text = stringResource(action.type.label), - onClick = { - action.action(ctx) - onDismissRequest() - }, - enabled = action.enabled(false), - modifier = Modifier - .height(buttonHeight) - .fillMaxWidth() - .weight(1F), - ) - rowIndex += 1 - } else if (headerWidthInButtons >= buttonsPerRow) { - // this branch is taken if the header is going to fit on one line - // (i.e. on phones in portrait) - LongPressMenuHeader( - item = longPressable, - onUploaderClick = onUploaderClick, - modifier = Modifier - // leave the height as small as possible, since it's the - // only item on the row anyway - .padding(start = 6.dp, end = 6.dp, bottom = 6.dp) - .fillMaxWidth() - .weight(headerWidthInButtons.toFloat()), - ) - rowIndex += headerWidthInButtons - } else { - // this branch is taken if the header will have some buttons to its - // right (i.e. on tablets or on phones in landscape) - LongPressMenuHeader( - item = longPressable, - onUploaderClick = onUploaderClick, - modifier = Modifier - .padding(6.dp) - .heightIn(min = 70.dp) - .fillMaxWidth() - .weight(headerWidthInButtons.toFloat()), - ) - rowIndex += headerWidthInButtons - } - actionIndex += 1 + Column { + var actionIndex = -1 // -1 indicates the header + while (actionIndex < actions.size) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + var rowIndex = 0 + while (rowIndex < buttonsPerRow) { + if (actionIndex >= actions.size) { + // no more buttons to show, fill the rest of the row with a + // spacer that has the same weight as the missing buttons, so that + // the other buttons don't grow too wide + Spacer( + modifier = Modifier + .height(buttonHeight) + .fillMaxWidth() + .weight((buttonsPerRow - rowIndex).toFloat()), + ) + break + } else if (actionIndex >= 0) { + val action = actions[actionIndex] + LongPressMenuButton( + icon = action.type.icon, + text = stringResource(action.type.label), + onClick = { + action.action(ctx) + onDismissRequest() + }, + enabled = action.enabled(false), + modifier = Modifier + .height(buttonHeight) + .fillMaxWidth() + .weight(1F), + ) + rowIndex += 1 + } else if (headerWidthInButtons >= buttonsPerRow) { + // this branch is taken if the header is going to fit on one line + // (i.e. on phones in portrait) + LongPressMenuHeader( + item = longPressable, + onUploaderClick = onUploaderClick, + modifier = Modifier + // leave the height as small as possible, since it's the + // only item on the row anyway + .padding(start = 6.dp, end = 6.dp, bottom = 6.dp) + .fillMaxWidth() + .weight(headerWidthInButtons.toFloat()), + ) + rowIndex += headerWidthInButtons + } else { + // this branch is taken if the header will have some buttons to its + // right (i.e. on tablets or on phones in landscape) + LongPressMenuHeader( + item = longPressable, + onUploaderClick = onUploaderClick, + modifier = Modifier + .padding(6.dp) + .heightIn(min = 70.dp) + .fillMaxWidth() + .weight(headerWidthInButtons.toFloat()), + ) + rowIndex += headerWidthInButtons } + actionIndex += 1 } } } @@ -619,14 +647,12 @@ private fun LongPressMenuPreview( AppTheme(useDarkTheme = useDarkTheme) { // longPressable is null when running the preview in an emulator for some reason... @Suppress("USELESS_ELVIS") - LongPressMenu( + LongPressMenuContent( longPressable = longPressable ?: LongPressablePreviews().values.first(), - onDismissRequest = {}, longPressActions = LongPressAction.Type.entries // disable Enqueue actions just to show it off .map { t -> t.buildAction({ t != EnqueueNext }) { } }, - onEditActions = { useDarkTheme = !useDarkTheme }, - sheetState = rememberStandardBottomSheetState(), // makes it start out as open + onDismissRequest = {}, ) } } diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressMenuEditor.kt b/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressMenuEditor.kt index a0a1cbf2399..f5ca4551f3e 100644 --- a/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressMenuEditor.kt +++ b/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressMenuEditor.kt @@ -20,7 +20,9 @@ package org.schabi.newpipe.ui.components.menu import androidx.annotation.StringRes import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawingPadding @@ -50,6 +52,7 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.toMutableStateList import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource @@ -69,6 +72,7 @@ import org.schabi.newpipe.util.text.FixedHeightCenteredText private const val ItemNotFound = -1 +// TODO implement accessibility for this, to allow using this with a DPAD (e.g. Android TV) @Composable fun LongPressMenuEditor() { // We get the current arrangement once and do not observe on purpose @@ -135,8 +139,8 @@ fun LongPressMenuEditor() { } } - fun beginDragGesture(pos: IntOffset): Boolean { - val item = findItemForOffsetOrClosestInRow(pos) ?: return false + fun beginDragGesture(pos: IntOffset) { + val item = findItemForOffsetOrClosestInRow(pos) ?: return val i = item.index val enabledActionIndex = indexOfEnabledAction(i) val hiddenActionIndex = indexOfHiddenAction(i) @@ -149,15 +153,18 @@ fun LongPressMenuEditor() { activeDragAction = hiddenActions[hiddenActionIndex] hiddenActions[hiddenActionIndex] = ActionOrMarker.DragMarker } else { - return false + return } activeDragPosition = pos activeDragSize = item.size - return true } - fun handleDragGestureChange(pos: IntOffset) { - if (activeDragAction == null) return + fun handleDragGestureChange(pos: IntOffset, posChange: Offset) { + if (activeDragAction == null) { + // when the user clicks outside of any draggable item, let the list be scrolled + gridState.dispatchRawDelta(-posChange.y) + return + } activeDragPosition = pos val item = findItemForOffsetOrClosestInRow(pos) ?: return val i = item.index @@ -223,6 +230,7 @@ fun LongPressMenuEditor() { ), // same width as the LongPressMenu columns = GridCells.Adaptive(MinButtonWidth), + userScrollEnabled = false, state = gridState, ) { item(span = { GridItemSpan(maxLineSpan) }) { @@ -267,6 +275,11 @@ fun LongPressMenuEditor() { itemsIndexed(hiddenActions, key = { _, action -> action.stableUniqueKey() }) { _, action -> ActionOrMarkerUi(modifier = Modifier.animateItem(), action = action) } + item { + // make the grid size a bit bigger to let items be dragged at the bottom and to give + // the view some space to resizing without jumping up and down + Spacer(modifier = Modifier.height(MinButtonWidth)) + } } if (activeDragAction != null) { val size = with(LocalDensity.current) { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ccd50304e9c..f8a889f73ef 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -909,4 +909,5 @@ Drag the header or the actions to this section to hide them Header Back + Reorder and disable actions From 8075a4341407db9f91c353f746d37413dd22c983 Mon Sep 17 00:00:00 2001 From: Stypox Date: Thu, 23 Oct 2025 00:54:54 +0200 Subject: [PATCH 39/41] Rewrite LongPressMenuEditor logic --- .../ui/components/menu/LongPressMenuEditor.kt | 397 +++++++++--------- app/src/main/res/values/strings.xml | 2 +- 2 files changed, 207 insertions(+), 192 deletions(-) diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressMenuEditor.kt b/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressMenuEditor.kt index f5ca4551f3e..c483ac08f3b 100644 --- a/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressMenuEditor.kt +++ b/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressMenuEditor.kt @@ -19,10 +19,10 @@ package org.schabi.newpipe.ui.components.menu import androidx.annotation.StringRes +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.border import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawingPadding @@ -40,12 +40,14 @@ import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.DragHandle import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon +import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -54,49 +56,60 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.toSize import org.schabi.newpipe.R -import org.schabi.newpipe.ui.components.menu.LongPressAction.Type.Companion.DefaultEnabledActions import org.schabi.newpipe.ui.detectDragGestures import org.schabi.newpipe.ui.theme.AppTheme import org.schabi.newpipe.util.text.FixedHeightCenteredText - -private const val ItemNotFound = -1 +import kotlin.math.min // TODO implement accessibility for this, to allow using this with a DPAD (e.g. Android TV) @Composable fun LongPressMenuEditor() { // We get the current arrangement once and do not observe on purpose // TODO load from settings - var headerEnabled by remember { mutableStateOf(true) } - val actionArrangement = remember { DefaultEnabledActions } - val enabledActions = remember(actionArrangement) { - actionArrangement - .map { ActionOrMarker.Action(it) } - .ifEmpty { listOf(ActionOrMarker.NoneMarker) } - .toMutableStateList() - } - val hiddenActions = remember(actionArrangement) { - LongPressAction.Type.entries - .filter { !actionArrangement.contains(it) } - .map { ActionOrMarker.Action(it) } - .ifEmpty { listOf(ActionOrMarker.NoneMarker) } - .toMutableStateList() + val headerEnabled = remember { false } // true } + val actionArrangement = remember { LongPressAction.Type.entries } // DefaultEnabledActions } + val items = remember(headerEnabled, actionArrangement) { + sequence { + yield(ItemInList.EnabledCaption) + if (headerEnabled) { + yield(ItemInList.HeaderBox) + } + yieldAll( + actionArrangement + .map { ItemInList.Action(it) } + .ifEmpty { if (headerEnabled) listOf() else listOf(ItemInList.NoneMarker) } + ) + yield(ItemInList.HiddenCaption) + if (!headerEnabled) { + yield(ItemInList.HeaderBox) + } + yieldAll( + LongPressAction.Type.entries + .filter { !actionArrangement.contains(it) } + .map { ItemInList.Action(it) } + .ifEmpty { if (headerEnabled) listOf(ItemInList.NoneMarker) else listOf() } + ) + }.toList().toMutableStateList() } val gridState = rememberLazyGridState() - var activeDragAction by remember { mutableStateOf(null) } + var activeDragItem by remember { mutableStateOf(null) } var activeDragPosition by remember { mutableStateOf(IntOffset.Zero) } var activeDragSize by remember { mutableStateOf(IntSize.Zero) } + var currentlyFocusedItem by remember { mutableIntStateOf(-1) } fun findItemForOffsetOrClosestInRow(offset: IntOffset): LazyGridItemInfo? { var closestItemInRow: LazyGridItemInfo? = null @@ -114,101 +127,66 @@ fun LongPressMenuEditor() { return closestItemInRow } - fun isIndexOfHeader(i: Int): Boolean { - return if (headerEnabled) i == 1 else i == enabledActions.size + 2 - } - - fun indexOfEnabledAction(i: Int): Int { - val base = if (headerEnabled) 2 else 1 - return if (i >= base && i < (enabledActions.size + base)) i - base else ItemNotFound - } - - fun indexOfHiddenAction(i: Int): Int { - val base = enabledActions.size + 3 - return if (i >= base && i < (hiddenActions.size + base)) i - base else ItemNotFound - } - - fun removeAllMarkers() { - enabledActions.remove(ActionOrMarker.DragMarker) - if (enabledActions.isEmpty()) { - enabledActions.add(ActionOrMarker.NoneMarker) - } - hiddenActions.remove(ActionOrMarker.DragMarker) - if (hiddenActions.isEmpty()) { - hiddenActions.add(ActionOrMarker.NoneMarker) - } - } - fun beginDragGesture(pos: IntOffset) { - val item = findItemForOffsetOrClosestInRow(pos) ?: return - val i = item.index - val enabledActionIndex = indexOfEnabledAction(i) - val hiddenActionIndex = indexOfHiddenAction(i) - if (isIndexOfHeader(i)) { - activeDragAction = ActionOrMarker.Header - } else if (enabledActionIndex != ItemNotFound && enabledActions[enabledActionIndex] != ActionOrMarker.NoneMarker) { - activeDragAction = enabledActions[enabledActionIndex] - enabledActions[enabledActionIndex] = ActionOrMarker.DragMarker - } else if (hiddenActionIndex != ItemNotFound && hiddenActions[hiddenActionIndex] != ActionOrMarker.NoneMarker) { - activeDragAction = hiddenActions[hiddenActionIndex] - hiddenActions[hiddenActionIndex] = ActionOrMarker.DragMarker - } else { - return + val rawItem = findItemForOffsetOrClosestInRow(pos) ?: return + val item = items.getOrNull(rawItem.index) ?: return + if (item.isDraggable) { + items[rawItem.index] = ItemInList.DragMarker(item.columnSpan) + activeDragItem = item + activeDragPosition = pos + activeDragSize = rawItem.size } - activeDragPosition = pos - activeDragSize = item.size } fun handleDragGestureChange(pos: IntOffset, posChange: Offset) { - if (activeDragAction == null) { + val dragItem = activeDragItem + if (dragItem == null) { // when the user clicks outside of any draggable item, let the list be scrolled gridState.dispatchRawDelta(-posChange.y) return } activeDragPosition = pos - val item = findItemForOffsetOrClosestInRow(pos) ?: return - val i = item.index + val rawItem = findItemForOffsetOrClosestInRow(pos) ?: return - if (activeDragAction == ActionOrMarker.Header) { - headerEnabled = i < enabledActions.size + 2 - return + // compute where the DragMarker will go (we need to do special logic to make sure the + // HeaderBox always sticks right after EnabledCaption or HiddenCaption) + val nextDragMarkerIndex = if (dragItem == ItemInList.HeaderBox) { + val hiddenCaptionIndex = items.indexOf(ItemInList.HiddenCaption) + if (rawItem.index < hiddenCaptionIndex) + 1 // i.e. right after the EnabledCaption + else + hiddenCaptionIndex + 1 // i.e. right after the HiddenCaption + } else { + var i = rawItem.index + // make sure it is not possible to move items in between a *Caption and a HeaderBox + if (!items[i].isDraggable) i += 1 + if (items[i] == ItemInList.HeaderBox) i += 1 + i } - val enabledActionIndex = indexOfEnabledAction(i) - val hiddenActionIndex = indexOfHiddenAction(i) - if (enabledActionIndex != ItemNotFound && enabledActions[enabledActionIndex] != ActionOrMarker.DragMarker) { - if (enabledActions[enabledActionIndex] == ActionOrMarker.NoneMarker) { - removeAllMarkers() - enabledActions[enabledActionIndex] = ActionOrMarker.DragMarker - } else { - removeAllMarkers() - enabledActions.add(enabledActionIndex, ActionOrMarker.DragMarker) - } - } else if (hiddenActionIndex != ItemNotFound && hiddenActions[hiddenActionIndex] != ActionOrMarker.DragMarker) { - if (hiddenActions[hiddenActionIndex] == ActionOrMarker.NoneMarker) { - removeAllMarkers() - hiddenActions[hiddenActionIndex] = ActionOrMarker.DragMarker - } else { - removeAllMarkers() - hiddenActions.add(hiddenActionIndex, ActionOrMarker.DragMarker) - } + // adjust the position of the DragMarker + items.removeIf { it is ItemInList.DragMarker } + items.add(min(nextDragMarkerIndex, items.size), ItemInList.DragMarker(dragItem.columnSpan)) + + // add or remove NoneMarkers as needed + items.removeIf { it is ItemInList.NoneMarker } + val hiddenCaptionIndex = items.indexOf(ItemInList.HiddenCaption) + if (hiddenCaptionIndex == items.size - 1) { + items.add(ItemInList.NoneMarker) + } else if (hiddenCaptionIndex == 1) { + items.add(1, ItemInList.NoneMarker) } } fun completeDragGestureAndCleanUp() { - val action = activeDragAction - if (action != null && action != ActionOrMarker.Header) { - val i = enabledActions.indexOf(ActionOrMarker.DragMarker) - if (i >= 0) { - enabledActions[i] = action - } else { - val j = hiddenActions.indexOf(ActionOrMarker.DragMarker) - if (j >= 0) { - hiddenActions[j] = action - } + val dragItem = activeDragItem + if (dragItem != null) { + val dragMarkerIndex = items.indexOfFirst { it is ItemInList.DragMarker } + if (dragMarkerIndex >= 0) { + items[dragMarkerIndex] = dragItem } } - activeDragAction = null + activeDragItem = null activeDragPosition = IntOffset.Zero activeDragSize = IntSize.Zero } @@ -233,90 +211,73 @@ fun LongPressMenuEditor() { userScrollEnabled = false, state = gridState, ) { - item(span = { GridItemSpan(maxLineSpan) }) { - Subheader( - title = R.string.long_press_menu_enabled_actions, - description = R.string.long_press_menu_enabled_actions_description, - ) - } - if (headerEnabled) { - item(span = { GridItemSpan(2) }) { - ActionOrMarkerUi( - modifier = Modifier.animateItem(), - // if the header is being dragged, show a DragMarker in its place - action = if (activeDragAction == ActionOrMarker.Header) - ActionOrMarker.DragMarker - else - ActionOrMarker.Header, - ) - } - } - itemsIndexed(enabledActions, key = { _, action -> action.stableUniqueKey() }) { _, action -> - ActionOrMarkerUi(modifier = Modifier.animateItem(), action = action) - } - item(span = { GridItemSpan(maxLineSpan) }) { - Subheader( - title = R.string.long_press_menu_hidden_actions, - description = R.string.long_press_menu_hidden_actions_description, + itemsIndexed( + items, + key = { _, item -> item.stableUniqueKey() }, + span = { _, item -> GridItemSpan(item.columnSpan ?: maxLineSpan) }, + ) { i, item -> + ItemInListUi( + item = item, + selected = currentlyFocusedItem == i, + modifier = Modifier.animateItem() ) } - if (!headerEnabled) { - item(span = { GridItemSpan(2) }) { - ActionOrMarkerUi( - modifier = Modifier.animateItem(), - // if the header is being dragged, show a DragMarker in its place - action = if (activeDragAction == ActionOrMarker.Header) - ActionOrMarker.DragMarker - else - ActionOrMarker.Header, - ) - } - } - itemsIndexed(hiddenActions, key = { _, action -> action.stableUniqueKey() }) { _, action -> - ActionOrMarkerUi(modifier = Modifier.animateItem(), action = action) - } - item { - // make the grid size a bit bigger to let items be dragged at the bottom and to give - // the view some space to resizing without jumping up and down - Spacer(modifier = Modifier.height(MinButtonWidth)) - } } - if (activeDragAction != null) { + if (activeDragItem != null) { val size = with(LocalDensity.current) { remember(activeDragSize) { activeDragSize.toSize().toDpSize() } } - ActionOrMarkerUi( + ItemInListUi( + item = activeDragItem!!, + selected = true, modifier = Modifier .size(size) .offset { activeDragPosition } .offset(-size.width / 2, -size.height / 2), - action = activeDragAction!!, ) } } -sealed interface ActionOrMarker { - object NoneMarker : ActionOrMarker - object DragMarker : ActionOrMarker - object Header : ActionOrMarker - data class Action(val type: LongPressAction.Type) : ActionOrMarker +sealed class ItemInList(val isDraggable: Boolean, open val columnSpan: Int? = 1) { + // decoration items (i.e. text subheaders) + object EnabledCaption : ItemInList(isDraggable = false, columnSpan = null /* i.e. all line */) + object HiddenCaption : ItemInList(isDraggable = false, columnSpan = null /* i.e. all line */) - fun stableUniqueKey(): Any { + // actual draggable actions (+ a header) + object HeaderBox : ItemInList(isDraggable = true, columnSpan = 2) + data class Action(val type: LongPressAction.Type) : ItemInList(isDraggable = true) + + // markers + object NoneMarker : ItemInList(isDraggable = true) + data class DragMarker(override val columnSpan: Int?) : ItemInList(isDraggable = true) + + fun stableUniqueKey(): Int { return when (this) { is Action -> this.type.ordinal - DragMarker -> LongPressAction.Type.entries.size - NoneMarker -> LongPressAction.Type.entries.size + 1 - Header -> LongPressAction.Type.entries.size + 2 + NoneMarker -> LongPressAction.Type.entries.size + 0 + HeaderBox -> LongPressAction.Type.entries.size + 1 + EnabledCaption -> LongPressAction.Type.entries.size + 2 + HiddenCaption -> LongPressAction.Type.entries.size + 3 + is DragMarker -> LongPressAction.Type.entries.size + 4 + (this.columnSpan ?: 0) } } } +inline fun T.letIf(condition: Boolean, block: T.() -> T): T = + if (condition) block(this) else this + @Composable -private fun Subheader(@StringRes title: Int, @StringRes description: Int) { +private fun Subheader( + selected: Boolean, + @StringRes title: Int, + @StringRes description: Int, + modifier: Modifier = Modifier, +) { Column( - modifier = Modifier + modifier = modifier .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 12.dp) + .letIf(selected) { border(2.dp, LocalContentColor.current) } ) { Text( text = stringResource(title), @@ -331,48 +292,33 @@ private fun Subheader(@StringRes title: Int, @StringRes description: Int) { } @Composable -private fun ActionOrMarkerUi(action: ActionOrMarker, modifier: Modifier = Modifier) { +private fun ActionOrHeaderBox( + selected: Boolean, + icon: ImageVector, + @StringRes text: Int, + contentColor: Color, + modifier: Modifier = Modifier, + backgroundColor: Color = Color.Transparent, + horizontalPadding: Dp = 3.dp, +) { Surface( - color = when (action) { - ActionOrMarker.Header -> MaterialTheme.colorScheme.surfaceVariant - else -> Color.Transparent - }, - contentColor = when (action) { - is ActionOrMarker.Action -> MaterialTheme.colorScheme.primary - ActionOrMarker.DragMarker -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f) - // 0.38f is the same alpha that the Material3 library applies for disabled buttons - ActionOrMarker.NoneMarker -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) - ActionOrMarker.Header -> MaterialTheme.colorScheme.onSurfaceVariant - }, + color = backgroundColor, + contentColor = contentColor, shape = MaterialTheme.shapes.large, + border = BorderStroke(2.dp, contentColor).takeIf { selected }, modifier = modifier.padding( - horizontal = if (action == ActionOrMarker.Header) 12.dp else 3.dp, + horizontal = horizontalPadding, vertical = 5.dp, ), ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier, - ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { Icon( - imageVector = when (action) { - is ActionOrMarker.Action -> action.type.icon - ActionOrMarker.DragMarker -> Icons.Default.DragHandle - ActionOrMarker.NoneMarker -> Icons.Default.Close - ActionOrMarker.Header -> Icons.Default.ArtTrack - }, + imageVector = icon, contentDescription = null, modifier = Modifier.size(32.dp), ) FixedHeightCenteredText( - text = stringResource( - when (action) { - is ActionOrMarker.Action -> action.type.label - ActionOrMarker.DragMarker -> R.string.detail_drag_description - ActionOrMarker.NoneMarker -> R.string.none - ActionOrMarker.Header -> R.string.header - } - ), + text = stringResource(text), lines = 2, style = MaterialTheme.typography.bodySmall, ) @@ -380,6 +326,71 @@ private fun ActionOrMarkerUi(action: ActionOrMarker, modifier: Modifier = Modifi } } +@Composable +private fun ItemInListUi( + item: ItemInList, + selected: Boolean, + modifier: Modifier = Modifier, +) { + when (item) { + ItemInList.EnabledCaption -> { + Subheader( + modifier = modifier, + selected = selected, + title = R.string.long_press_menu_enabled_actions, + description = R.string.long_press_menu_enabled_actions_description, + ) + } + ItemInList.HiddenCaption -> { + Subheader( + modifier = modifier, + selected = selected, + title = R.string.long_press_menu_hidden_actions, + description = R.string.long_press_menu_hidden_actions_description, + ) + } + is ItemInList.Action -> { + ActionOrHeaderBox( + modifier = modifier, + selected = selected, + icon = item.type.icon, + text = item.type.label, + contentColor = MaterialTheme.colorScheme.onSurface, + ) + } + ItemInList.HeaderBox -> { + ActionOrHeaderBox( + modifier = modifier, + selected = selected, + icon = Icons.Default.ArtTrack, + text = R.string.header, + contentColor = MaterialTheme.colorScheme.onSurfaceVariant, + backgroundColor = MaterialTheme.colorScheme.surfaceVariant, + horizontalPadding = 12.dp, + ) + } + ItemInList.NoneMarker -> { + ActionOrHeaderBox( + modifier = modifier, + selected = selected, + icon = Icons.Default.Close, + text = R.string.none, + // 0.38f is the same alpha that the Material3 library applies for disabled buttons + contentColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f), + ) + } + is ItemInList.DragMarker -> { + ActionOrHeaderBox( + modifier = modifier, + selected = selected, + icon = Icons.Default.DragHandle, + text = R.string.detail_drag_description, + contentColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f), + ) + } + } +} + @Preview @Composable private fun LongPressMenuEditorPreview() { @@ -390,19 +401,23 @@ private fun LongPressMenuEditorPreview() { } } -private class ActionOrMarkerPreviewProvider : CollectionPreviewParameterProvider( - listOf(ActionOrMarker.Header, ActionOrMarker.DragMarker, ActionOrMarker.NoneMarker) + - LongPressAction.Type.entries.take(3).map { ActionOrMarker.Action(it) } +private class ItemInListPreviewProvider : CollectionPreviewParameterProvider( + listOf(ItemInList.HeaderBox, ItemInList.DragMarker(1), ItemInList.NoneMarker) + + LongPressAction.Type.entries.take(3).map { ItemInList.Action(it) } ) @Preview @Composable private fun QuickActionButtonPreview( - @PreviewParameter(ActionOrMarkerPreviewProvider::class) actionOrMarker: ActionOrMarker + @PreviewParameter(ItemInListPreviewProvider::class) itemInList: ItemInList ) { AppTheme { Surface { - ActionOrMarkerUi(actionOrMarker, Modifier.width(MinButtonWidth)) + ItemInListUi( + item = itemInList, + selected = itemInList.stableUniqueKey() % 2 == 0, + modifier = Modifier.width(MinButtonWidth) + ) } } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f8a889f73ef..d0ddc049e2e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -909,5 +909,5 @@ Drag the header or the actions to this section to hide them Header Back - Reorder and disable actions + Reorder and hide actions From 6506d6264f2792f4566988fbf367de31e1b1e811 Mon Sep 17 00:00:00 2001 From: Stypox Date: Thu, 23 Oct 2025 03:11:14 +0200 Subject: [PATCH 40/41] Make LongPressMenuEditor work with DPAD / Android TV --- .../ui/components/menu/LongPressMenu.kt | 19 +- .../ui/components/menu/LongPressMenuEditor.kt | 240 ++++++++++++++---- 2 files changed, 199 insertions(+), 60 deletions(-) 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 361631c6ad9..1ac1e08eb39 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,7 +7,6 @@ import android.content.Context import android.content.res.Configuration import android.view.ViewGroup import android.view.ViewGroup.LayoutParams -import androidx.activity.compose.BackHandler import androidx.compose.foundation.clickable import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement @@ -69,6 +68,8 @@ import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider import androidx.compose.ui.tooling.preview.datasource.LoremIpsum import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties import coil3.compose.AsyncImage import org.schabi.newpipe.R import org.schabi.newpipe.extractor.stream.StreamType @@ -126,14 +127,16 @@ fun LongPressMenu( if (showEditor) { // we can't put the editor in a bottom sheet, because it relies on dragging gestures - ScaffoldWithToolbar( - title = stringResource(R.string.long_press_menu_actions_editor), - onBackClick = { showEditor = false }, - ) { paddingValues -> - Box(modifier = Modifier.padding(paddingValues)) { - LongPressMenuEditor() + Dialog( + onDismissRequest = { showEditor = false }, + properties = DialogProperties(usePlatformDefaultWidth = false) + ) { + ScaffoldWithToolbar( + title = stringResource(R.string.long_press_menu_actions_editor), + onBackClick = { showEditor = false }, + ) { paddingValues -> + LongPressMenuEditor(modifier = Modifier.padding(paddingValues)) } - BackHandler { showEditor = false } } } else { ModalBottomSheet( diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressMenuEditor.kt b/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressMenuEditor.kt index c483ac08f3b..7904404fea2 100644 --- a/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressMenuEditor.kt +++ b/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressMenuEditor.kt @@ -21,6 +21,7 @@ package org.schabi.newpipe.ui.components.menu import androidx.annotation.StringRes import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.border +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.offset @@ -54,9 +55,15 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.toMutableStateList import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.focusTarget import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.input.key.type import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontStyle @@ -69,18 +76,19 @@ import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.toSize import org.schabi.newpipe.R +import org.schabi.newpipe.ui.components.menu.LongPressAction.Type.Companion.DefaultEnabledActions import org.schabi.newpipe.ui.detectDragGestures import org.schabi.newpipe.ui.theme.AppTheme import org.schabi.newpipe.util.text.FixedHeightCenteredText +import kotlin.math.floor import kotlin.math.min -// TODO implement accessibility for this, to allow using this with a DPAD (e.g. Android TV) @Composable -fun LongPressMenuEditor() { +fun LongPressMenuEditor(modifier: Modifier = Modifier) { // We get the current arrangement once and do not observe on purpose // TODO load from settings - val headerEnabled = remember { false } // true } - val actionArrangement = remember { LongPressAction.Type.entries } // DefaultEnabledActions } + val headerEnabled = remember { true } + val actionArrangement = remember { DefaultEnabledActions } val items = remember(headerEnabled, actionArrangement) { sequence { yield(ItemInList.EnabledCaption) @@ -127,8 +135,8 @@ fun LongPressMenuEditor() { return closestItemInRow } - fun beginDragGesture(pos: IntOffset) { - val rawItem = findItemForOffsetOrClosestInRow(pos) ?: return + fun beginDragGesture(pos: IntOffset, rawItem: LazyGridItemInfo) { + if (activeDragItem != null) return val item = items.getOrNull(rawItem.index) ?: return if (item.isDraggable) { items[rawItem.index] = ItemInList.DragMarker(item.columnSpan) @@ -138,15 +146,14 @@ fun LongPressMenuEditor() { } } - fun handleDragGestureChange(pos: IntOffset, posChange: Offset) { - val dragItem = activeDragItem - if (dragItem == null) { - // when the user clicks outside of any draggable item, let the list be scrolled - gridState.dispatchRawDelta(-posChange.y) - return - } - activeDragPosition = pos + fun beginDragGesture(pos: IntOffset) { val rawItem = findItemForOffsetOrClosestInRow(pos) ?: return + beginDragGesture(pos, rawItem) + } + + fun handleDragGestureChange(dragItem: ItemInList, rawItem: LazyGridItemInfo) { + val prevDragMarkerIndex = items.indexOfFirst { it is ItemInList.DragMarker } + .takeIf { it >= 0 } ?: return // impossible situation, DragMarker is always in the list // compute where the DragMarker will go (we need to do special logic to make sure the // HeaderBox always sticks right after EnabledCaption or HiddenCaption) @@ -154,18 +161,26 @@ fun LongPressMenuEditor() { val hiddenCaptionIndex = items.indexOf(ItemInList.HiddenCaption) if (rawItem.index < hiddenCaptionIndex) 1 // i.e. right after the EnabledCaption + else if (prevDragMarkerIndex < hiddenCaptionIndex) + hiddenCaptionIndex // i.e. right after the HiddenCaption else hiddenCaptionIndex + 1 // i.e. right after the HiddenCaption } else { var i = rawItem.index // make sure it is not possible to move items in between a *Caption and a HeaderBox - if (!items[i].isDraggable) i += 1 - if (items[i] == ItemInList.HeaderBox) i += 1 + val offsetForRemovingPrev = if (prevDragMarkerIndex < rawItem.index) 1 else 0 + if (!items[i - offsetForRemovingPrev].isDraggable) i += 1 + if (items[i - offsetForRemovingPrev] == ItemInList.HeaderBox) i += 1 i } + // no need to do anything if the DragMarker is already at the right place + if (prevDragMarkerIndex == nextDragMarkerIndex) { + return + } + // adjust the position of the DragMarker - items.removeIf { it is ItemInList.DragMarker } + items.removeAt(prevDragMarkerIndex) items.add(min(nextDragMarkerIndex, items.size), ItemInList.DragMarker(dragItem.columnSpan)) // add or remove NoneMarkers as needed @@ -178,6 +193,18 @@ fun LongPressMenuEditor() { } } + fun handleDragGestureChange(pos: IntOffset, posChangeForScrolling: Offset) { + val dragItem = activeDragItem + if (dragItem == null) { + // when the user clicks outside of any draggable item, let the list be scrolled + gridState.dispatchRawDelta(-posChangeForScrolling.y) + return + } + activeDragPosition = pos + val rawItem = findItemForOffsetOrClosestInRow(pos) ?: return + handleDragGestureChange(dragItem, rawItem) + } + fun completeDragGestureAndCleanUp() { val dragItem = activeDragItem if (dragItem != null) { @@ -198,44 +225,153 @@ fun LongPressMenuEditor() { } } - LazyVerticalGrid( - modifier = Modifier - .safeDrawingPadding() - .detectDragGestures( - beginDragGesture = ::beginDragGesture, - handleDragGestureChange = ::handleDragGestureChange, - endDragGesture = ::completeDragGestureAndCleanUp, - ), - // same width as the LongPressMenu - columns = GridCells.Adaptive(MinButtonWidth), - userScrollEnabled = false, - state = gridState, - ) { - itemsIndexed( - items, - key = { _, item -> item.stableUniqueKey() }, - span = { _, item -> GridItemSpan(item.columnSpan ?: maxLineSpan) }, - ) { i, item -> + BoxWithConstraints(modifier) { + // otherwise we wouldn't know the amount of columns to handle the Up/Down key events + val columns = maxOf(1, floor(this.maxWidth / MinButtonWidth).toInt()) + LazyVerticalGrid( + modifier = Modifier + .safeDrawingPadding() + .detectDragGestures( + beginDragGesture = ::beginDragGesture, + handleDragGestureChange = ::handleDragGestureChange, + endDragGesture = ::completeDragGestureAndCleanUp, + ) + .focusTarget() + .onKeyEvent { event -> + if (event.type != KeyEventType.KeyDown) { + if (event.type == KeyEventType.KeyUp && + event.key == Key.DirectionDown && + currentlyFocusedItem < 0 + ) { + // + currentlyFocusedItem = 0 + } + return@onKeyEvent false + } + var focusedItem = currentlyFocusedItem + when (event.key) { + Key.DirectionUp -> { + if (focusedItem < 0) { + return@onKeyEvent false + } else if (items[focusedItem].columnSpan == null) { + focusedItem -= 1 + } else { + var remaining = columns + while (true) { + focusedItem -= 1 + if (focusedItem < 0) { + break + } + remaining -= items[focusedItem].columnSpan ?: columns + if (remaining <= 0) { + break + } + } + } + } + + Key.DirectionDown -> { + if (focusedItem >= items.size - 1) { + return@onKeyEvent false + } else if (items[focusedItem].columnSpan == null) { + focusedItem += 1 + } else { + var remaining = columns + while (true) { + focusedItem += 1 + if (focusedItem >= items.size - 1) { + break + } + remaining -= items[focusedItem].columnSpan ?: columns + if (remaining <= 0) { + break + } + } + } + } + + Key.DirectionLeft -> { + if (focusedItem < 0) { + return@onKeyEvent false + } else { + focusedItem -= 1 + } + } + + Key.DirectionRight -> { + if (focusedItem >= items.size - 1) { + return@onKeyEvent false + } else { + focusedItem += 1 + } + } + + Key.Enter, Key.NumPadEnter, Key.DirectionCenter -> if (activeDragItem == null) { + val rawItem = gridState.layoutInfo.visibleItemsInfo + .firstOrNull { it.index == focusedItem } + ?: return@onKeyEvent false + beginDragGesture(rawItem.offset, rawItem) + return@onKeyEvent true + } else { + completeDragGestureAndCleanUp() + return@onKeyEvent true + } + + else -> return@onKeyEvent false + } + + currentlyFocusedItem = focusedItem + if (focusedItem < 0) { + // not checking for focusedItem>=items.size because it's impossible for it + // to reach that value, and that's because we assume that there is nothing + // else focusable *after* this view. This way we don't need to cleanup the + // drag gestures when the user reaches the end, which would be confusing as + // then there would be no indication of the current cursor position at all. + completeDragGestureAndCleanUp() + return@onKeyEvent false + } + + val dragItem = activeDragItem + if (dragItem != null) { + val rawItem = gridState.layoutInfo.visibleItemsInfo + .firstOrNull { it.index == focusedItem } + ?: return@onKeyEvent false + activeDragPosition = rawItem.offset + handleDragGestureChange(dragItem, rawItem) + } + return@onKeyEvent true + }, + // same width as the LongPressMenu + columns = GridCells.Adaptive(MinButtonWidth), + userScrollEnabled = false, + state = gridState, + ) { + itemsIndexed( + items, + key = { _, item -> item.stableUniqueKey() }, + span = { _, item -> GridItemSpan(item.columnSpan ?: maxLineSpan) }, + ) { i, item -> + ItemInListUi( + item = item, + selected = currentlyFocusedItem == i, + modifier = Modifier.animateItem() + ) + } + } + if (activeDragItem != null) { + val size = with(LocalDensity.current) { + remember(activeDragSize) { activeDragSize.toSize().toDpSize() } + } ItemInListUi( - item = item, - selected = currentlyFocusedItem == i, - modifier = Modifier.animateItem() + item = activeDragItem!!, + selected = false, + modifier = Modifier + .size(size) + .offset { activeDragPosition } + .offset(-size.width / 2, -size.height / 2), ) } } - if (activeDragItem != null) { - val size = with(LocalDensity.current) { - remember(activeDragSize) { activeDragSize.toSize().toDpSize() } - } - ItemInListUi( - item = activeDragItem!!, - selected = true, - modifier = Modifier - .size(size) - .offset { activeDragPosition } - .offset(-size.width / 2, -size.height / 2), - ) - } } sealed class ItemInList(val isDraggable: Boolean, open val columnSpan: Int? = 1) { @@ -305,7 +441,7 @@ private fun ActionOrHeaderBox( color = backgroundColor, contentColor = contentColor, shape = MaterialTheme.shapes.large, - border = BorderStroke(2.dp, contentColor).takeIf { selected }, + border = BorderStroke(2.dp, contentColor.copy(alpha = 1f)).takeIf { selected }, modifier = modifier.padding( horizontal = horizontalPadding, vertical = 5.dp, From 81c12acd88f0e1d2bd549b1b497b8352c94601fc Mon Sep 17 00:00:00 2001 From: Stypox Date: Thu, 23 Oct 2025 03:37:10 +0200 Subject: [PATCH 41/41] Fix an edge case on the DragMarker position logic --- .../newpipe/ui/components/menu/LongPressMenuEditor.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressMenuEditor.kt b/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressMenuEditor.kt index 7904404fea2..b2e8622502f 100644 --- a/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressMenuEditor.kt +++ b/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressMenuEditor.kt @@ -168,9 +168,9 @@ fun LongPressMenuEditor(modifier: Modifier = Modifier) { } else { var i = rawItem.index // make sure it is not possible to move items in between a *Caption and a HeaderBox - val offsetForRemovingPrev = if (prevDragMarkerIndex < rawItem.index) 1 else 0 - if (!items[i - offsetForRemovingPrev].isDraggable) i += 1 - if (items[i - offsetForRemovingPrev] == ItemInList.HeaderBox) i += 1 + if (!items[i].isDraggable) i += 1 + if (i < items.size && items[i] == ItemInList.HeaderBox) i += 1 + if (i > rawItem.index && prevDragMarkerIndex < rawItem.index) i -= 1 i }