From e276d706d6f4409c913c35646f88e99e495f1409 Mon Sep 17 00:00:00 2001 From: Stypox Date: Tue, 28 Jan 2025 11:12:06 +0100 Subject: [PATCH 01/87] 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 000000000..9d1f8f0f2 --- /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 64c5aa2540b8f8507667ed5fdab4fa2a67e435bf Mon Sep 17 00:00:00 2001 From: Stypox Date: Tue, 28 Jan 2025 11:14:12 +0100 Subject: [PATCH 02/87] 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 000000000..44731b120 --- /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 000000000..6f66c84b4 --- /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 6ad16fc42ef9115b176ce94c7319810667fa518f Mon Sep 17 00:00:00 2001 From: Stypox Date: Tue, 28 Jan 2025 12:36:32 +0100 Subject: [PATCH 03/87] 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 44731b120..30e499a73 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/87] 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 30e499a73..abcf1585f 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 a7701c43deecd0568196a040d0254a2b18156775 Mon Sep 17 00:00:00 2001 From: Stypox Date: Tue, 28 Jan 2025 14:03:24 +0100 Subject: [PATCH 05/87] 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 abcf1585f..f58f13ec1 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/87] 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 f58f13ec1..038f99f1b 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 d4eb403e1df26294fa9f59f60c778778a91f1b74 Mon Sep 17 00:00:00 2001 From: Stypox Date: Fri, 31 Jan 2025 13:18:30 +0100 Subject: [PATCH 07/87] 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 038f99f1b..ee1531573 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 d9520a056..af161f338 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 8e454dcb71ea2adb6accaa2ab4d15366b10e0047 Mon Sep 17 00:00:00 2001 From: Stypox Date: Tue, 11 Feb 2025 15:03:37 +0100 Subject: [PATCH 08/87] 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 ee1531573..b870f72b1 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 000000000..aac91ee04 --- /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 208dbc895..fbc88b64a 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 9abbb7b075bb0669bcf59a3c057f25243ef9a958 Mon Sep 17 00:00:00 2001 From: Stypox Date: Tue, 11 Feb 2025 15:46:03 +0100 Subject: [PATCH 09/87] Initial work on handling many long-press actions --- .../list/playlist/PlaylistFragment.java | 11 + .../ui/components/menu/LongPressAction.kt | 162 ++++++++++++++ .../ui/components/menu/LongPressMenu.kt | 206 ++++++++---------- .../ui/components/menu/LongPressable.kt | 2 + 4 files changed, 271 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 be4f076dd..10c90a6c4 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/playlist/PlaylistFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/playlist/PlaylistFragment.java @@ -3,6 +3,7 @@ package org.schabi.newpipe.fragments.list.playlist; import static org.schabi.newpipe.extractor.utils.Utils.isBlank; import static org.schabi.newpipe.ktx.ViewUtils.animate; import static org.schabi.newpipe.ktx.ViewUtils.animateHideRecyclerViewAllowingScrolling; +import static org.schabi.newpipe.ui.components.menu.LongPressMenuKt.getLongPressMenuView; import static org.schabi.newpipe.util.ServiceHelper.getServiceById; import android.content.Context; @@ -150,6 +151,16 @@ public class PlaylistFragment extends BaseListInfoFragment Unit, + val enabled: (isPlayerRunning: Boolean) -> Boolean = { true }, +) { + enum class Type( + @StringRes val label: Int, + val icon: ImageVector, + ) { + Enqueue(R.string.enqueue, Icons.Default.AddToQueue), + EnqueueNext(R.string.enqueue_next_stream, Icons.Default.QueuePlayNext), + Background(R.string.controls_background_title, Icons.Default.Headset), + Popup(R.string.controls_popup_title, Icons.Default.PictureInPicture), + Play(R.string.play, Icons.Default.PlayArrow), + PlayWithKodi(R.string.play_with_kodi_title, Icons.Default.Cast), + Download(R.string.download, Icons.Default.Download), + AddToPlaylist(R.string.add_to_playlist, Icons.AutoMirrored.Default.PlaylistAdd), + Share(R.string.share, Icons.Default.Share), + OpenInBrowser(R.string.open_in_browser, Icons.Default.OpenInBrowser), + ShowChannelDetails(R.string.show_channel_details, Icons.Default.Person), + MarkAsWatched(R.string.mark_as_watched, Icons.Default.Done), + Delete(R.string.delete, Icons.Default.Delete), + ; + + // TODO allow actions to return disposables + // TODO add actions that use the whole list the item belongs to (see wholeListQueue) + + fun buildAction( + enabled: (isPlayerRunning: Boolean) -> Boolean = { true }, + action: (context: Context) -> Unit, + ) = LongPressAction(this, action, enabled) + } + + companion object { + private fun buildPlayerActionList(queue: () -> PlayQueue): List { + return listOf( + Type.Enqueue.buildAction({ isPlayerRunning -> isPlayerRunning }) { context -> + NavigationHelper.enqueueOnPlayer(context, queue()) + }, + Type.EnqueueNext.buildAction({ isPlayerRunning -> isPlayerRunning }) { context -> + NavigationHelper.enqueueNextOnPlayer(context, queue()) + }, + Type.Background.buildAction { context -> + NavigationHelper.playOnBackgroundPlayer(context, queue(), true) + }, + Type.Popup.buildAction { context -> + NavigationHelper.playOnPopupPlayer(context, queue(), true) + }, + Type.Play.buildAction { context -> + NavigationHelper.playOnMainPlayer(context, queue(), false) + }, + ) + } + + private fun buildShareActionList(item: InfoItem): List { + return listOf( + Type.Share.buildAction { context -> + ShareUtils.shareText(context, item.name, item.url, item.thumbnails) + }, + Type.OpenInBrowser.buildAction { context -> + ShareUtils.openUrlInBrowser(context, item.url) + }, + ) + } + + fun buildActionList( + item: StreamInfoItem, + isKodiEnabled: Boolean, + /* TODO wholeListQueue: (() -> PlayQueue)? */ + ): List { + return buildPlayerActionList { SinglePlayQueue(item) } + + buildShareActionList(item) + + listOf( + Type.Download.buildAction { context -> /* TODO */ }, + Type.AddToPlaylist.buildAction { context -> + PlaylistDialog.createCorrespondingDialog( + context, + listOf(StreamEntity(item)) + ) { dialog: PlaylistDialog -> + val tag = if (dialog is PlaylistAppendDialog) "append" else "create" + dialog.show( + context.findFragmentActivity().supportFragmentManager, + "StreamDialogEntry@${tag}_playlist" + ) + } + }, + Type.ShowChannelDetails.buildAction { context -> + SparseItemUtil.fetchUploaderUrlIfSparse( + context, item.serviceId, item.url, item.uploaderUrl + ) { url: String -> + NavigationHelper.openChannelFragment( + context.findFragmentActivity().supportFragmentManager, + item.serviceId, + url, + item.uploaderName, + ) + } + }, + Type.MarkAsWatched.buildAction { context -> + HistoryRecordManager(context) + .markAsWatched(item) + .doOnError { error -> + ErrorUtil.showSnackbar( + context, + ErrorInfo( + error, + UserAction.OPEN_INFO_ITEM_DIALOG, + "Got an error when trying to mark as watched" + ) + ) + } + .onErrorComplete() + .observeOn(AndroidSchedulers.mainThread()) + .subscribe() + }, + ) + if (isKodiEnabled) listOf( + Type.PlayWithKodi.buildAction { context -> + KoreUtils.playWithKore(context, Uri.parse(item.url)) + }, + ) else listOf() + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressMenu.kt b/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressMenu.kt index b870f72b1..7f4be175d 100644 --- a/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressMenu.kt +++ b/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressMenu.kt @@ -4,8 +4,10 @@ package org.schabi.newpipe.ui.components.menu import android.content.Context import android.content.res.Configuration +import android.view.ViewGroup import androidx.compose.foundation.basicMarquee import androidx.compose.foundation.clickable +import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints @@ -22,25 +24,12 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.PlaylistAdd import androidx.compose.material.icons.automirrored.filled.PlaylistPlay -import androidx.compose.material.icons.filled.AddToQueue -import androidx.compose.material.icons.filled.Delete -import androidx.compose.material.icons.filled.Done -import androidx.compose.material.icons.filled.Download -import androidx.compose.material.icons.filled.Edit -import androidx.compose.material.icons.filled.Headset -import androidx.compose.material.icons.filled.OpenInBrowser -import androidx.compose.material.icons.filled.Person -import androidx.compose.material.icons.filled.PictureInPicture -import androidx.compose.material.icons.filled.PlayArrow -import androidx.compose.material.icons.filled.QueuePlayNext -import androidx.compose.material.icons.filled.Share +import androidx.compose.material.icons.filled.Settings import androidx.compose.material3.BottomSheetDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton -import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedButton @@ -51,11 +40,16 @@ import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.rememberStandardBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -74,18 +68,60 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.times import coil3.compose.AsyncImage import org.schabi.newpipe.R +import org.schabi.newpipe.extractor.stream.StreamInfoItem import org.schabi.newpipe.player.playqueue.PlayQueue import org.schabi.newpipe.player.playqueue.SinglePlayQueue import org.schabi.newpipe.ui.theme.AppTheme import org.schabi.newpipe.ui.theme.customColors import org.schabi.newpipe.util.Either import org.schabi.newpipe.util.Localization +import org.schabi.newpipe.util.image.ImageStrategy import java.time.OffsetDateTime +fun getLongPressMenuView( + context: Context, + streamInfoItem: StreamInfoItem, +): ComposeView { + return ComposeView(context).apply { + setContent { + LongPressMenu( + longPressable = object : LongPressable { + override val title: String = streamInfoItem.name + override val url: String? = streamInfoItem.url?.takeIf { it.isNotBlank() } + override val thumbnailUrl: String? = + ImageStrategy.choosePreferredImage(streamInfoItem.thumbnails) + override val uploader: String? = + streamInfoItem.uploaderName?.takeIf { it.isNotBlank() } + override val uploaderUrl: String? = + streamInfoItem.uploaderUrl?.takeIf { it.isNotBlank() } + override val viewCount: Long? = + streamInfoItem.viewCount.takeIf { it >= 0 } + override val uploadDate: Either? = + streamInfoItem.uploadDate?.let { Either.right(it.offsetDateTime()) } + ?: streamInfoItem.textualUploadDate?.let { Either.left(it) } + override val decoration: LongPressableDecoration? = + streamInfoItem.duration.takeIf { it >= 0 }?.let { + LongPressableDecoration.Duration(it) + } + + override fun getPlayQueue(): PlayQueue { + TODO("Not yet implemented") + } + }, + onDismissRequest = { (this.parent as ViewGroup).removeView(this) }, + actions = LongPressAction.buildActionList(streamInfoItem, false), + onEditActions = {}, + ) + } + } +} + @Composable fun LongPressMenu( longPressable: LongPressable, onDismissRequest: () -> Unit, + actions: List, + onEditActions: () -> Unit, sheetState: SheetState = rememberModalBottomSheetState(), ) { ModalBottomSheet( @@ -99,17 +135,19 @@ fun LongPressMenu( modifier = Modifier.align(Alignment.Center) ) IconButton( - onClick = {}, + onClick = onEditActions, modifier = Modifier.align(Alignment.CenterEnd) ) { // show a small button here, it's not an important button and it shouldn't // capture the user attention Icon( - imageVector = Icons.Default.Edit, + imageVector = Icons.Default.Settings, contentDescription = stringResource(R.string.edit), // same color and height as the DragHandle tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(2.dp).size(16.dp), + modifier = Modifier + .padding(2.dp) + .size(16.dp), ) } } @@ -135,9 +173,21 @@ fun LongPressMenu( // errors in the calculations above don't make the items wrap at the wrong position modifier = Modifier.align(Alignment.Center), ) { + val actionsWithoutChannel = actions.toMutableList() + val showChannelAction = actionsWithoutChannel.indexOfFirst { + it.type == LongPressAction.Type.ShowChannelDetails + }.let { i -> + if (i >= 0) { + actionsWithoutChannel.removeAt(i) + } else { + null + } + } + LongPressMenuHeader( item = longPressable, thumbnailHeight = buttonHeight, + onUploaderClickAction = showChannelAction?.action, // subtract 2.dp to account for approximation errors in the calculations above modifier = if (desiredHeaderWidth >= maxContainerWidth - 2 * padding - 2.dp) { // leave the height as small as possible, since it's the only item on the @@ -149,92 +199,16 @@ fun LongPressMenu( } ) - LongPressMenuButton( - icon = Icons.Default.AddToQueue, - text = stringResource(R.string.enqueue), - onClick = {}, - enabled = false, - modifier = Modifier.size(buttonWidth, buttonHeight), - ) - - LongPressMenuButton( - icon = Icons.Default.QueuePlayNext, - text = stringResource(R.string.enqueue_next_stream), - onClick = {}, - enabled = false, - modifier = Modifier.size(buttonWidth, buttonHeight), - ) - - LongPressMenuButton( - icon = Icons.Default.Headset, - text = stringResource(R.string.controls_background_title), - onClick = {}, - modifier = Modifier.size(buttonWidth, buttonHeight), - ) - - LongPressMenuButton( - icon = Icons.Default.PictureInPicture, - text = stringResource(R.string.controls_popup_title), - onClick = {}, - modifier = Modifier.size(buttonWidth, buttonHeight), - ) - - LongPressMenuButton( - icon = Icons.Default.PlayArrow, - text = stringResource(R.string.play), - onClick = {}, - modifier = Modifier.size(buttonWidth, buttonHeight), - ) - - LongPressMenuButton( - icon = Icons.Default.Download, - text = stringResource(R.string.download), - onClick = {}, - modifier = Modifier.size(buttonWidth, buttonHeight), - ) - - LongPressMenuButton( - icon = Icons.AutoMirrored.Default.PlaylistAdd, - text = stringResource(R.string.add_to_playlist), - onClick = {}, - modifier = Modifier.size(buttonWidth, buttonHeight), - ) - - LongPressMenuButton( - icon = Icons.Default.Share, - text = stringResource(R.string.share), - onClick = {}, - modifier = Modifier.size(buttonWidth, buttonHeight), - ) - - LongPressMenuButton( - icon = Icons.Default.OpenInBrowser, - text = stringResource(R.string.open_in_browser), - onClick = {}, - modifier = Modifier.size(buttonWidth, buttonHeight), - ) - - LongPressMenuButton( - icon = Icons.Default.Done, - text = stringResource(R.string.mark_as_watched), - onClick = {}, - modifier = Modifier.size(buttonWidth, buttonHeight), - ) - - LongPressMenuButton( - icon = Icons.Default.Person, - text = stringResource(R.string.show_channel_details), - onClick = {}, - enabled = longPressable.uploaderUrl != null, - modifier = Modifier.size(buttonWidth, buttonHeight), - ) - - LongPressMenuButton( - icon = Icons.Default.Delete, - text = stringResource(R.string.delete), - onClick = {}, - modifier = Modifier.size(buttonWidth, buttonHeight), - ) + val ctx = LocalContext.current + for (action in actionsWithoutChannel) { + LongPressMenuButton( + icon = action.type.icon, + text = stringResource(action.type.label), + onClick = { action.action(ctx) }, + enabled = action.enabled(false), + modifier = Modifier.size(buttonWidth, buttonHeight), + ) + } } } } @@ -244,6 +218,7 @@ fun LongPressMenu( fun LongPressMenuHeader( item: LongPressable, thumbnailHeight: Dp, + onUploaderClickAction: ((context: Context) -> Unit)?, modifier: Modifier = Modifier, ) { val ctx = LocalContext.current @@ -354,6 +329,7 @@ fun LongPressMenuHeader( val subtitle = getSubtitleAnnotatedString( item = item, + showLink = onUploaderClickAction != null, linkColor = MaterialTheme.customColors.onSurfaceVariantLink, ctx = ctx, ) @@ -363,12 +339,10 @@ fun LongPressMenuHeader( Text( text = subtitle, style = MaterialTheme.typography.bodyMedium, - modifier = if (item.uploaderUrl.isNullOrBlank()) { + modifier = if (onUploaderClickAction == null) { Modifier } else { - Modifier.clickable { - // TODO handle click on uploader URL - } + Modifier.clickable { onUploaderClickAction(ctx) } }.basicMarquee(iterations = Int.MAX_VALUE) ) } @@ -379,11 +353,12 @@ fun LongPressMenuHeader( fun getSubtitleAnnotatedString( item: LongPressable, + showLink: Boolean, linkColor: Color, ctx: Context, ) = buildAnnotatedString { var shouldAddSeparator = false - if (!item.uploaderUrl.isNullOrBlank()) { + if (showLink) { withStyle( SpanStyle( fontWeight = FontWeight.Bold, @@ -554,10 +529,21 @@ private fun LongPressMenuPreview( Localization.initPrettyTime(Localization.resolvePrettyTime()) onDispose {} } - AppTheme { + + // the incorrect theme is set when running the preview in an emulator for some reason... + val initialUseDarkTheme = isSystemInDarkTheme() + var useDarkTheme by remember { mutableStateOf(initialUseDarkTheme) } + + AppTheme(useDarkTheme = useDarkTheme) { + // longPressable is null when running the preview in an emulator for some reason... + @Suppress("USELESS_ELVIS") LongPressMenu( - longPressable = longPressable, + longPressable = longPressable ?: LongPressablePreviews().values.first(), onDismissRequest = {}, + actions = LongPressAction.Type.entries + // disable Enqueue actions just to show it off + .map { t -> t.buildAction({ !t.name.startsWith("E") }) { } }, + onEditActions = { useDarkTheme = !useDarkTheme }, sheetState = rememberStandardBottomSheetState(), // makes it start out as open ) } diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressable.kt b/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressable.kt index 0b9d15f14..1dce8161a 100644 --- a/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressable.kt +++ b/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressable.kt @@ -5,12 +5,14 @@ import org.schabi.newpipe.player.playqueue.PlayQueue import org.schabi.newpipe.util.Either import java.time.OffsetDateTime +// TODO move within LongPressable sealed interface LongPressableDecoration { data class Duration(val duration: Long) : LongPressableDecoration data object Live : LongPressableDecoration data class Playlist(val itemCount: Long) : LongPressableDecoration } +// TODO this can be a data class @Stable interface LongPressable { val title: String From 719f8a5f289ecf22a5b4a4ab1d04d060680f3118 Mon Sep 17 00:00:00 2001 From: Stypox Date: Tue, 11 Feb 2025 15:56:09 +0100 Subject: [PATCH 10/87] 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 7f4be175d..7c19daef8 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 1dce8161a..b4b9cae1a 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 4581f2088511f5b4246ac59149daf518711a1642 Mon Sep 17 00:00:00 2001 From: Stypox Date: Tue, 11 Feb 2025 16:09:03 +0100 Subject: [PATCH 11/87] 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 7c19daef8..a8ab0e161 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 b727834092fd96c5779e7d848f8e64480dd159e0 Mon Sep 17 00:00:00 2001 From: Stypox Date: Tue, 11 Feb 2025 17:18:08 +0100 Subject: [PATCH 12/87] Improve how long press menu buttons are laid out --- .../main/java/org/schabi/newpipe/ktx/List.kt | 9 ++ .../ui/components/menu/LongPressMenu.kt | 153 ++++++++++-------- 2 files changed, 98 insertions(+), 64 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 000000000..0dd41bb6e --- /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 a8ab0e161..55b8c5656 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,59 +119,87 @@ 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 + + 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) }, + 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 + } } } - - 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) - } - ) - - 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), - ) - } } } } @@ -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 ee0f98f9ae8f58689594d294ecce2cc487e051b2 Mon Sep 17 00:00:00 2001 From: Stypox Date: Tue, 11 Feb 2025 17:29:33 +0100 Subject: [PATCH 13/87] 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 55b8c5656..f01e58501 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 b213a46399f36f58a7b9abc5ced1f4b0d5cd3591 Mon Sep 17 00:00:00 2001 From: Stypox Date: Tue, 11 Feb 2025 18:37:00 +0100 Subject: [PATCH 14/87] 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 77482d5ac..4f2030a27 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.error.ErrorInfo import org.schabi.newpipe.error.ErrorUtil import org.schabi.newpipe.error.UserAction @@ -110,7 +111,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 eca3486e092b6160890d00cc2ccd35fb194eb920 Mon Sep 17 00:00:00 2001 From: Stypox Date: Tue, 11 Feb 2025 18:42:36 +0100 Subject: [PATCH 15/87] 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 10c90a6c4..269d85c7a 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.dialog.PlaylistDialog; 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 @@ public class PlaylistFragment extends BaseListInfoFragment, ): 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 b4b9cae1a..ada15e6cd 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 985872f2c11219c7c1f0a350d7fa2ebf3b234290 Mon Sep 17 00:00:00 2001 From: Stypox Date: Wed, 12 Feb 2025 01:03:34 +0100 Subject: [PATCH 16/87] 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 | 27 +--- .../components/items/stream/StreamListItem.kt | 31 +++- .../ui/components/items/stream/StreamMenu.kt | 142 ------------------ .../ui/components/menu/LongPressAction.kt | 61 +++++++- .../ui/components/menu/LongPressMenu.kt | 20 ++- .../ui/components/menu/LongPressable.kt | 37 +++-- 11 files changed, 166 insertions(+), 331 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 8a117a47a..7a1ee3095 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 @@ package org.schabi.newpipe.fragments.list; 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.BaseStateFragment; 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 @@ public abstract class BaseListFragment extends BaseStateFragment } 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 269d85c7a..c3755ab65 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 @@ package org.schabi.newpipe.fragments.list.playlist; import static org.schabi.newpipe.extractor.utils.Utils.isBlank; import static org.schabi.newpipe.ktx.ViewUtils.animate; import static org.schabi.newpipe.ktx.ViewUtils.animateHideRecyclerViewAllowingScrolling; -import static org.schabi.newpipe.ui.components.menu.LongPressMenuKt.getLongPressMenuView; +import static org.schabi.newpipe.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.services.youtube.YoutubeParsingHelper; 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 @@ public class PlaylistFragment extends BaseListInfoFragment 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 560850294..b20fa330b 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 @@ -65,17 +64,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 @@ -381,14 +381,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) { @@ -407,7 +399,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 43b7f1c0d..d31e6bf57 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,13 +10,11 @@ import android.view.MenuInflater; 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 com.evernote.android.state.State; -import com.google.android.material.snackbar.Snackbar; import org.reactivestreams.Subscriber; import org.reactivestreams.Subscription; @@ -29,12 +28,12 @@ import org.schabi.newpipe.error.ErrorInfo; 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 java.util.function.Supplier; 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 @@ public class StatisticsPlaylistFragment } 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 cb38d9bae..5ae7ce158 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 @@ -8,6 +8,7 @@ import static org.schabi.newpipe.local.playlist.ExportPlaylistKt.export; 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; @@ -52,13 +53,13 @@ import org.schabi.newpipe.error.UserAction; 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.util.DeviceUtils; +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; @@ -789,39 +790,16 @@ public class LocalPlaylistFragment extends BaseLocalListFragment 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 ba45c503d..520587589 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 @@ -59,20 +56,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()) @@ -89,15 +72,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 84fff3e74..f4fbfb716 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 099a93005..000000000 --- a/app/src/main/java/org/schabi/newpipe/ui/components/items/stream/StreamMenu.kt +++ /dev/null @@ -1,142 +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 b77880075..cc025ee10 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.error.ErrorInfo @@ -35,7 +38,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( @@ -60,6 +62,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 @@ -104,9 +107,9 @@ data class LongPressAction( } @JvmStatic - fun buildActionList( + fun fromStreamInfoItem( item: StreamInfoItem, - isKodiEnabled: Boolean, + /* TODO isKodiEnabled: Boolean, */ /* TODO wholeListQueue: (() -> PlayQueue)? */ ): List { return buildPlayerActionList { SinglePlayQueue(item) } + @@ -163,11 +166,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 66c7a2629..c57e271ea 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 ada15e6cd..06e5146fd 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 bfbde938dd897db479d5b65a837854ac4cca684f Mon Sep 17 00:00:00 2001 From: Stypox Date: Wed, 12 Feb 2025 01:06:27 +0100 Subject: [PATCH 17/87] Remove InfoItemDialog --- .../info_list/dialog/InfoItemDialog.java | 356 ------------------ .../dialog/StreamDialogDefaultEntry.java | 171 --------- .../info_list/dialog/StreamDialogEntry.java | 31 -- 3 files changed, 558 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 cbaae2834..000000000 --- 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 5676fee95..000000000 --- a/app/src/main/java/org/schabi/newpipe/info_list/dialog/StreamDialogDefaultEntry.java +++ /dev/null @@ -1,171 +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.error.ErrorInfo; -import org.schabi.newpipe.error.ErrorUtil; -import org.schabi.newpipe.error.UserAction; -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) - .doOnError(error -> { - ErrorUtil.showSnackbar( - fragment.requireContext(), - new ErrorInfo( - error, - UserAction.OPEN_INFO_ITEM_DIALOG, - "Got an error when trying to mark as watched" - ) - ); - }) - .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 9d82e3b58..000000000 --- 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 3eb42d66857a8aabd7c3617c8679f5600451c8df Mon Sep 17 00:00:00 2001 From: Stypox Date: Wed, 12 Feb 2025 01:23:06 +0100 Subject: [PATCH 18/87] 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 c57e271ea..200b131f2 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/87] 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 200b131f2..8cc3ec861 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 1bb298be843e071ae47b3d67727af619aa3062a0 Mon Sep 17 00:00:00 2001 From: Stypox Date: Fri, 14 Feb 2025 14:08:08 +0100 Subject: [PATCH 20/87] Use LongPressMenu in BookmarkFragment --- .../local/bookmark/BookmarkFragment.java | 64 +++++++++---------- .../ui/components/menu/LongPressAction.kt | 53 ++++++++++++++- .../ui/components/menu/LongPressable.kt | 30 +++++++++ 3 files changed, 113 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 3b1d0c573..7f8a163a9 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 @@ -2,8 +2,8 @@ package org.schabi.newpipe.local.bookmark; import static org.schabi.newpipe.local.bookmark.MergedPlaylistManager.getMergedOrderedPlaylists; import static org.schabi.newpipe.util.ThemeHelper.shouldUseGridLayout; +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; @@ -40,6 +40,8 @@ import org.schabi.newpipe.local.holder.LocalBookmarkPlaylistItemHolder; 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; @@ -163,7 +165,7 @@ public final class BookmarkFragment extends BaseLocalListFragment showDeleteDialog(item.getOrderingName(), 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.getOrderingName(), 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.getOrderingName(), selectedItem), + isThumbnailPermanent ? () -> unsetPermanentThumbnail(selectedItem) : null + ) + ); } private void showRenameDialog(final PlaylistMetadataEntry selectedItem) { @@ -561,4 +552,13 @@ public final class BookmarkFragment extends BaseLocalListFragment { + 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, @@ -209,6 +226,7 @@ data class LongPressAction( @JvmStatic fun fromPlaylistStreamEntry( item: PlaylistStreamEntry, + // TODO possibly embed these two actions here onDelete: Runnable, onSetAsPlaylistThumbnail: Runnable, ): List { @@ -218,5 +236,36 @@ 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.orderingName ?: "", + 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 06e5146fd..644783d78 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.database.playlist.PlaylistMetadataEntry +import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity import org.schabi.newpipe.database.stream.model.StreamEntity +import org.schabi.newpipe.extractor.ListExtractor import org.schabi.newpipe.extractor.stream.StreamInfoItem import org.schabi.newpipe.extractor.stream.StreamType import org.schabi.newpipe.extractor.stream.StreamType.AUDIO_LIVE_STREAM @@ -62,5 +65,32 @@ 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.orderingName ?: "", + 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.orderingName ?: "", + url = item.url, + thumbnailUrl = item.thumbnailUrl, + uploader = item.uploader, + uploaderUrl = null, + viewCount = null, + uploadDate = null, + decoration = Decoration.Playlist( + item.streamCount ?: ListExtractor.ITEM_COUNT_UNKNOWN + ), + ) } } From 612122c26166cd4eb60373534f2b356da21ee990 Mon Sep 17 00:00:00 2001 From: Stypox Date: Fri, 14 Feb 2025 14:13:41 +0100 Subject: [PATCH 21/87] 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 8cc3ec861..899e56502 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 a18933792b9a63d8cfae21c4cb02fc2548d8e09d Mon Sep 17 00:00:00 2001 From: Stypox Date: Fri, 14 Feb 2025 14:36:21 +0100 Subject: [PATCH 22/87] 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 899e56502..6e2950729 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 000000000..e9d78c92c --- /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 1d1688529d49ce226ac1d287ed3e5331a956904d Mon Sep 17 00:00:00 2001 From: Stypox Date: Fri, 14 Mar 2025 12:57:26 +0100 Subject: [PATCH 23/87] 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 | 37 ++++++-- .../ui/components/menu/LongPressable.kt | 26 ++++++ 5 files changed, 155 insertions(+), 40 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 7a1ee3095..bafe1d554 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 androidx.recyclerview.widget.RecyclerView; 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 @@ public abstract class BaseListFragment extends BaseStateFragment 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 abstract class BaseListFragment extends BaseStateFragment } }); - 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 abstract class BaseListFragment extends BaseStateFragment 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 abstract class BaseListFragment extends BaseStateFragment } } - 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 a9eb2a19c..77b253283 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 @@ import io.reactivex.rxjava3.schedulers.Schedulers; 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 final class ChannelTabPlayQueue extends AbstractInfoPlayQueue { + 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 32316f393..ee87a64f3 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.playlist.PlaylistInfo; 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 final class PlaylistPlayQueue extends AbstractInfoPlayQueue 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 52882a972..b051f9168 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 @@ -33,12 +33,16 @@ import org.schabi.newpipe.error.ErrorInfo import org.schabi.newpipe.error.ErrorUtil import org.schabi.newpipe.error.UserAction import org.schabi.newpipe.extractor.InfoItem +import org.schabi.newpipe.extractor.channel.ChannelInfoItem +import org.schabi.newpipe.extractor.playlist.PlaylistInfoItem 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 @@ -258,14 +262,37 @@ data class LongPressAction( item: PlaylistRemoteEntity, onDelete: Runnable, ): List { - return buildShareActionList( - item.orderingName ?: "", - item.url ?: "", - item.thumbnailUrl - ) + + return buildPlayerActionList { PlaylistPlayQueue(item.serviceId, item.url) } + + buildShareActionList( + item.orderingName ?: "", + item.orderingName ?: "", + 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 644783d78..a29e39a99 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,6 +5,8 @@ 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.ListExtractor +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 @@ -92,5 +94,29 @@ data class LongPressable( item.streamCount ?: ListExtractor.ITEM_COUNT_UNKNOWN ), ) + + @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 4c604985e1ef8d5a6dd0dc21e6454492e1d745ec Mon Sep 17 00:00:00 2001 From: Stypox Date: Sun, 17 Aug 2025 13:16:15 +0200 Subject: [PATCH 24/87] 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 6e2950729..69053ffaa 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 0347fd9bf0c5df38d5908e66660b759e8d5ac0a8 Mon Sep 17 00:00:00 2001 From: Stypox Date: Wed, 27 Aug 2025 19:50:27 +0200 Subject: [PATCH 25/87] 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 69053ffaa..40e0daed6 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 b50db477a1ff65f3106c209bb1116fc22192331c Mon Sep 17 00:00:00 2001 From: Stypox Date: Thu, 28 Aug 2025 17:39:56 +0200 Subject: [PATCH 26/87] 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 b051f9168..3ec82630b 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 @@ -44,6 +44,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 @@ -60,8 +63,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 000000000..fd5972fcc --- /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 000000000..160317f4a --- /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 000000000..471593868 --- /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 af161f338..0c320fdf7 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 4b90295aab6ec0f91e392c3c1052c30ff47ef9fa Mon Sep 17 00:00:00 2001 From: Stypox Date: Thu, 28 Aug 2025 18:26:50 +0200 Subject: [PATCH 27/87] Uniform localizing view counts --- .../fragments/detail/VideoDetailFragment.kt | 11 +-- .../holder/StreamInfoItemHolder.java | 13 +--- .../newpipe/local/feed/item/StreamItem.kt | 24 +++---- .../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 ++++++++++++------- 8 files changed, 79 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 ae5800ab3..aa3fad60c 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 @@ -1424,14 +1424,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 80f62eed3..84ee2742a 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 android.widget.TextView; 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 class StreamInfoItemHolder extends StreamMiniInfoItemHolder { 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 258a67a4c..970e8f09c 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,23 +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/holder/LocalStatisticStreamItemHolder.java b/app/src/main/java/org/schabi/newpipe/local/holder/LocalStatisticStreamItemHolder.java index f26a76ad9..dd8edfa66 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 @@ public class LocalStatisticStreamItemHolder extends LocalItemHolder { 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 d744b700d..b200a4b45 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 @@ -37,16 +37,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 40e0daed6..141903143 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?, ) { @@ -50,6 +51,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), @@ -63,6 +65,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), @@ -77,6 +80,7 @@ data class LongPressable( uploader = null, uploaderUrl = null, viewCount = null, + streamType = null, uploadDate = null, decoration = Decoration.Playlist(item.streamCount), ) @@ -89,6 +93,7 @@ data class LongPressable( uploader = item.uploader, uploaderUrl = null, viewCount = null, + streamType = null, uploadDate = null, decoration = Decoration.Playlist( item.streamCount ?: ListExtractor.ITEM_COUNT_UNKNOWN @@ -103,6 +108,7 @@ data class LongPressable( uploader = null, uploaderUrl = item.url?.takeIf { it.isNotBlank() }, viewCount = null, + streamType = null, uploadDate = null, decoration = null, ) @@ -115,6 +121,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 c44423290..fc19e578b 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.ContentCountry; 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 final class Localization { 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 final class Localization { } } - 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 final class Localization { } } - 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 67778718e0394ee36961f1167ce453dde9917755 Mon Sep 17 00:00:00 2001 From: Stypox Date: Thu, 28 Aug 2025 18:52:46 +0200 Subject: [PATCH 28/87] 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 bafe1d554..93dc21112 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 @@ public abstract class BaseListFragment extends BaseStateFragment 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 c3755ab65..634e2520a 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 @@ public class PlaylistFragment extends BaseListInfoFragment 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 b20fa330b..5cf574bd4 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 @@ -402,7 +402,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 d31e6bf57..759d242e5 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 @@ public class StatisticsPlaylistFragment 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 5ae7ce158..19703d6e9 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 @@ -793,9 +793,9 @@ public class LocalPlaylistFragment extends BaseLocalListFragment 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 f4fbfb716..3078a4aff 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 3ec82630b..40dead56e 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 @@ -111,6 +111,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 -> @@ -133,13 +147,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 -> @@ -205,18 +224,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) @@ -236,11 +257,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 91d5e4882c3b733ee1d961c55f38f6eaeb9432a5 Mon Sep 17 00:00:00 2001 From: Stypox Date: Thu, 28 Aug 2025 19:41:42 +0200 Subject: [PATCH 29/87] 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 5cf574bd4..30f47a613 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 @@ -73,6 +73,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 @@ -384,7 +385,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, @@ -401,9 +402,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 } @@ -575,7 +598,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 970e8f09c..ac092d8f1 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 89fa03a526ec3394df3f53022b340e6cc37cf1aa Mon Sep 17 00:00:00 2001 From: Stypox Date: Thu, 28 Aug 2025 19:56:18 +0200 Subject: [PATCH 30/87] 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 feb23b6ac..855b28907 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.BaseListInfoFragment; 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 class ChannelTabFragment extends BaseListInfoFragment 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 89dcb6fef3068d417b8daf1fa71ff146ef668342 Mon Sep 17 00:00:00 2001 From: Stypox Date: Fri, 29 Aug 2025 13:00:19 +0200 Subject: [PATCH 31/87] 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 fd5972fcc..573aa445c 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 160317f4a..0af14bbe3 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 471593868..b33648a96 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 9f4730e61f2dc0e239471b293f28fbbdf41114d8 Mon Sep 17 00:00:00 2001 From: Stypox Date: Fri, 29 Aug 2025 13:26:54 +0200 Subject: [PATCH 32/87] Implement long pressing on subscriptions --- .../fragments/list/BaseListFragment.java | 2 +- .../subscription/SubscriptionFragment.kt | 43 +++++-------------- .../ui/components/menu/LongPressAction.kt | 15 ++++++- 3 files changed, 24 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 93dc21112..9aecd487d 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 abstract class BaseListFragment extends BaseStateFragment 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 045148844..2a6d7bfb6 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 @@ -31,7 +29,6 @@ import java.util.Date import java.util.Locale 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 @@ -56,12 +53,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 class SubscriptionFragment : BaseStateFragment() { private var _binding: FragmentSubscriptionBinding? = null @@ -334,36 +333,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 40dead56e..2b02d45b1 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 @@ -79,6 +79,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 @@ -302,7 +303,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( @@ -314,7 +318,14 @@ data class LongPressAction( item.name, ) }, - ) + ) + + ( + onUnsubscribe + ?.let { onUnsubscribe -> + listOf(Type.Unsubscribe.buildAction { onUnsubscribe.run() }) + } + ?: listOf() + ) } @JvmStatic From 701e8c59c5d7cfb07d6188313812a4df96bd241a Mon Sep 17 00:00:00 2001 From: Stypox Date: Sun, 19 Oct 2025 11:15:32 +0200 Subject: [PATCH 33/87] 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 01f50e454..8c9c1a1eb 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 @@ -37,7 +37,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 162c9ce565b23b457e7b3c8ed02c1582cfc8127d Mon Sep 17 00:00:00 2001 From: Stypox Date: Sun, 19 Oct 2025 11:21:34 +0200 Subject: [PATCH 34/87] 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 2b02d45b1..977f3158b 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 @@ -309,7 +309,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, @@ -318,14 +318,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 23c2de7c221163279e0e36565fdfe68209f9d193 Mon Sep 17 00:00:00 2001 From: Stypox Date: Tue, 21 Oct 2025 23:52:03 +0200 Subject: [PATCH 35/87] 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 141903143..e959ce615 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 000000000..57de24269 --- /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 9e1c3406a10e27ce24c1c444226c8437abab0e3f Mon Sep 17 00:00:00 2001 From: Stypox Date: Tue, 21 Oct 2025 23:53:15 +0200 Subject: [PATCH 36/87] Implement LongPressMenuEditor UI (still not persisted) --- .../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 + 4 files changed, 462 insertions(+), 2 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/ui/DetectDragModifier.kt b/app/src/main/java/org/schabi/newpipe/ui/DetectDragModifier.kt new file mode 100644 index 000000000..813734c94 --- /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 977f3158b..c695738a2 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 @@ -63,10 +63,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), @@ -89,6 +89,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 000000000..a0a1cbf23 --- /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 0c320fdf7..1f6e765fe 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 From a3af6e20ce801cf06af635ced0a0a4d43ff2ea72 Mon Sep 17 00:00:00 2001 From: Stypox Date: Wed, 22 Oct 2025 00:33:10 +0200 Subject: [PATCH 37/87] Add Back content description to toolbar back buttons --- app/src/main/java/org/schabi/newpipe/ui/Toolbar.kt | 4 ++-- .../newpipe/ui/components/common/ScaffoldWithToolbar.kt | 4 +++- app/src/main/res/values/strings.xml | 1 + 3 files changed, 6 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 d3a20bb02..40a1458af 100644 --- a/app/src/main/java/org/schabi/newpipe/ui/Toolbar.kt +++ b/app/src/main/java/org/schabi/newpipe/ui/Toolbar.kt @@ -38,8 +38,8 @@ fun TextAction(text: String, modifier: Modifier = Modifier) { fun NavigationIcon() { Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Back", - modifier = Modifier.padding(horizontal = SizeTokens.SpacingExtraSmall) + 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 4780e78a3..82b3ffaf6 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 1f6e765fe..e8025732a 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 0cc63347af435e0af6db3ac982b5e09b402fa364 Mon Sep 17 00:00:00 2001 From: Stypox Date: Wed, 22 Oct 2025 01:11:30 +0200 Subject: [PATCH 38/87] 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 813734c94..1a5c6e5dc 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 e959ce615..361631c6a 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 a0a1cbf23..f5ca4551f 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 e8025732a..446c1d664 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 6396c97c9a990c66e5e123d96136bab7598aae7b Mon Sep 17 00:00:00 2001 From: Stypox Date: Thu, 23 Oct 2025 00:54:54 +0200 Subject: [PATCH 39/87] Rewrite LongPressMenuEditor logic --- .../ui/components/menu/LongPressMenuEditor.kt | 398 +++++++++--------- app/src/main/res/values/strings.xml | 2 +- 2 files changed, 208 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 f5ca4551f..65817a88c 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,67 @@ 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 +212,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, + 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(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) - } - 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 = false) + data class DragMarker(override val columnSpan: Int?) : ItemInList(isDraggable = false) + + 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 +293,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 +327,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 +402,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 446c1d664..5620f9382 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 e350b10b146c074fcaaee6534ba1531b377d25e6 Mon Sep 17 00:00:00 2001 From: Stypox Date: Thu, 23 Oct 2025 03:11:14 +0200 Subject: [PATCH 40/87] Make LongPressMenuEditor work with DPAD / Android TV --- .../ui/components/menu/LongPressMenu.kt | 19 +- .../ui/components/menu/LongPressMenuEditor.kt | 242 ++++++++++++++---- 2 files changed, 201 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 361631c6a..1ac1e08eb 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 65817a88c..9eb1b0666 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,21 @@ 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) +// TODO padding doesn't seem to work as expected when the list becomes scrollable? +// TODO does Android TV auto-scroll to the selected item when the list becomes scrollable? @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 +137,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 +148,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,19 +163,27 @@ 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 @@ -179,6 +196,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) { @@ -199,44 +228,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) { @@ -306,7 +444,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 f0c324835fe5a42a2346aee842a2b4e9956362e9 Mon Sep 17 00:00:00 2001 From: Stypox Date: Thu, 23 Oct 2025 03:37:10 +0200 Subject: [PATCH 41/87] 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 9eb1b0666..3f0a494f7 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 @@ -171,9 +171,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 } From 444aba2c384df97b03f091a4eefa44bb077540c4 Mon Sep 17 00:00:00 2001 From: Stypox Date: Wed, 24 Dec 2025 15:50:18 +0100 Subject: [PATCH 42/87] Handle scrolling on Android TV --- .../ui/components/menu/LongPressMenuEditor.kt | 47 ++++++++++++++++--- 1 file changed, 40 insertions(+), 7 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 3f0a494f7..fafdf2df7 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 @@ -18,6 +18,7 @@ package org.schabi.newpipe.ui.components.menu +import android.util.Log import androidx.annotation.StringRes import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.border @@ -51,6 +52,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.runtime.toMutableStateList import androidx.compose.ui.Alignment @@ -75,16 +77,19 @@ 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 kotlinx.coroutines.launch 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.abs import kotlin.math.floor import kotlin.math.min +const val TAG = "LongPressMenuEditor" + // TODO padding doesn't seem to work as expected when the list becomes scrollable? -// TODO does Android TV auto-scroll to the selected item when the list becomes scrollable? @Composable fun LongPressMenuEditor(modifier: Modifier = Modifier) { // We get the current arrangement once and do not observe on purpose @@ -115,6 +120,7 @@ fun LongPressMenuEditor(modifier: Modifier = Modifier) { }.toList().toMutableStateList() } + val coroutineScope = rememberCoroutineScope() val gridState = rememberLazyGridState() var activeDragItem by remember { mutableStateOf(null) } var activeDragPosition by remember { mutableStateOf(IntOffset.Zero) } @@ -173,7 +179,7 @@ fun LongPressMenuEditor(modifier: Modifier = Modifier) { // make sure it is not possible to move items in between a *Caption and a HeaderBox 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 + if (rawItem.index in (prevDragMarkerIndex + 1).. if (event.type != KeyEventType.KeyDown) { @@ -246,7 +255,6 @@ fun LongPressMenuEditor(modifier: Modifier = Modifier) { event.key == Key.DirectionDown && currentlyFocusedItem < 0 ) { - // currentlyFocusedItem = 0 } return@onKeyEvent false @@ -259,6 +267,7 @@ fun LongPressMenuEditor(modifier: Modifier = Modifier) { } else if (items[focusedItem].columnSpan == null) { focusedItem -= 1 } else { + // go to the previous line var remaining = columns while (true) { focusedItem -= 1 @@ -276,9 +285,10 @@ fun LongPressMenuEditor(modifier: Modifier = Modifier) { Key.DirectionDown -> { if (focusedItem >= items.size - 1) { return@onKeyEvent false - } else if (items[focusedItem].columnSpan == null) { + } else if (focusedItem < 0 || items[focusedItem].columnSpan == null) { focusedItem += 1 } else { + // go to the next line var remaining = columns while (true) { focusedItem += 1 @@ -332,13 +342,36 @@ fun LongPressMenuEditor(modifier: Modifier = Modifier) { // then there would be no indication of the current cursor position at all. completeDragGestureAndCleanUp() return@onKeyEvent false + } else if (focusedItem >= items.size) { + Log.w(TAG, "Invalid focusedItem $focusedItem: >= items size ${items.size}") + } + + val rawItem = gridState.layoutInfo.visibleItemsInfo + .minByOrNull { abs(it.index - focusedItem) } + ?: return@onKeyEvent false // no item is visible at all, impossible case + + // If the item we are going to focus is not visible or is close to the boundary, + // scroll to it. Note that this will cause the "drag item" to appear misplaced, + // since the drag item's position is set to the position of the focused item + // before scrolling. However, it's not worth overcomplicating the logic just for + // correcting the position of a drag hint on Android TVs. + val h = rawItem.size.height + if (rawItem.index != focusedItem || + rawItem.offset.y <= gridState.layoutInfo.viewportStartOffset + 0.8 * h || + rawItem.offset.y + 1.8 * h >= gridState.layoutInfo.viewportEndOffset + ) { + coroutineScope.launch { + gridState.scrollToItem(focusedItem, -(0.8 * h).toInt()) + } } val dragItem = activeDragItem if (dragItem != null) { - val rawItem = gridState.layoutInfo.visibleItemsInfo - .firstOrNull { it.index == focusedItem } - ?: return@onKeyEvent false + // This will mostly bring the drag item to the right position, but will + // misplace it if the view just scrolled (see above), or if the DragMarker's + // position is moved past HiddenCaption by handleDragGestureChange() below. + // However, it's not worth overcomplicating the logic just for correcting + // the position of a drag hint on Android TVs. activeDragPosition = rawItem.offset handleDragGestureChange(dragItem, rawItem) } From 3d6c37a5132908ddc3f569c79855fc5a80b3be5d Mon Sep 17 00:00:00 2001 From: Stypox Date: Wed, 24 Dec 2025 16:28:44 +0100 Subject: [PATCH 43/87] Autoscroll when dragging close to border --- .../ui/components/menu/LongPressMenuEditor.kt | 53 ++++++++++++++++++- 1 file changed, 52 insertions(+), 1 deletion(-) 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 fafdf2df7..9f60b356a 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 @@ -22,6 +22,7 @@ import android.util.Log import androidx.annotation.StringRes import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.border +import androidx.compose.foundation.gestures.scrollBy import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth @@ -33,6 +34,7 @@ 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.LazyGridState import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.itemsIndexed import androidx.compose.foundation.lazy.grid.rememberLazyGridState @@ -49,6 +51,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -77,6 +80,9 @@ 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 kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import org.schabi.newpipe.R import org.schabi.newpipe.ui.components.menu.LongPressAction.Type.Companion.DefaultEnabledActions @@ -85,6 +91,7 @@ import org.schabi.newpipe.ui.theme.AppTheme import org.schabi.newpipe.util.text.FixedHeightCenteredText import kotlin.math.abs import kotlin.math.floor +import kotlin.math.max import kotlin.math.min const val TAG = "LongPressMenuEditor" @@ -120,12 +127,15 @@ fun LongPressMenuEditor(modifier: Modifier = Modifier) { }.toList().toMutableStateList() } - val coroutineScope = rememberCoroutineScope() + // variables for handling drag, focus, and autoscrolling when finger is at top/bottom val gridState = rememberLazyGridState() 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) } + val coroutineScope = rememberCoroutineScope() + var autoScrollJob by remember { mutableStateOf(null) } + var autoScrollSpeed by remember { mutableFloatStateOf(0f) } // -1, 0 or 1 fun findItemForOffsetOrClosestInRow(offset: IntOffset): LazyGridItemInfo? { var closestItemInRow: LazyGridItemInfo? = null @@ -143,6 +153,7 @@ fun LongPressMenuEditor(modifier: Modifier = Modifier) { return closestItemInRow } + // called not just for drag gestures initiated by moving the finger, but also with DPAD's Enter fun beginDragGesture(pos: IntOffset, rawItem: LazyGridItemInfo) { if (activeDragItem != null) return val item = items.getOrNull(rawItem.index) ?: return @@ -154,11 +165,22 @@ fun LongPressMenuEditor(modifier: Modifier = Modifier) { } } + // this beginDragGesture() overload is only called when moving the finger (not on DPAD's Enter) fun beginDragGesture(pos: IntOffset) { val rawItem = findItemForOffsetOrClosestInRow(pos) ?: return beginDragGesture(pos, rawItem) + autoScrollSpeed = 0f + autoScrollJob = coroutineScope.launch { + while (isActive) { + if (autoScrollSpeed != 0f) { + gridState.scrollBy(autoScrollSpeed) + } + delay(16L) // roughly 60 FPS + } + } } + // called not just for drag gestures by moving the finger, but also with DPAD's events 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 @@ -202,6 +224,8 @@ fun LongPressMenuEditor(modifier: Modifier = Modifier) { } } + // this handleDragGestureChange() overload is only called when moving the finger + // (not on DPAD's events) fun handleDragGestureChange(pos: IntOffset, posChangeForScrolling: Offset) { val dragItem = activeDragItem if (dragItem == null) { @@ -209,12 +233,18 @@ fun LongPressMenuEditor(modifier: Modifier = Modifier) { gridState.dispatchRawDelta(-posChangeForScrolling.y) return } + autoScrollSpeed = autoScrollSpeedFromTouchPos(pos, gridState) activeDragPosition = pos val rawItem = findItemForOffsetOrClosestInRow(pos) ?: return handleDragGestureChange(dragItem, rawItem) } + // called in multiple places both, e.g. when the finger stops touching, or with DPAD events fun completeDragGestureAndCleanUp() { + autoScrollJob?.cancel() + autoScrollJob = null + autoScrollSpeed = 0f + val dragItem = activeDragItem if (dragItem != null) { val dragMarkerIndex = items.indexOfFirst { it is ItemInList.DragMarker } @@ -410,6 +440,27 @@ fun LongPressMenuEditor(modifier: Modifier = Modifier) { } } +fun autoScrollSpeedFromTouchPos( + touchPos: IntOffset, + gridState: LazyGridState, + maxSpeed: Float = 20f, + scrollIfCloseToBorderPercent: Float = 0.1f, +): Float { + val heightPosRatio = touchPos.y.toFloat() / + (gridState.layoutInfo.viewportEndOffset - gridState.layoutInfo.viewportStartOffset) + // just a linear piecewise function, sets higher speeds the closer the finger is to the border + return maxSpeed * max( + // proportionally positive speed when close to the bottom border + (heightPosRatio - 1) / scrollIfCloseToBorderPercent + 1, + min( + // proportionally negative speed when close to the top border + heightPosRatio / scrollIfCloseToBorderPercent - 1, + // don't scroll at all if not close to any border + 0f + ) + ) +} + 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 */) From 032a853072fc0452070bc6e4e786de9f4a248df3 Mon Sep 17 00:00:00 2001 From: Stypox Date: Wed, 24 Dec 2025 23:59:13 +0100 Subject: [PATCH 44/87] Fix long press menu on DPAD clicks onEditActions right after opened Also see the comment --- .../newpipe/ui/components/menu/LongPressMenu.kt | 17 ++++++++++++++++- 1 file changed, 16 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 1ac1e08eb..8204d1dc9 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 @@ -8,6 +8,7 @@ import android.content.res.Configuration import android.view.ViewGroup import android.view.ViewGroup.LayoutParams import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusable import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -50,6 +51,7 @@ 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.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.ComposeView @@ -122,7 +124,7 @@ fun LongPressMenu( longPressActions: List, onDismissRequest: () -> Unit, ) { - var showEditor by rememberSaveable(key = longPressable.url) { mutableStateOf(false) } + var showEditor by rememberSaveable { mutableStateOf(false) } val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) if (showEditor) { @@ -253,9 +255,22 @@ private fun LongPressMenuContent( @Composable fun LongPressMenuDragHandle(onEditActions: () -> Unit) { + var showFocusTrap by remember { mutableStateOf(true) } + Box( modifier = Modifier.fillMaxWidth() ) { + if (showFocusTrap) { + // Just a focus trap to make sure the button below (onEditActions) is not the button + // that is first focused when opening the view. That would be a problem on Android TVs + // with DPAD, where the long press menu is opened by long pressing on stuff, and the UP + // event of the long press would click the button below if it were the first focused. + // This way we create a focus trap which disappears as soon as it is focused, leaving + // the focus to "nothing focused". Ideally it would be great to focus the first item in + // the long press menu, but then there would need to be a way to ignore the UP from the + // DPAD after an externally-triggered long press. + Box(Modifier.size(1.dp).focusable().onFocusChanged { showFocusTrap = !it.isFocused }) + } BottomSheetDefaults.DragHandle( modifier = Modifier.align(Alignment.Center) ) From f2a1a638db22fbf5a3ed4b1673903bcc1e0f3355 Mon Sep 17 00:00:00 2001 From: Stypox Date: Tue, 30 Dec 2025 18:27:07 +0100 Subject: [PATCH 45/87] DetectDragModifier now detects long-presses The long press initiates an item drag; otherwise the view just scrolls on drag. --- .../schabi/newpipe/ui/DetectDragModifier.kt | 48 +++++++++++++++++-- .../ui/components/menu/LongPressMenuEditor.kt | 12 +++-- app/src/main/res/values/strings.xml | 2 +- 3 files changed, 53 insertions(+), 9 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 1a5c6e5dc..ca844d855 100644 --- a/app/src/main/java/org/schabi/newpipe/ui/DetectDragModifier.kt +++ b/app/src/main/java/org/schabi/newpipe/ui/DetectDragModifier.kt @@ -1,37 +1,75 @@ package org.schabi.newpipe.ui +import android.view.MotionEvent 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.PointerEventTimeoutCancellationException +import androidx.compose.ui.input.pointer.changedToUp +import androidx.compose.ui.input.pointer.isOutOfBounds import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.positionChange import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.util.fastAll +import androidx.compose.ui.util.fastAny /** * 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 + * or strange behaviors when dragging stuff around quickly. Also detects whether a drag gesture + * began with a long press or not, which can be useful to decide whether an item should be dragged + * around (in case of long-press) or the view should be scrolled (otherwise). 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. + * pointer position and whether a long press was detected. * @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: (position: IntOffset) -> Unit, + beginDragGesture: (position: IntOffset, wasLongPressed: Boolean) -> Unit, handleDragGestureChange: (position: IntOffset, positionChange: Offset) -> Unit, endDragGesture: () -> Unit ): Modifier { return this.pointerInput(Unit) { awaitEachGesture { val down = awaitFirstDown() + val wasLongPressed = try { + // code in this branch was taken from AwaitPointerEventScope.waitForLongPress(), + // which unfortunately is private + withTimeout(viewConfiguration.longPressTimeoutMillis) { + while (true) { + val event = awaitPointerEvent() + if (event.changes.fastAll { it.changedToUp() }) { + // All pointers are up + break + } + + if (event.classification == MotionEvent.CLASSIFICATION_DEEP_PRESS) { + return@withTimeout true + } + + if ( + event.changes.fastAny { + it.isConsumed || it.isOutOfBounds(IntSize(0, 0), extendedTouchPadding) + } + ) { + break + } + } + return@withTimeout false + } + } catch (_: PointerEventTimeoutCancellationException) { + true + } + val pointerId = down.id - beginDragGesture(down.position.toIntOffset()) + beginDragGesture(down.position.toIntOffset(), wasLongPressed) while (true) { val change = awaitPointerEvent().changes.find { it.id == pointerId } if (change == null || !change.pressed) { 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 9f60b356a..5ff418913 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 @@ -94,7 +94,7 @@ import kotlin.math.floor import kotlin.math.max import kotlin.math.min -const val TAG = "LongPressMenuEditor" +internal const val TAG = "LongPressMenuEditor" // TODO padding doesn't seem to work as expected when the list becomes scrollable? @Composable @@ -166,7 +166,12 @@ fun LongPressMenuEditor(modifier: Modifier = Modifier) { } // this beginDragGesture() overload is only called when moving the finger (not on DPAD's Enter) - fun beginDragGesture(pos: IntOffset) { + fun beginDragGesture(pos: IntOffset, wasLongPressed: Boolean) { + if (!wasLongPressed) { + // items can be dragged around only if they are long-pressed; + // use the drag as scroll otherwise + return + } val rawItem = findItemForOffsetOrClosestInRow(pos) ?: return beginDragGesture(pos, rawItem) autoScrollSpeed = 0f @@ -229,7 +234,8 @@ fun LongPressMenuEditor(modifier: Modifier = Modifier) { 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 + // when the user clicks outside of any draggable item, or if the user did not long-press + // on an item to begin with, let the list be scrolled gridState.dispatchRawDelta(-posChangeForScrolling.y) return } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5620f9382..8d0ba0799 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -904,7 +904,7 @@ Popup\nfrom here Play\nfrom here Enabled actions: - Reorder the actions by dragging them around + Reorder the actions by long pressing and then dragging them around Hidden actions: Drag the header or the actions to this section to hide them Header From 35401e7ab4c88528c08bbd5a03a997de4d73297c Mon Sep 17 00:00:00 2001 From: Stypox Date: Tue, 30 Dec 2025 18:48:34 +0100 Subject: [PATCH 46/87] Distinguish between isDraggable and isCaption --- .../ui/components/menu/LongPressMenuEditor.kt | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 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 5ff418913..bf428a538 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 @@ -204,7 +204,7 @@ 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 - if (!items[i].isDraggable) i += 1 + if (items[i].isCaption) i += 1 if (i < items.size && items[i] == ItemInList.HeaderBox) i += 1 if (rawItem.index in (prevDragMarkerIndex + 1).. Date: Tue, 30 Dec 2025 19:01:11 +0100 Subject: [PATCH 47/87] Make channel link less attractive --- .../schabi/newpipe/ui/components/menu/LongPressMenu.kt | 8 +------- 1 file changed, 1 insertion(+), 7 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 8204d1dc9..5124809aa 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 @@ -455,13 +455,7 @@ fun getSubtitleAnnotatedString( ) = buildAnnotatedString { var shouldAddSeparator = false if (showLink) { - withStyle( - SpanStyle( - fontWeight = FontWeight.Bold, - color = linkColor, - textDecoration = TextDecoration.Underline - ) - ) { + withStyle(SpanStyle(color = linkColor)) { if (item.uploader.isNullOrBlank()) { append(ctx.getString(R.string.show_channel_details)) } else { From b9485488f87c1fd39f8b5c245c750db8fa5f9fc4 Mon Sep 17 00:00:00 2001 From: Stypox Date: Tue, 30 Dec 2025 21:42:56 +0100 Subject: [PATCH 48/87] Tune long press menu UI --- .../ui/components/menu/LongPressMenu.kt | 66 +++++++++++-------- .../ui/components/menu/LongPressMenuEditor.kt | 2 +- 2 files changed, 38 insertions(+), 30 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 5124809aa..9854443a1 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,8 +62,6 @@ 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 -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 @@ -117,6 +115,7 @@ fun getLongPressMenuView( } internal val MinButtonWidth = 86.dp +internal val ThumbnailHeight = 60.dp @Composable fun LongPressMenu( @@ -166,8 +165,12 @@ private fun LongPressMenuContent( .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 + // landscape aspect ratio, 1:1 square in the limit + val buttonHeight = MinButtonWidth + // max width for the portrait/full-width header, measured in button widths + val maxHeaderWidthInButtonsFullSpan = 5 + // width for the landscape/reduced header, measured in button widths + val headerWidthInButtonsReducedSpan = 4 val buttonsPerRow = (this.maxWidth / MinButtonWidth).toInt() // the channel icon goes in the menu header, so do not show a button for it @@ -217,33 +220,36 @@ private fun LongPressMenuContent( .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) + } else if (maxHeaderWidthInButtonsFullSpan >= buttonsPerRow) { + // this branch is taken if the full-span header is going to fit on one + // line (i.e. on phones in portrait) LongPressMenuHeader( item = longPressable, onUploaderClick = onUploaderClick, modifier = Modifier + .padding(start = 6.dp, end = 6.dp, bottom = 6.dp) // 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()), + .weight(maxHeaderWidthInButtonsFullSpan.toFloat()), ) - rowIndex += headerWidthInButtons + rowIndex += maxHeaderWidthInButtonsFullSpan } else { // this branch is taken if the header will have some buttons to its - // right (i.e. on tablets or on phones in landscape) + // right (i.e. on tablets, or on phones in landscape), and we have the + // header's reduced span be less than its full span so that when this + // branch is taken, at least two buttons will be on the right side of + // the header (just one button would look off). LongPressMenuHeader( item = longPressable, onUploaderClick = onUploaderClick, modifier = Modifier - .padding(6.dp) - .heightIn(min = 70.dp) + .padding(start = 8.dp, top = 11.dp, bottom = 11.dp) + .heightIn(min = ThumbnailHeight) .fillMaxWidth() - .weight(headerWidthInButtons.toFloat()), + .weight(headerWidthInButtonsReducedSpan.toFloat()), ) - rowIndex += headerWidthInButtons + rowIndex += headerWidthInButtonsReducedSpan } actionIndex += 1 } @@ -313,7 +319,7 @@ fun LongPressMenuHeader( val ctx = LocalContext.current Surface( - color = MaterialTheme.colorScheme.surfaceVariant, + color = MaterialTheme.colorScheme.surfaceContainer, contentColor = MaterialTheme.colorScheme.onSurfaceVariant, shape = MaterialTheme.shapes.large, modifier = modifier, @@ -327,8 +333,8 @@ fun LongPressMenuHeader( placeholder = painterResource(R.drawable.placeholder_thumbnail_video), error = painterResource(R.drawable.placeholder_thumbnail_video), modifier = Modifier - .height(70.dp) - .widthIn(max = 125.dp) // 16:9 thumbnail at most + .height(ThumbnailHeight) + .widthIn(max = ThumbnailHeight * 16 / 9) // 16:9 thumbnail at most .clip(MaterialTheme.shapes.large) ) } @@ -380,7 +386,7 @@ fun LongPressMenuHeader( contentColor = Color.White, modifier = Modifier .align(Alignment.TopEnd) - .size(width = 40.dp, height = 70.dp) + .size(width = 40.dp, height = ThumbnailHeight) .clip(MaterialTheme.shapes.large), ) { Column( @@ -409,7 +415,7 @@ fun LongPressMenuHeader( } Column( - modifier = Modifier.padding(vertical = 12.dp), + modifier = Modifier.padding(vertical = 8.dp), ) { Text( text = item.title, @@ -657,14 +663,16 @@ private fun LongPressMenuPreview( 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") - LongPressMenuContent( - longPressable = longPressable ?: LongPressablePreviews().values.first(), - longPressActions = LongPressAction.Type.entries - // disable Enqueue actions just to show it off - .map { t -> t.buildAction({ t != EnqueueNext }) { } }, - onDismissRequest = {}, - ) + Surface(color = MaterialTheme.colorScheme.surfaceContainerLow) { + // longPressable is null when running the preview in an emulator for some reason... + @Suppress("USELESS_ELVIS") + LongPressMenuContent( + longPressable = longPressable ?: LongPressablePreviews().values.first(), + longPressActions = LongPressAction.Type.entries + // disable Enqueue actions just to show it off + .map { t -> t.buildAction({ t != EnqueueNext }) { } }, + 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 bf428a538..70b57d526 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 @@ -450,7 +450,7 @@ fun autoScrollSpeedFromTouchPos( touchPos: IntOffset, gridState: LazyGridState, maxSpeed: Float = 20f, - scrollIfCloseToBorderPercent: Float = 0.1f, + scrollIfCloseToBorderPercent: Float = 0.2f, ): Float { val heightPosRatio = touchPos.y.toFloat() / (gridState.layoutInfo.viewportEndOffset - gridState.layoutInfo.viewportStartOffset) From cf4bfa522e492e775e00a97b7d285b51807a2f55 Mon Sep 17 00:00:00 2001 From: Stypox Date: Tue, 30 Dec 2025 22:37:53 +0100 Subject: [PATCH 49/87] Make it clearer when items are being dragged under the finger --- .../newpipe/ui/components/menu/LongPressMenuEditor.kt | 7 +++++-- 1 file changed, 5 insertions(+), 2 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 70b57d526..bfa9a7248 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 android.util.Log import androidx.annotation.StringRes import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.gestures.scrollBy import androidx.compose.foundation.layout.BoxWithConstraints @@ -431,16 +432,18 @@ fun LongPressMenuEditor(modifier: Modifier = Modifier) { } } if (activeDragItem != null) { + // draw it the same size as the selected item, val size = with(LocalDensity.current) { remember(activeDragSize) { activeDragSize.toSize().toDpSize() } } ItemInListUi( item = activeDragItem!!, - selected = false, + selected = true, modifier = Modifier .size(size) .offset { activeDragPosition } - .offset(-size.width / 2, -size.height / 2), + .offset(-size.width / 2, -size.height / 2) + .offset((-24).dp, (-24).dp), ) } } From 4d8cdc46815d5d8720067805d6d5ed1c0b152fbd Mon Sep 17 00:00:00 2001 From: Stypox Date: Tue, 6 Jan 2026 17:47:31 +0100 Subject: [PATCH 50/87] Fix strange animations when quickly reording items --- .../newpipe/ui/components/menu/LongPressMenuEditor.kt | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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 bfa9a7248..0e758eb1a 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 @@ -427,7 +427,12 @@ fun LongPressMenuEditor(modifier: Modifier = Modifier) { ItemInListUi( item = item, selected = currentlyFocusedItem == i, - modifier = Modifier.animateItem() + // We only want placement animations: fade in/out animations interfere with + // items being replaced by a drag marker while being dragged around, and a fade + // in/out animation there does not make sense as the item was just "picked up". + // Furthermore there are strange moving animation artifacts when moving and + // releasing items quickly before their fade-out animation finishes. + modifier = Modifier.animateItem(fadeInSpec = null, fadeOutSpec = null) ) } } From 1a42f300f22f46e9deb6ba37815c1c44d5af87bb Mon Sep 17 00:00:00 2001 From: Stypox Date: Tue, 6 Jan 2026 20:30:59 +0100 Subject: [PATCH 51/87] Improve some strings and add some comments --- .../ui/components/menu/LongPressMenuEditor.kt | 21 +++++++++++++++---- app/src/main/res/values/strings.xml | 4 ++-- 2 files changed, 19 insertions(+), 6 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 0e758eb1a..9594866be 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,7 +21,6 @@ package org.schabi.newpipe.ui.components.menu import android.util.Log import androidx.annotation.StringRes import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.gestures.scrollBy import androidx.compose.foundation.layout.BoxWithConstraints @@ -97,7 +96,20 @@ import kotlin.math.min internal const val TAG = "LongPressMenuEditor" -// TODO padding doesn't seem to work as expected when the list becomes scrollable? +/** + * When making changes to this composable, make sure to test the following use cases still work: + * - both the actions and the header can be dragged around + * - the header can only be dragged to the first position in each section + * - when a section is empty the None marker will appear + * - actions and header are loaded from and stored to settings properly + * - it is possible to move items around using DPAD on Android TVs, and there are no strange bugs + * - when dragging items around, a Drag marker appears at the would-be position of the item being + * dragged, and the item being dragged is "picked up" and shown below the user's finger (at an + * offset to ensure the user can see the thing being dragged under their finger) + * - when the view does not fit the page, it is possible to scroll without moving any item, and + * dragging an item towards the top/bottom of the page scrolls up/down + * @author This composable was originally copied from FlorisBoard. + */ @Composable fun LongPressMenuEditor(modifier: Modifier = Modifier) { // We get the current arrangement once and do not observe on purpose @@ -604,9 +616,9 @@ private fun ItemInListUi( modifier = modifier, selected = selected, icon = Icons.Default.ArtTrack, - text = R.string.header, + text = R.string.long_press_menu_header, contentColor = MaterialTheme.colorScheme.onSurfaceVariant, - backgroundColor = MaterialTheme.colorScheme.surfaceVariant, + backgroundColor = MaterialTheme.colorScheme.surfaceContainer, horizontalPadding = 12.dp, ) } @@ -633,6 +645,7 @@ private fun ItemInListUi( } @Preview +@Preview(device = "spec:width=1080px,height=1000px,dpi=440") @Composable private fun LongPressMenuEditorPreview() { AppTheme { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8d0ba0799..7644ed5ef 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -904,10 +904,10 @@ Popup\nfrom here Play\nfrom here Enabled actions: - Reorder the actions by long pressing and then dragging them around + Reorder the actions by long pressing them and then dragging them around Hidden actions: Drag the header or the actions to this section to hide them - Header + Header with title, thumbnail, and clickable channel Back Reorder and hide actions From 3d62b923c7c9db1bdef8807040055819641f5c7d Mon Sep 17 00:00:00 2001 From: Stypox Date: Tue, 6 Jan 2026 21:31:25 +0100 Subject: [PATCH 52/87] Separate @Composables from state logic for actions editor --- .../main/java/org/schabi/newpipe/ktx/Scope.kt | 12 + .../ui/components/menu/LongPressMenuEditor.kt | 396 +---------------- .../menu/LongPressMenuEditorState.kt | 401 ++++++++++++++++++ 3 files changed, 430 insertions(+), 379 deletions(-) create mode 100644 app/src/main/java/org/schabi/newpipe/ktx/Scope.kt create mode 100644 app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressMenuEditorState.kt diff --git a/app/src/main/java/org/schabi/newpipe/ktx/Scope.kt b/app/src/main/java/org/schabi/newpipe/ktx/Scope.kt new file mode 100644 index 000000000..decf1c3a6 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/ktx/Scope.kt @@ -0,0 +1,12 @@ +package org.schabi.newpipe.ktx + +/** + * Especially useful to apply some Compose Modifiers only if some condition is met. E.g. + * ```kt + * Modifier + * .padding(left = 4.dp) + * .letIf(someCondition) { padding(right = 4.dp) } + * ``` + */ +inline fun T.letIf(condition: Boolean, block: T.() -> T): T = + if (condition) block(this) else this 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 9594866be..b337ade5a 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 @@ -18,11 +18,9 @@ package org.schabi.newpipe.ui.components.menu -import android.util.Log import androidx.annotation.StringRes import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.border -import androidx.compose.foundation.gestures.scrollBy import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth @@ -33,8 +31,6 @@ 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.LazyGridState import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.itemsIndexed import androidx.compose.foundation.lazy.grid.rememberLazyGridState @@ -50,25 +46,14 @@ 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.mutableFloatStateOf -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -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 @@ -76,28 +61,20 @@ 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 kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch import org.schabi.newpipe.R -import org.schabi.newpipe.ui.components.menu.LongPressAction.Type.Companion.DefaultEnabledActions +import org.schabi.newpipe.ktx.letIf import org.schabi.newpipe.ui.detectDragGestures import org.schabi.newpipe.ui.theme.AppTheme import org.schabi.newpipe.util.text.FixedHeightCenteredText -import kotlin.math.abs import kotlin.math.floor -import kotlin.math.max -import kotlin.math.min internal const val TAG = "LongPressMenuEditor" /** - * When making changes to this composable, make sure to test the following use cases still work: + * When making changes to this composable and to [LongPressMenuEditorState], make sure to test the + * following use cases, and check that they still work: * - both the actions and the header can be dragged around * - the header can only be dragged to the first position in each section * - when a section is empty the None marker will appear @@ -112,174 +89,15 @@ internal const val TAG = "LongPressMenuEditor" */ @Composable fun LongPressMenuEditor(modifier: Modifier = Modifier) { - // We get the current arrangement once and do not observe on purpose - // TODO load from settings - val headerEnabled = remember { true } - val actionArrangement = remember { 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() - } - - // variables for handling drag, focus, and autoscrolling when finger is at top/bottom val gridState = rememberLazyGridState() - 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) } val coroutineScope = rememberCoroutineScope() - var autoScrollJob by remember { mutableStateOf(null) } - var autoScrollSpeed by remember { mutableFloatStateOf(0f) } // -1, 0 or 1 - - 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 - } - - // called not just for drag gestures initiated by moving the finger, but also with DPAD's Enter - 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) - activeDragItem = item - activeDragPosition = pos - activeDragSize = rawItem.size - } - } - - // this beginDragGesture() overload is only called when moving the finger (not on DPAD's Enter) - fun beginDragGesture(pos: IntOffset, wasLongPressed: Boolean) { - if (!wasLongPressed) { - // items can be dragged around only if they are long-pressed; - // use the drag as scroll otherwise - return - } - val rawItem = findItemForOffsetOrClosestInRow(pos) ?: return - beginDragGesture(pos, rawItem) - autoScrollSpeed = 0f - autoScrollJob = coroutineScope.launch { - while (isActive) { - if (autoScrollSpeed != 0f) { - gridState.scrollBy(autoScrollSpeed) - } - delay(16L) // roughly 60 FPS - } - } - } - - // called not just for drag gestures by moving the finger, but also with DPAD's events - 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) - val nextDragMarkerIndex = if (dragItem == ItemInList.HeaderBox) { - 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].isCaption) i += 1 - if (i < items.size && items[i] == ItemInList.HeaderBox) i += 1 - if (rawItem.index in (prevDragMarkerIndex + 1)..= 0) { - items[dragMarkerIndex] = dragItem - } - } - activeDragItem = null - activeDragPosition = IntOffset.Zero - activeDragSize = IntSize.Zero + val state = remember(gridState, coroutineScope) { + LongPressMenuEditorState(gridState, coroutineScope) } DisposableEffect(Unit) { onDispose { - completeDragGestureAndCleanUp() - // TODO save to settings + state.onDispose() } } @@ -292,153 +110,26 @@ fun LongPressMenuEditor(modifier: Modifier = Modifier) { modifier = Modifier .safeDrawingPadding() .detectDragGestures( - beginDragGesture = ::beginDragGesture, - handleDragGestureChange = ::handleDragGestureChange, - endDragGesture = ::completeDragGestureAndCleanUp, + beginDragGesture = state::beginDragGesture, + handleDragGestureChange = state::handleDragGestureChange, + endDragGesture = state::completeDragGestureAndCleanUp, ) - // this huge .focusTarget().onKeyEvent() block just handles DPAD on Android TVs + // `.focusTarget().onKeyEvent()` handles DPAD on Android TVs .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 { - // go to the previous line - 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 (focusedItem < 0 || items[focusedItem].columnSpan == null) { - focusedItem += 1 - } else { - // go to the next line - 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 - } else if (focusedItem >= items.size) { - Log.w(TAG, "Invalid focusedItem $focusedItem: >= items size ${items.size}") - } - - val rawItem = gridState.layoutInfo.visibleItemsInfo - .minByOrNull { abs(it.index - focusedItem) } - ?: return@onKeyEvent false // no item is visible at all, impossible case - - // If the item we are going to focus is not visible or is close to the boundary, - // scroll to it. Note that this will cause the "drag item" to appear misplaced, - // since the drag item's position is set to the position of the focused item - // before scrolling. However, it's not worth overcomplicating the logic just for - // correcting the position of a drag hint on Android TVs. - val h = rawItem.size.height - if (rawItem.index != focusedItem || - rawItem.offset.y <= gridState.layoutInfo.viewportStartOffset + 0.8 * h || - rawItem.offset.y + 1.8 * h >= gridState.layoutInfo.viewportEndOffset - ) { - coroutineScope.launch { - gridState.scrollToItem(focusedItem, -(0.8 * h).toInt()) - } - } - - val dragItem = activeDragItem - if (dragItem != null) { - // This will mostly bring the drag item to the right position, but will - // misplace it if the view just scrolled (see above), or if the DragMarker's - // position is moved past HiddenCaption by handleDragGestureChange() below. - // However, it's not worth overcomplicating the logic just for correcting - // the position of a drag hint on Android TVs. - activeDragPosition = rawItem.offset - handleDragGestureChange(dragItem, rawItem) - } - return@onKeyEvent true - }, + .onKeyEvent { event -> state.onKeyEvent(event, columns) }, // same width as the LongPressMenu columns = GridCells.Adaptive(MinButtonWidth), userScrollEnabled = false, state = gridState, ) { itemsIndexed( - items, + state.items, key = { _, item -> item.stableUniqueKey() }, span = { _, item -> GridItemSpan(item.columnSpan ?: maxLineSpan) }, ) { i, item -> ItemInListUi( item = item, - selected = currentlyFocusedItem == i, + selected = state.currentlyFocusedItem == i, // We only want placement animations: fade in/out animations interfere with // items being replaced by a drag marker while being dragged around, and a fade // in/out animation there does not make sense as the item was just "picked up". @@ -448,17 +139,17 @@ fun LongPressMenuEditor(modifier: Modifier = Modifier) { ) } } - if (activeDragItem != null) { + state.activeDragItem?.let { activeDragItem -> // draw it the same size as the selected item, val size = with(LocalDensity.current) { - remember(activeDragSize) { activeDragSize.toSize().toDpSize() } + remember(state.activeDragSize) { state.activeDragSize.toSize().toDpSize() } } ItemInListUi( - item = activeDragItem!!, + item = activeDragItem, selected = true, modifier = Modifier .size(size) - .offset { activeDragPosition } + .offset { state.activeDragPosition } .offset(-size.width / 2, -size.height / 2) .offset((-24).dp, (-24).dp), ) @@ -466,59 +157,6 @@ fun LongPressMenuEditor(modifier: Modifier = Modifier) { } } -fun autoScrollSpeedFromTouchPos( - touchPos: IntOffset, - gridState: LazyGridState, - maxSpeed: Float = 20f, - scrollIfCloseToBorderPercent: Float = 0.2f, -): Float { - val heightPosRatio = touchPos.y.toFloat() / - (gridState.layoutInfo.viewportEndOffset - gridState.layoutInfo.viewportStartOffset) - // just a linear piecewise function, sets higher speeds the closer the finger is to the border - return maxSpeed * max( - // proportionally positive speed when close to the bottom border - (heightPosRatio - 1) / scrollIfCloseToBorderPercent + 1, - min( - // proportionally negative speed when close to the top border - heightPosRatio / scrollIfCloseToBorderPercent - 1, - // don't scroll at all if not close to any border - 0f - ) - ) -} - -sealed class ItemInList( - val isDraggable: Boolean = false, - val isCaption: Boolean = false, - open val columnSpan: Int? = 1, -) { - // decoration items (i.e. text subheaders) - object EnabledCaption : ItemInList(isCaption = true, columnSpan = null /* i.e. all line */) - object HiddenCaption : ItemInList(isCaption = true, columnSpan = null /* i.e. all line */) - - // 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() - data class DragMarker(override val columnSpan: Int?) : ItemInList() - - fun stableUniqueKey(): Int { - return when (this) { - is Action -> this.type.ordinal - 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( selected: Boolean, diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressMenuEditorState.kt b/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressMenuEditorState.kt new file mode 100644 index 000000000..3eaa7a086 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressMenuEditorState.kt @@ -0,0 +1,401 @@ +package org.schabi.newpipe.ui.components.menu + +import android.util.Log +import androidx.compose.foundation.gestures.scrollBy +import androidx.compose.foundation.lazy.grid.LazyGridItemInfo +import androidx.compose.foundation.lazy.grid.LazyGridState +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.runtime.toMutableStateList +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEvent +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.type +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import org.schabi.newpipe.ui.components.menu.LongPressAction.Type.Companion.DefaultEnabledActions +import kotlin.math.abs +import kotlin.math.max +import kotlin.math.min + +/** + * This class is very tied to [LongPressMenuEditor] and interacts with the UI layer through + * [gridState]. Therefore it's not a view model but rather a state holder class, see + * https://developer.android.com/topic/architecture/ui-layer/stateholders#ui-logic. + * + * See the javadoc of [LongPressMenuEditor] to understand which behaviors you should test for when + * changing this class. + */ +@Stable +class LongPressMenuEditorState( + val gridState: LazyGridState, + val coroutineScope: CoroutineScope, +) { + // We get the current arrangement once and do not observe on purpose + val items = run { + // TODO load from settings + val headerEnabled = true + val actionArrangement = DefaultEnabledActions + 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() + } + + // variables for handling drag, focus, and autoscrolling when finger is at top/bottom + var activeDragItem by mutableStateOf(null) + var activeDragPosition by mutableStateOf(IntOffset.Zero) + var activeDragSize by mutableStateOf(IntSize.Zero) + var currentlyFocusedItem by mutableIntStateOf(-1) + var autoScrollJob by mutableStateOf(null) + var autoScrollSpeed by mutableFloatStateOf(0f) + + private 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 + } + + private fun autoScrollSpeedFromTouchPos( + touchPos: IntOffset, + maxSpeed: Float = 20f, + scrollIfCloseToBorderPercent: Float = 0.2f, + ): Float { + val heightPosRatio = touchPos.y.toFloat() / + (gridState.layoutInfo.viewportEndOffset - gridState.layoutInfo.viewportStartOffset) + // just a linear piecewise function, sets higher speeds the closer the finger is to the border + return maxSpeed * max( + // proportionally positive speed when close to the bottom border + (heightPosRatio - 1) / scrollIfCloseToBorderPercent + 1, + min( + // proportionally negative speed when close to the top border + heightPosRatio / scrollIfCloseToBorderPercent - 1, + // don't scroll at all if not close to any border + 0f + ) + ) + } + + /** + * Called not just for drag gestures initiated by moving the finger, but also with DPAD's Enter. + */ + private 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) + activeDragItem = item + activeDragPosition = pos + activeDragSize = rawItem.size + } + } + + /** + * This beginDragGesture() overload is only called when moving the finger (not on DPAD's Enter). + */ + fun beginDragGesture(pos: IntOffset, wasLongPressed: Boolean) { + if (!wasLongPressed) { + // items can be dragged around only if they are long-pressed; + // use the drag as scroll otherwise + return + } + val rawItem = findItemForOffsetOrClosestInRow(pos) ?: return + beginDragGesture(pos, rawItem) + autoScrollSpeed = 0f + autoScrollJob = coroutineScope.launch { + while (isActive) { + if (autoScrollSpeed != 0f) { + gridState.scrollBy(autoScrollSpeed) + } + delay(16L) // roughly 60 FPS + } + } + } + + /** + * Called not just for drag gestures by moving the finger, but also with DPAD's events. + */ + private 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) + val nextDragMarkerIndex = if (dragItem == ItemInList.HeaderBox) { + 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].isCaption) i += 1 + if (i < items.size && items[i] == ItemInList.HeaderBox) i += 1 + if (rawItem.index in (prevDragMarkerIndex + 1).. + val dragMarkerIndex = items.indexOfFirst { it is ItemInList.DragMarker } + if (dragMarkerIndex >= 0) { + items[dragMarkerIndex] = dragItem + } + } + activeDragItem = null + activeDragPosition = IntOffset.Zero + activeDragSize = IntSize.Zero + } + + /** + * Handles DPAD events on Android TVs. + */ + fun onKeyEvent(event: KeyEvent, columns: Int): Boolean { + if (event.type != KeyEventType.KeyDown) { + if (event.type == KeyEventType.KeyUp && + event.key == Key.DirectionDown && + currentlyFocusedItem < 0 + ) { + currentlyFocusedItem = 0 + } + return false + } + var focusedItem = currentlyFocusedItem + when (event.key) { + Key.DirectionUp -> { + if (focusedItem < 0) { + return false + } else if (items[focusedItem].columnSpan == null) { + focusedItem -= 1 + } else { + // go to the previous line + 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 false + } else if (focusedItem < 0 || items[focusedItem].columnSpan == null) { + focusedItem += 1 + } else { + // go to the next line + 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 false + } else { + focusedItem -= 1 + } + } + + Key.DirectionRight -> { + if (focusedItem >= items.size - 1) { + return false + } else { + focusedItem += 1 + } + } + + Key.Enter, Key.NumPadEnter, Key.DirectionCenter -> if (activeDragItem == null) { + val rawItem = gridState.layoutInfo.visibleItemsInfo + .firstOrNull { it.index == focusedItem } + ?: return false + beginDragGesture(rawItem.offset, rawItem) + return true + } else { + completeDragGestureAndCleanUp() + return true + } + + else -> return 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 false + } else if (focusedItem >= items.size) { + Log.w(TAG, "Invalid focusedItem $focusedItem: >= items size ${items.size}") + } + + val rawItem = gridState.layoutInfo.visibleItemsInfo + .minByOrNull { abs(it.index - focusedItem) } + ?: return false // no item is visible at all, impossible case + + // If the item we are going to focus is not visible or is close to the boundary, + // scroll to it. Note that this will cause the "drag item" to appear misplaced, + // since the drag item's position is set to the position of the focused item + // before scrolling. However, it's not worth overcomplicating the logic just for + // correcting the position of a drag hint on Android TVs. + val h = rawItem.size.height + if (rawItem.index != focusedItem || + rawItem.offset.y <= gridState.layoutInfo.viewportStartOffset + 0.8 * h || + rawItem.offset.y + 1.8 * h >= gridState.layoutInfo.viewportEndOffset + ) { + coroutineScope.launch { + gridState.scrollToItem(focusedItem, -(0.8 * h).toInt()) + } + } + + val dragItem = activeDragItem + if (dragItem != null) { + // This will mostly bring the drag item to the right position, but will + // misplace it if the view just scrolled (see above), or if the DragMarker's + // position is moved past HiddenCaption by handleDragGestureChange() below. + // However, it's not worth overcomplicating the logic just for correcting + // the position of a drag hint on Android TVs. + activeDragPosition = rawItem.offset + handleDragGestureChange(dragItem, rawItem) + } + return true + } + + fun onDispose() { + completeDragGestureAndCleanUp() + // TODO save to settings + } +} + +sealed class ItemInList( + val isDraggable: Boolean = false, + val isCaption: Boolean = false, + open val columnSpan: Int? = 1, +) { + // decoration items (i.e. text subheaders) + object EnabledCaption : ItemInList(isCaption = true, columnSpan = null /* i.e. all line */) + object HiddenCaption : ItemInList(isCaption = true, columnSpan = null /* i.e. all line */) + + // 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() + data class DragMarker(override val columnSpan: Int?) : ItemInList() + + fun stableUniqueKey(): Int { + return when (this) { + is Action -> this.type.ordinal + 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) + } + } +} From 85cb372f5f90551868092059ade3fd45a9a4d13f Mon Sep 17 00:00:00 2001 From: Stypox Date: Tue, 6 Jan 2026 22:30:56 +0100 Subject: [PATCH 53/87] Persist long press actions to settings --- .../ui/components/menu/LongPressAction.kt | 47 ++++++++------- .../ui/components/menu/LongPressMenuEditor.kt | 8 +-- .../menu/LongPressMenuEditorState.kt | 38 ++++++++---- .../components/menu/LongPressMenuSettings.kt | 58 +++++++++++++++++++ app/src/main/res/values/settings_keys.xml | 3 + .../ui/components/menu/LongPressActionTest.kt | 12 ++++ 6 files changed, 130 insertions(+), 36 deletions(-) create mode 100644 app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressMenuSettings.kt create mode 100644 app/src/test/java/org/schabi/newpipe/ui/components/menu/LongPressActionTest.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 c695738a2..506f6da0c 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 @@ -57,29 +57,34 @@ data class LongPressAction( val enabled: (isPlayerRunning: Boolean) -> Boolean = { true }, ) { enum class Type( + /** + * A unique ID that allows saving and restoring a list of action types from settings. + * MUST NOT CHANGE ACROSS APP VERSIONS! + */ + val id: Int, @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), - 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), - 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), - 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), + Enqueue(0, R.string.enqueue, Icons.Default.AddToQueue), + EnqueueNext(1, R.string.enqueue_next_stream, Icons.Default.QueuePlayNext), + Background(2, R.string.controls_background_title, Icons.Default.Headset), + Popup(3, R.string.controls_popup_title, Icons.Default.PictureInPicture), + Play(4, R.string.play, Icons.Default.PlayArrow), + BackgroundFromHere(5, R.string.background_from_here, Icons.Default.BackgroundFromHere), + PopupFromHere(6, R.string.popup_from_here, Icons.Default.PopupFromHere), + PlayFromHere(7, R.string.play_from_here, Icons.Default.PlayFromHere), + PlayWithKodi(8, R.string.play_with_kodi_title, Icons.Default.Cast), + Download(9, R.string.download, Icons.Default.Download), + AddToPlaylist(10, R.string.add_to_playlist, Icons.AutoMirrored.Default.PlaylistAdd), + Share(11, R.string.share, Icons.Default.Share), + OpenInBrowser(12, R.string.open_in_browser, Icons.Default.OpenInBrowser), + ShowChannelDetails(13, R.string.show_channel_details, Icons.Default.Person), + MarkAsWatched(14, R.string.mark_as_watched, Icons.Default.Done), + Delete(15, R.string.delete, Icons.Default.Delete), + Rename(16, R.string.rename, Icons.Default.Edit), + SetAsPlaylistThumbnail(17, R.string.set_as_playlist_thumbnail, Icons.Default.Image), + UnsetPlaylistThumbnail(18, R.string.unset_playlist_thumbnail, Icons.Default.HideImage), + Unsubscribe(19, R.string.unsubscribe, Icons.Default.Delete), ; // TODO allow actions to return disposables @@ -93,7 +98,7 @@ data class LongPressAction( 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( + val DefaultEnabledActions: List = listOf( Enqueue, EnqueueNext, Background, Popup, BackgroundFromHere, Download, AddToPlaylist, Share, OpenInBrowser, MarkAsWatched, Delete, Rename, SetAsPlaylistThumbnail, UnsetPlaylistThumbnail, Unsubscribe 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 b337ade5a..d705654a9 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 @@ -54,6 +54,7 @@ import androidx.compose.ui.focus.focusTarget import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontStyle @@ -70,8 +71,6 @@ import org.schabi.newpipe.ui.theme.AppTheme import org.schabi.newpipe.util.text.FixedHeightCenteredText import kotlin.math.floor -internal const val TAG = "LongPressMenuEditor" - /** * When making changes to this composable and to [LongPressMenuEditorState], make sure to test the * following use cases, and check that they still work: @@ -89,15 +88,16 @@ internal const val TAG = "LongPressMenuEditor" */ @Composable fun LongPressMenuEditor(modifier: Modifier = Modifier) { + val context = LocalContext.current val gridState = rememberLazyGridState() val coroutineScope = rememberCoroutineScope() val state = remember(gridState, coroutineScope) { - LongPressMenuEditorState(gridState, coroutineScope) + LongPressMenuEditorState(context, gridState, coroutineScope) } DisposableEffect(Unit) { onDispose { - state.onDispose() + state.onDispose(context) } } diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressMenuEditorState.kt b/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressMenuEditorState.kt index 3eaa7a086..f33f434c0 100644 --- a/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressMenuEditorState.kt +++ b/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressMenuEditorState.kt @@ -1,5 +1,6 @@ package org.schabi.newpipe.ui.components.menu +import android.content.Context import android.util.Log import androidx.compose.foundation.gestures.scrollBy import androidx.compose.foundation.lazy.grid.LazyGridItemInfo @@ -24,11 +25,12 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.launch -import org.schabi.newpipe.ui.components.menu.LongPressAction.Type.Companion.DefaultEnabledActions import kotlin.math.abs import kotlin.math.max import kotlin.math.min +private const val TAG = "LongPressMenuEditorStat" + /** * This class is very tied to [LongPressMenuEditor] and interacts with the UI layer through * [gridState]. Therefore it's not a view model but rather a state holder class, see @@ -39,33 +41,33 @@ import kotlin.math.min */ @Stable class LongPressMenuEditorState( + context: Context, val gridState: LazyGridState, val coroutineScope: CoroutineScope, ) { - // We get the current arrangement once and do not observe on purpose val items = run { - // TODO load from settings - val headerEnabled = true - val actionArrangement = DefaultEnabledActions + // We get the current arrangement once and do not observe on purpose. + val isHeaderEnabled = loadIsHeaderEnabledFromSettings(context) + val actionArrangement = loadLongPressActionArrangementFromSettings(context) sequence { yield(ItemInList.EnabledCaption) - if (headerEnabled) { + if (isHeaderEnabled) { yield(ItemInList.HeaderBox) } yieldAll( actionArrangement .map { ItemInList.Action(it) } - .ifEmpty { if (headerEnabled) listOf() else listOf(ItemInList.NoneMarker) } + .ifEmpty { if (isHeaderEnabled) listOf() else listOf(ItemInList.NoneMarker) } ) yield(ItemInList.HiddenCaption) - if (!headerEnabled) { + if (!isHeaderEnabled) { yield(ItemInList.HeaderBox) } yieldAll( LongPressAction.Type.entries .filter { !actionArrangement.contains(it) } .map { ItemInList.Action(it) } - .ifEmpty { if (headerEnabled) listOf(ItemInList.NoneMarker) else listOf() } + .ifEmpty { if (isHeaderEnabled) listOf(ItemInList.NoneMarker) else listOf() } ) }.toList().toMutableStateList() } @@ -365,9 +367,23 @@ class LongPressMenuEditorState( return true } - fun onDispose() { + fun onDispose(context: Context) { completeDragGestureAndCleanUp() - // TODO save to settings + + var isHeaderEnabled = false + val actionArrangement = ArrayList() + // All of the items before the HiddenCaption are enabled. + for (item in items) { + when (item) { + is ItemInList.Action -> actionArrangement.add(item.type) + ItemInList.HeaderBox -> isHeaderEnabled = true + ItemInList.HiddenCaption -> break + else -> {} + } + } + + storeIsHeaderEnabledToSettings(context, isHeaderEnabled) + storeLongPressActionArrangementToSettings(context, actionArrangement) } } diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressMenuSettings.kt b/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressMenuSettings.kt new file mode 100644 index 000000000..be14125dc --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressMenuSettings.kt @@ -0,0 +1,58 @@ +package org.schabi.newpipe.ui.components.menu + +import android.content.Context +import android.util.Log +import androidx.core.content.edit +import androidx.preference.PreferenceManager +import org.schabi.newpipe.R + +private const val TAG: String = "LongPressMenuSettings" + +fun loadIsHeaderEnabledFromSettings(context: Context): Boolean { + val key = context.getString(R.string.long_press_menu_is_header_enabled_key) + return PreferenceManager.getDefaultSharedPreferences(context).getBoolean(key, true) +} + +fun storeIsHeaderEnabledToSettings(context: Context, enabled: Boolean) { + val key = context.getString(R.string.long_press_menu_is_header_enabled_key) + return PreferenceManager.getDefaultSharedPreferences(context).edit { + putBoolean(key, enabled) + } +} + +fun loadLongPressActionArrangementFromSettings(context: Context): List { + val key = context.getString(R.string.long_press_menu_action_arrangement_key) + val items = PreferenceManager.getDefaultSharedPreferences(context) + .getString(key, null) + if (items == null) { + return LongPressAction.Type.DefaultEnabledActions + } + + try { + val actions = items.split(',') + .map { item -> + LongPressAction.Type.entries.first { entry -> + entry.id.toString() == item + } + } + + // In case there is some bug in the stored data, make sure we don't return duplicate items, + // as that would break/crash the UI and also not make any sense. + val actionsDistinct = actions.distinct() + if (actionsDistinct.size != actions.size) { + Log.w(TAG, "Actions in settings were not distinct: $actions != $actionsDistinct") + } + return actionsDistinct + } catch (e: NoSuchElementException) { + Log.e(TAG, "Invalid action in settings", e) + return LongPressAction.Type.DefaultEnabledActions + } +} + +fun storeLongPressActionArrangementToSettings(context: Context, actions: List) { + val items = actions.joinToString(separator = ",") { it.id.toString() } + val key = context.getString(R.string.long_press_menu_action_arrangement_key) + PreferenceManager.getDefaultSharedPreferences(context).edit { + putString(key, items) + } +} diff --git a/app/src/main/res/values/settings_keys.xml b/app/src/main/res/values/settings_keys.xml index f33f5eef7..cce08ad3c 100644 --- a/app/src/main/res/values/settings_keys.xml +++ b/app/src/main/res/values/settings_keys.xml @@ -366,6 +366,9 @@ show_thumbnail_key + long_press_menu_action_arrangement + long_press_menu_is_header_enabled + @string/feed_update_threshold_option_always_update diff --git a/app/src/test/java/org/schabi/newpipe/ui/components/menu/LongPressActionTest.kt b/app/src/test/java/org/schabi/newpipe/ui/components/menu/LongPressActionTest.kt new file mode 100644 index 000000000..26812088a --- /dev/null +++ b/app/src/test/java/org/schabi/newpipe/ui/components/menu/LongPressActionTest.kt @@ -0,0 +1,12 @@ +package org.schabi.newpipe.ui.components.menu + +import org.junit.Assert.assertEquals +import org.junit.Test + +class LongPressActionTest { + @Test + fun `LongPressAction Type ids are unique`() { + val ids = LongPressAction.Type.entries.map { it.id } + assertEquals(ids.size, ids.toSet().size) + } +} From c62004d9030e39e358831bee1c9585a015d85ec8 Mon Sep 17 00:00:00 2001 From: Stypox Date: Tue, 6 Jan 2026 23:39:12 +0100 Subject: [PATCH 54/87] Load settings in LongPressMenu too --- .../main/java/org/schabi/newpipe/ktx/List.kt | 9 --- .../ui/components/menu/LongPressAction.kt | 12 ++-- .../ui/components/menu/LongPressMenu.kt | 62 +++++++++++++------ .../components/menu/LongPressMenuViewModel.kt | 50 +++++++++++++++ 4 files changed, 98 insertions(+), 35 deletions(-) delete mode 100644 app/src/main/java/org/schabi/newpipe/ktx/List.kt create mode 100644 app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressMenuViewModel.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 deleted file mode 100644 index 0dd41bb6e..000000000 --- a/app/src/main/java/org/schabi/newpipe/ktx/List.kt +++ /dev/null @@ -1,9 +0,0 @@ -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/LongPressAction.kt b/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressAction.kt index 506f6da0c..19cd612f8 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,6 +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 @@ -21,6 +22,7 @@ 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 androidx.core.net.toUri import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import org.schabi.newpipe.R import org.schabi.newpipe.database.playlist.PlaylistMetadataEntry @@ -49,6 +51,7 @@ import org.schabi.newpipe.ui.components.menu.icons.PlayFromHere import org.schabi.newpipe.ui.components.menu.icons.PopupFromHere import org.schabi.newpipe.util.NavigationHelper import org.schabi.newpipe.util.SparseItemUtil +import org.schabi.newpipe.util.external_communication.KoreUtils import org.schabi.newpipe.util.external_communication.ShareUtils data class LongPressAction( @@ -228,13 +231,10 @@ data class LongPressAction( .observeOn(AndroidSchedulers.mainThread()) .subscribe() }, + Type.PlayWithKodi.buildAction { context -> + KoreUtils.playWithKore(context, item.url.toUri()) + }, ) - /* TODO handle kodi - + if (isKodiEnabled) listOf( - Type.PlayWithKodi.buildAction { context -> - KoreUtils.playWithKore(context, Uri.parse(item.url)) - }, - ) else listOf()*/ } @JvmStatic 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 9854443a1..5857f1e77 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 @@ -43,6 +43,8 @@ import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -70,10 +72,10 @@ 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 androidx.lifecycle.viewmodel.compose.viewModel 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 @@ -123,6 +125,9 @@ fun LongPressMenu( longPressActions: List, onDismissRequest: () -> Unit, ) { + val viewModel: LongPressMenuViewModel = viewModel() + val isHeaderEnabled by viewModel.isHeaderEnabled.collectAsState() + val actionArrangement by viewModel.actionArrangement.collectAsState() var showEditor by rememberSaveable { mutableStateOf(false) } val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) @@ -140,14 +145,39 @@ fun LongPressMenu( } } } else { + val enabledLongPressActions by remember { + derivedStateOf { + actionArrangement.mapNotNull { type -> + longPressActions.firstOrNull { it.type == type } + } + } + } + + // show a clickable uploader in the header if an uploader action is available and the + // "show channel details" action is not enabled as a standalone action + val ctx = LocalContext.current + val onUploaderClick by remember { + derivedStateOf { + longPressActions.firstOrNull { it.type == ShowChannelDetails } + ?.takeIf { !actionArrangement.contains(ShowChannelDetails) } + ?.let { showChannelDetailsAction -> + { + showChannelDetailsAction.action(ctx) + onDismissRequest() + } + } + } + } + ModalBottomSheet( sheetState = sheetState, onDismissRequest = onDismissRequest, dragHandle = { LongPressMenuDragHandle(onEditActions = { showEditor = true }) }, ) { LongPressMenuContent( - longPressable = longPressable, - longPressActions = longPressActions, + header = longPressable.takeIf { isHeaderEnabled }, + onUploaderClick = onUploaderClick, + actions = enabledLongPressActions, onDismissRequest = onDismissRequest, ) } @@ -156,8 +186,9 @@ fun LongPressMenu( @Composable private fun LongPressMenuContent( - longPressable: LongPressable, - longPressActions: List, + header: LongPressable?, + onUploaderClick: (() -> Unit)?, + actions: List, onDismissRequest: () -> Unit, ) { BoxWithConstraints( @@ -172,20 +203,10 @@ private fun LongPressMenuContent( // width for the landscape/reduced header, measured in button widths val headerWidthInButtonsReducedSpan = 4 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 + var actionIndex = if (header != null) -1 else 0 // -1 indicates the header while (actionIndex < actions.size) { Row( verticalAlignment = Alignment.CenterVertically, @@ -224,7 +245,7 @@ private fun LongPressMenuContent( // this branch is taken if the full-span header is going to fit on one // line (i.e. on phones in portrait) LongPressMenuHeader( - item = longPressable, + item = header!!, // surely not null since actionIndex < 0 onUploaderClick = onUploaderClick, modifier = Modifier .padding(start = 6.dp, end = 6.dp, bottom = 6.dp) @@ -241,7 +262,7 @@ private fun LongPressMenuContent( // branch is taken, at least two buttons will be on the right side of // the header (just one button would look off). LongPressMenuHeader( - item = longPressable, + item = header!!, // surely not null since actionIndex < 0 onUploaderClick = onUploaderClick, modifier = Modifier .padding(start = 8.dp, top = 11.dp, bottom = 11.dp) @@ -667,8 +688,9 @@ private fun LongPressMenuPreview( // longPressable is null when running the preview in an emulator for some reason... @Suppress("USELESS_ELVIS") LongPressMenuContent( - longPressable = longPressable ?: LongPressablePreviews().values.first(), - longPressActions = LongPressAction.Type.entries + header = longPressable ?: LongPressablePreviews().values.first(), + onUploaderClick = {}, + actions = LongPressAction.Type.entries // disable Enqueue actions just to show it off .map { t -> t.buildAction({ t != EnqueueNext }) { } }, onDismissRequest = {}, diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressMenuViewModel.kt b/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressMenuViewModel.kt new file mode 100644 index 000000000..eb9b57db1 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressMenuViewModel.kt @@ -0,0 +1,50 @@ +package org.schabi.newpipe.ui.components.menu + +import android.content.Context +import android.content.SharedPreferences +import androidx.lifecycle.ViewModel +import androidx.preference.PreferenceManager +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import org.schabi.newpipe.App +import org.schabi.newpipe.R + +/** + * Since view models can't have access to the UI's Context, we use [App.instance] instead to fetch + * shared preferences. This is not the best but won't be needed anyway once we will have a Hilt + * injected repository that provides access to a modern alternative to shared preferences. The whole + * thing with the shared preference listener will not be necessary with the modern alternative. + */ +class LongPressMenuViewModel : ViewModel() { + private val _isHeaderEnabled = MutableStateFlow( + loadIsHeaderEnabledFromSettings(App.instance) + ) + val isHeaderEnabled: StateFlow = _isHeaderEnabled.asStateFlow() + + private val _actionArrangement = MutableStateFlow( + loadLongPressActionArrangementFromSettings(App.instance) + ) + val actionArrangement: StateFlow> = _actionArrangement.asStateFlow() + + private val prefs = PreferenceManager.getDefaultSharedPreferences(App.instance) + private val isHeaderEnabledKey = + App.instance.getString(R.string.long_press_menu_is_header_enabled_key) + private val actionArrangementKey = + App.instance.getString(R.string.long_press_menu_action_arrangement_key) + private val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key -> + if (key == isHeaderEnabledKey) { + _isHeaderEnabled.value = loadIsHeaderEnabledFromSettings(App.instance) + } else if (key == actionArrangementKey) { + _actionArrangement.value = loadLongPressActionArrangementFromSettings(App.instance) + } + } + + init { + prefs.registerOnSharedPreferenceChangeListener(listener) + } + + override fun onCleared() { + prefs.unregisterOnSharedPreferenceChangeListener(listener) + } +} From 8f19f95feed377e84ad6f97db5864faa60bd7ff5 Mon Sep 17 00:00:00 2001 From: Stypox Date: Mon, 2 Feb 2026 19:37:39 +0100 Subject: [PATCH 55/87] Show loading when action takes some time Convert RxJava3 calls to suspend functions, sometimes requiring .await() to bridge between the two Also migrate play queue items' popup menu to new long press menu Also do centralized error handling --- .../org/schabi/newpipe/QueueItemMenuUtil.java | 94 --------- .../org/schabi/newpipe/RouterActivity.java | 18 +- .../org/schabi/newpipe/error/UserAction.kt | 4 +- .../fragments/detail/VideoDetailFragment.kt | 2 +- .../list/playlist/PlaylistFragment.java | 18 +- .../newpipe/local/dialog/PlaylistDialog.java | 24 +-- .../newpipe/player/PlayQueueActivity.java | 11 +- .../newpipe/player/playqueue/PlayQueueItem.kt | 9 + .../newpipe/player/ui/MainPlayerUi.java | 11 +- ...ectDragModifier.kt => GestureModifiers.kt} | 21 ++ .../ui/components/menu/LongPressAction.kt | 196 ++++++++++-------- .../ui/components/menu/LongPressMenu.kt | 87 ++++++-- .../ui/components/menu/LongPressable.kt | 16 +- .../ui/components/menu/SparseItemUtil.kt | 106 ++++++++++ .../schabi/newpipe/util/SparseItemUtil.java | 127 ------------ 15 files changed, 375 insertions(+), 369 deletions(-) delete mode 100644 app/src/main/java/org/schabi/newpipe/QueueItemMenuUtil.java rename app/src/main/java/org/schabi/newpipe/ui/{DetectDragModifier.kt => GestureModifiers.kt} (84%) create mode 100644 app/src/main/java/org/schabi/newpipe/ui/components/menu/SparseItemUtil.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/util/SparseItemUtil.java diff --git a/app/src/main/java/org/schabi/newpipe/QueueItemMenuUtil.java b/app/src/main/java/org/schabi/newpipe/QueueItemMenuUtil.java deleted file mode 100644 index e6177f6a3..000000000 --- a/app/src/main/java/org/schabi/newpipe/QueueItemMenuUtil.java +++ /dev/null @@ -1,94 +0,0 @@ -package org.schabi.newpipe; - -import static org.schabi.newpipe.util.SparseItemUtil.fetchStreamInfoAndSaveToDatabase; -import static org.schabi.newpipe.util.external_communication.ShareUtils.shareText; - -import android.content.Context; -import android.view.ContextThemeWrapper; -import android.view.View; -import android.widget.PopupMenu; - -import androidx.fragment.app.FragmentManager; - -import org.schabi.newpipe.database.stream.model.StreamEntity; -import org.schabi.newpipe.download.DownloadDialog; -import org.schabi.newpipe.local.dialog.PlaylistDialog; -import org.schabi.newpipe.player.playqueue.PlayQueue; -import org.schabi.newpipe.player.playqueue.PlayQueueItem; -import org.schabi.newpipe.util.NavigationHelper; -import org.schabi.newpipe.util.SparseItemUtil; - -import java.util.List; - -public final class QueueItemMenuUtil { - private QueueItemMenuUtil() { - } - - public static void openPopupMenu(final PlayQueue playQueue, - final PlayQueueItem item, - final View view, - final boolean hideDetails, - final FragmentManager fragmentManager, - final Context context) { - final ContextThemeWrapper themeWrapper = - new ContextThemeWrapper(context, R.style.DarkPopupMenu); - - final PopupMenu popupMenu = new PopupMenu(themeWrapper, view); - popupMenu.inflate(R.menu.menu_play_queue_item); - - if (hideDetails) { - popupMenu.getMenu().findItem(R.id.menu_item_details).setVisible(false); - } - - popupMenu.setOnMenuItemClickListener(menuItem -> { - switch (menuItem.getItemId()) { - case R.id.menu_item_remove: - final int index = playQueue.indexOf(item); - playQueue.remove(index); - return true; - case R.id.menu_item_details: - // playQueue is null since we don't want any queue change - NavigationHelper.openVideoDetail(context, item.getServiceId(), - item.getUrl(), item.getTitle(), null, - false); - return true; - case R.id.menu_item_append_playlist: - PlaylistDialog.createCorrespondingDialog( - context, - List.of(new StreamEntity(item)), - dialog -> dialog.show( - fragmentManager, - "QueueItemMenuUtil@append_playlist" - ) - ); - - return true; - case R.id.menu_item_channel_details: - SparseItemUtil.fetchUploaderUrlIfSparse(context, item.getServiceId(), - item.getUrl(), item.getUploaderUrl(), - // An intent must be used here. - // Opening with FragmentManager transactions is not working, - // as PlayQueueActivity doesn't use fragments. - uploaderUrl -> NavigationHelper.openChannelFragmentUsingIntent( - context, item.getServiceId(), uploaderUrl, item.getUploader() - )); - return true; - case R.id.menu_item_share: - shareText(context, item.getTitle(), item.getUrl(), - item.getThumbnails()); - return true; - case R.id.menu_item_download: - fetchStreamInfoAndSaveToDatabase(context, item.getServiceId(), item.getUrl(), - info -> { - final DownloadDialog downloadDialog = new DownloadDialog(context, - info); - downloadDialog.show(fragmentManager, "downloadDialog"); - }); - return true; - } - return false; - }); - - popupMenu.show(); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/RouterActivity.java b/app/src/main/java/org/schabi/newpipe/RouterActivity.java index 6d3863018..587e94ebe 100644 --- a/app/src/main/java/org/schabi/newpipe/RouterActivity.java +++ b/app/src/main/java/org/schabi/newpipe/RouterActivity.java @@ -823,15 +823,15 @@ public class RouterActivity extends AppCompatActivity { .compose(this::pleaseWait) .subscribe( info -> getActivityContext().ifPresent(context -> - PlaylistDialog.createCorrespondingDialog(context, - List.of(new StreamEntity(info)), - playlistDialog -> runOnVisible(ctx -> { - // dismiss listener to be handled by FragmentManager - final FragmentManager fm = - ctx.getSupportFragmentManager(); - playlistDialog.show(fm, "addToPlaylistDialog"); - }) - )), + disposables.add( + PlaylistDialog.createCorrespondingDialog(context, + List.of(new StreamEntity(info))) + .subscribe(dialog -> runOnVisible(ctx -> { + // dismiss listener to be handled by FragmentManager + final FragmentManager fm = + ctx.getSupportFragmentManager(); + dialog.show(fm, "addToPlaylistDialog"); + })))), throwable -> runOnVisible(ctx -> handleError(ctx, new ErrorInfo( throwable, UserAction.REQUESTED_STREAM, "Tried to add " + currentUrl + " to a playlist", diff --git a/app/src/main/java/org/schabi/newpipe/error/UserAction.kt b/app/src/main/java/org/schabi/newpipe/error/UserAction.kt index b3f14e2da..1b0d35755 100644 --- a/app/src/main/java/org/schabi/newpipe/error/UserAction.kt +++ b/app/src/main/java/org/schabi/newpipe/error/UserAction.kt @@ -37,8 +37,8 @@ enum class UserAction(val message: String) { PREFERENCES_MIGRATION("migration of preferences"), SHARE_TO_NEWPIPE("share to newpipe"), CHECK_FOR_NEW_APP_VERSION("check for new app version"), - OPEN_INFO_ITEM_DIALOG("open info item dialog"), GETTING_MAIN_SCREEN_TAB("getting main screen tab"), PLAY_ON_POPUP("play on popup"), - SUBSCRIPTIONS("loading subscriptions") + SUBSCRIPTIONS("loading subscriptions"), + LONG_PRESS_MENU_ACTION("long press menu action"), } diff --git a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.kt b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.kt index aa3fad60c..6d8a20630 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.kt +++ b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.kt @@ -439,7 +439,7 @@ class VideoDetailFragment : PlaylistDialog.createCorrespondingDialog( requireContext(), listOf(StreamEntity(info)) - ) { dialog -> dialog.show(getParentFragmentManager(), TAG) } + ).subscribe { dialog -> dialog.show(getParentFragmentManager(), TAG) } ) } } diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/playlist/PlaylistFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/playlist/PlaylistFragment.java index 634e2520a..5cee18136 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/playlist/PlaylistFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/playlist/PlaylistFragment.java @@ -237,15 +237,15 @@ public class PlaylistFragment extends BaseListInfoFragment dialog.show(getFM(), TAG) - )); + disposables.add( + PlaylistDialog.createCorrespondingDialog( + getContext(), + getPlayQueue() + .getStreams() + .stream() + .map(StreamEntity::new) + .collect(Collectors.toList()) + ).subscribe(dialog -> dialog.show(getFM(), TAG))); } break; default: diff --git a/app/src/main/java/org/schabi/newpipe/local/dialog/PlaylistDialog.java b/app/src/main/java/org/schabi/newpipe/local/dialog/PlaylistDialog.java index 612c38181..ddc84e783 100644 --- a/app/src/main/java/org/schabi/newpipe/local/dialog/PlaylistDialog.java +++ b/app/src/main/java/org/schabi/newpipe/local/dialog/PlaylistDialog.java @@ -20,11 +20,11 @@ import org.schabi.newpipe.util.StateSaver; import java.util.List; import java.util.Objects; import java.util.Queue; -import java.util.function.Consumer; import java.util.stream.Collectors; import java.util.stream.Stream; import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; +import io.reactivex.rxjava3.core.Maybe; import io.reactivex.rxjava3.disposables.Disposable; public abstract class PlaylistDialog extends DialogFragment implements StateSaver.WriteRead { @@ -135,22 +135,18 @@ public abstract class PlaylistDialog extends DialogFragment implements StateSave * * @param context context used for accessing the database * @param streamEntities used for crating the dialog - * @param onExec execution that should occur after a dialog got created, e.g. showing it - * @return the disposable that was created + * @return the {@link Maybe} to subscribe to to obtain the correct {@link PlaylistDialog} */ - public static Disposable createCorrespondingDialog( + public static Maybe createCorrespondingDialog( final Context context, - final List streamEntities, - final Consumer onExec) { + final List streamEntities) { return new LocalPlaylistManager(NewPipeDatabase.getInstance(context)) .hasPlaylists() - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(hasPlaylists -> - onExec.accept(hasPlaylists - ? PlaylistAppendDialog.newInstance(streamEntities) - : PlaylistCreationDialog.newInstance(streamEntities)) - ); + .map(hasPlaylists -> hasPlaylists + ? PlaylistAppendDialog.newInstance(streamEntities) + : PlaylistCreationDialog.newInstance(streamEntities)) + .observeOn(AndroidSchedulers.mainThread()); } /** @@ -175,7 +171,7 @@ public abstract class PlaylistDialog extends DialogFragment implements StateSave return Disposable.empty(); } - return PlaylistDialog.createCorrespondingDialog(player.getContext(), streamEntities, - dialog -> dialog.show(fragmentManager, "PlaylistDialog")); + return PlaylistDialog.createCorrespondingDialog(player.getContext(), streamEntities) + .subscribe(dialog -> dialog.show(fragmentManager, "PlaylistDialog")); } } diff --git a/app/src/main/java/org/schabi/newpipe/player/PlayQueueActivity.java b/app/src/main/java/org/schabi/newpipe/player/PlayQueueActivity.java index 7f3a8dbd5..6d1da1bed 100644 --- a/app/src/main/java/org/schabi/newpipe/player/PlayQueueActivity.java +++ b/app/src/main/java/org/schabi/newpipe/player/PlayQueueActivity.java @@ -1,7 +1,7 @@ package org.schabi.newpipe.player; -import static org.schabi.newpipe.QueueItemMenuUtil.openPopupMenu; import static org.schabi.newpipe.player.helper.PlayerHelper.formatSpeed; +import static org.schabi.newpipe.ui.components.menu.LongPressMenuKt.openLongPressMenuInActivity; import android.content.ComponentName; import android.content.Intent; @@ -41,6 +41,8 @@ import org.schabi.newpipe.player.playqueue.PlayQueueItem; import org.schabi.newpipe.player.playqueue.PlayQueueItemBuilder; import org.schabi.newpipe.player.playqueue.PlayQueueItemHolder; import org.schabi.newpipe.player.playqueue.PlayQueueItemTouchCallback; +import org.schabi.newpipe.ui.components.menu.LongPressAction; +import org.schabi.newpipe.ui.components.menu.LongPressable; import org.schabi.newpipe.util.Localization; import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.PermissionHelper; @@ -328,8 +330,11 @@ public final class PlayQueueActivity extends AppCompatActivity @Override public void held(final PlayQueueItem item, final View view) { if (player != null && player.getPlayQueue().indexOf(item) != -1) { - openPopupMenu(player.getPlayQueue(), item, view, false, - getSupportFragmentManager(), PlayQueueActivity.this); + openLongPressMenuInActivity( + PlayQueueActivity.this, + LongPressable.fromPlayQueueItem(item), + LongPressAction.fromPlayQueueItem(item, player.getPlayQueue(), true) + ); } } diff --git a/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueItem.kt b/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueItem.kt index 0e7a3b90b..96e2578f5 100644 --- a/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueItem.kt +++ b/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueItem.kt @@ -65,6 +65,15 @@ class PlayQueueItem private constructor( .subscribeOn(Schedulers.io()) .doOnError { throwable -> error = throwable } + fun toStreamInfoItem(): StreamInfoItem { + val item = StreamInfoItem(serviceId, url, title, streamType) + item.duration = duration + item.thumbnails = thumbnails + item.uploaderName = uploader + item.uploaderUrl = uploaderUrl + return item + } + override fun equals(o: Any?) = o is PlayQueueItem && serviceId == o.serviceId && url == o.url override fun hashCode() = Objects.hash(url, serviceId) diff --git a/app/src/main/java/org/schabi/newpipe/player/ui/MainPlayerUi.java b/app/src/main/java/org/schabi/newpipe/player/ui/MainPlayerUi.java index 034e18368..717d1a7fd 100644 --- a/app/src/main/java/org/schabi/newpipe/player/ui/MainPlayerUi.java +++ b/app/src/main/java/org/schabi/newpipe/player/ui/MainPlayerUi.java @@ -2,7 +2,6 @@ package org.schabi.newpipe.player.ui; import static android.view.ViewGroup.LayoutParams.MATCH_PARENT; import static org.schabi.newpipe.MainActivity.DEBUG; -import static org.schabi.newpipe.QueueItemMenuUtil.openPopupMenu; import static org.schabi.newpipe.extractor.ServiceList.YouTube; import static org.schabi.newpipe.ktx.ViewUtils.animate; import static org.schabi.newpipe.player.Player.STATE_COMPLETED; @@ -14,6 +13,7 @@ import static org.schabi.newpipe.player.helper.PlayerHelper.getMinimizeOnExitAct import static org.schabi.newpipe.player.helper.PlayerHelper.getTimeString; import static org.schabi.newpipe.player.helper.PlayerHelper.globalScreenOrientationLocked; import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_PLAY_PAUSE; +import static org.schabi.newpipe.ui.components.menu.LongPressMenuKt.openLongPressMenuInActivity; import android.app.Activity; import android.content.Context; @@ -68,6 +68,8 @@ import org.schabi.newpipe.player.playqueue.PlayQueueItem; import org.schabi.newpipe.player.playqueue.PlayQueueItemBuilder; import org.schabi.newpipe.player.playqueue.PlayQueueItemHolder; import org.schabi.newpipe.player.playqueue.PlayQueueItemTouchCallback; +import org.schabi.newpipe.ui.components.menu.LongPressAction; +import org.schabi.newpipe.ui.components.menu.LongPressable; import org.schabi.newpipe.util.DeviceUtils; import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.external_communication.KoreUtils; @@ -795,8 +797,11 @@ public final class MainPlayerUi extends VideoPlayerUi implements View.OnLayoutCh @Nullable final PlayQueue playQueue = player.getPlayQueue(); @Nullable final AppCompatActivity parentActivity = getParentActivity().orElse(null); if (playQueue != null && parentActivity != null && playQueue.indexOf(item) != -1) { - openPopupMenu(player.getPlayQueue(), item, view, true, - parentActivity.getSupportFragmentManager(), context); + openLongPressMenuInActivity( + parentActivity, + LongPressable.fromPlayQueueItem(item), + LongPressAction.fromPlayQueueItem(item, playQueue, false) + ); } } diff --git a/app/src/main/java/org/schabi/newpipe/ui/DetectDragModifier.kt b/app/src/main/java/org/schabi/newpipe/ui/GestureModifiers.kt similarity index 84% rename from app/src/main/java/org/schabi/newpipe/ui/DetectDragModifier.kt rename to app/src/main/java/org/schabi/newpipe/ui/GestureModifiers.kt index ca844d855..164f28e72 100644 --- a/app/src/main/java/org/schabi/newpipe/ui/DetectDragModifier.kt +++ b/app/src/main/java/org/schabi/newpipe/ui/GestureModifiers.kt @@ -5,7 +5,9 @@ import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.pointer.PointerEventPass import androidx.compose.ui.input.pointer.PointerEventTimeoutCancellationException +import androidx.compose.ui.input.pointer.PointerInputChange import androidx.compose.ui.input.pointer.changedToUp import androidx.compose.ui.input.pointer.isOutOfBounds import androidx.compose.ui.input.pointer.pointerInput @@ -87,3 +89,22 @@ fun Modifier.detectDragGestures( } private fun Offset.toIntOffset() = IntOffset(this.x.toInt(), this.y.toInt()) + +/** + * Discards all touches on child composables. See https://stackoverflow.com/a/69146178. + * @param doDiscard whether this Modifier is active (touches discarded) or not (no effect). + */ +fun Modifier.discardAllTouchesIf(doDiscard: Boolean) = if (doDiscard) { + pointerInput(Unit) { + awaitPointerEventScope { + // we should wait for all new pointer events + while (true) { + awaitPointerEvent(pass = PointerEventPass.Initial) + .changes + .forEach(PointerInputChange::consume) + } + } + } +} else { + this +} diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressAction.kt b/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressAction.kt index 19cd612f8..95042e9e9 100644 --- a/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressAction.kt +++ b/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressAction.kt @@ -1,8 +1,8 @@ package org.schabi.newpipe.ui.components.menu import android.content.Context -import android.net.Uri import android.widget.Toast +import androidx.annotation.MainThread import androidx.annotation.StringRes import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.PlaylistAdd @@ -15,6 +15,7 @@ import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Headset import androidx.compose.material.icons.filled.HideImage import androidx.compose.material.icons.filled.Image +import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.OpenInBrowser import androidx.compose.material.icons.filled.Person import androidx.compose.material.icons.filled.PictureInPicture @@ -23,7 +24,11 @@ import androidx.compose.material.icons.filled.QueuePlayNext import androidx.compose.material.icons.filled.Share import androidx.compose.ui.graphics.vector.ImageVector import androidx.core.net.toUri -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.rx3.await +import kotlinx.coroutines.rx3.awaitSingle +import kotlinx.coroutines.withContext +import org.schabi.newpipe.NewPipeDatabase import org.schabi.newpipe.R import org.schabi.newpipe.database.playlist.PlaylistMetadataEntry import org.schabi.newpipe.database.playlist.PlaylistStreamEntry @@ -31,9 +36,6 @@ import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity import org.schabi.newpipe.database.stream.StreamStatisticsEntry import org.schabi.newpipe.database.stream.model.StreamEntity import org.schabi.newpipe.download.DownloadDialog -import org.schabi.newpipe.error.ErrorInfo -import org.schabi.newpipe.error.ErrorUtil -import org.schabi.newpipe.error.UserAction import org.schabi.newpipe.extractor.InfoItem import org.schabi.newpipe.extractor.channel.ChannelInfoItem import org.schabi.newpipe.extractor.playlist.PlaylistInfoItem @@ -42,21 +44,22 @@ import org.schabi.newpipe.ktx.findFragmentActivity import org.schabi.newpipe.local.dialog.PlaylistAppendDialog import org.schabi.newpipe.local.dialog.PlaylistDialog import org.schabi.newpipe.local.history.HistoryRecordManager +import org.schabi.newpipe.local.playlist.LocalPlaylistManager import org.schabi.newpipe.player.playqueue.ChannelTabPlayQueue import org.schabi.newpipe.player.playqueue.PlayQueue +import org.schabi.newpipe.player.playqueue.PlayQueueItem import org.schabi.newpipe.player.playqueue.PlaylistPlayQueue -import org.schabi.newpipe.player.playqueue.SinglePlayQueue import org.schabi.newpipe.ui.components.menu.icons.BackgroundFromHere import org.schabi.newpipe.ui.components.menu.icons.PlayFromHere import org.schabi.newpipe.ui.components.menu.icons.PopupFromHere import org.schabi.newpipe.util.NavigationHelper -import org.schabi.newpipe.util.SparseItemUtil import org.schabi.newpipe.util.external_communication.KoreUtils import org.schabi.newpipe.util.external_communication.ShareUtils data class LongPressAction( val type: Type, - val action: (context: Context) -> Unit, + @MainThread + val action: suspend (context: Context) -> Unit, val enabled: (isPlayerRunning: Boolean) -> Boolean = { true }, ) { enum class Type( @@ -88,6 +91,8 @@ data class LongPressAction( SetAsPlaylistThumbnail(17, R.string.set_as_playlist_thumbnail, Icons.Default.Image), UnsetPlaylistThumbnail(18, R.string.unset_playlist_thumbnail, Icons.Default.HideImage), Unsubscribe(19, R.string.unsubscribe, Icons.Default.Delete), + ShowDetails(20, R.string.play_queue_stream_detail, Icons.Default.Info), + Remove(21, R.string.play_queue_remove, Icons.Default.Delete), ; // TODO allow actions to return disposables @@ -95,37 +100,39 @@ data class LongPressAction( fun buildAction( enabled: (isPlayerRunning: Boolean) -> Boolean = { true }, - action: (context: Context) -> Unit, + action: suspend (context: Context) -> Unit, ) = LongPressAction(this, action, enabled) companion object { // ShowChannelDetails is not enabled by default, since navigating to channel details can // also be done by clicking on the uploader name in the long press menu header val DefaultEnabledActions: List = listOf( - Enqueue, EnqueueNext, Background, Popup, BackgroundFromHere, Download, + ShowDetails, Enqueue, EnqueueNext, Background, Popup, BackgroundFromHere, Download, AddToPlaylist, Share, OpenInBrowser, MarkAsWatched, Delete, - Rename, SetAsPlaylistThumbnail, UnsetPlaylistThumbnail, Unsubscribe + Rename, SetAsPlaylistThumbnail, UnsetPlaylistThumbnail, Unsubscribe, Remove, ) } } companion object { - private fun buildPlayerActionList(queue: () -> PlayQueue): List { + private fun buildPlayerActionList( + queue: suspend (Context) -> PlayQueue + ): List { return listOf( Type.Enqueue.buildAction({ isPlayerRunning -> isPlayerRunning }) { context -> - NavigationHelper.enqueueOnPlayer(context, queue()) + NavigationHelper.enqueueOnPlayer(context, queue(context)) }, Type.EnqueueNext.buildAction({ isPlayerRunning -> isPlayerRunning }) { context -> - NavigationHelper.enqueueNextOnPlayer(context, queue()) + NavigationHelper.enqueueNextOnPlayer(context, queue(context)) }, Type.Background.buildAction { context -> - NavigationHelper.playOnBackgroundPlayer(context, queue(), true) + NavigationHelper.playOnBackgroundPlayer(context, queue(context), true) }, Type.Popup.buildAction { context -> - NavigationHelper.playOnPopupPlayer(context, queue(), true) + NavigationHelper.playOnPopupPlayer(context, queue(context), true) }, Type.Play.buildAction { context -> - NavigationHelper.playOnMainPlayer(context, queue(), false) + NavigationHelper.playOnMainPlayer(context, queue(context), false) }, ) } @@ -166,6 +173,53 @@ data class LongPressAction( ) } + private fun buildAdditionalStreamActionList(item: StreamInfoItem): List { + return listOf( + Type.Download.buildAction { context -> + val info = fetchStreamInfoAndSaveToDatabase(context, item.serviceId, item.url) + val downloadDialog = DownloadDialog(context, info) + val fragmentManager = context.findFragmentActivity() + .supportFragmentManager + downloadDialog.show(fragmentManager, "downloadDialog") + }, + Type.AddToPlaylist.buildAction { context -> + LocalPlaylistManager(NewPipeDatabase.getInstance(context)) + .hasPlaylists() + val dialog = withContext(Dispatchers.IO) { + PlaylistDialog.createCorrespondingDialog( + context, + listOf(StreamEntity(item)) + ) + .awaitSingle() + } + val tag = if (dialog is PlaylistAppendDialog) "append" else "create" + dialog.show( + context.findFragmentActivity().supportFragmentManager, + "StreamDialogEntry@${tag}_playlist" + ) + }, + Type.ShowChannelDetails.buildAction { context -> + val uploaderUrl = fetchUploaderUrlIfSparse( + context, item.serviceId, item.url, item.uploaderUrl + ) + NavigationHelper.openChannelFragment( + context.findFragmentActivity().supportFragmentManager, + item.serviceId, + uploaderUrl, + item.uploaderName, + ) + }, + Type.MarkAsWatched.buildAction { context -> + withContext(Dispatchers.IO) { + HistoryRecordManager(context).markAsWatched(item).await() + } + }, + Type.PlayWithKodi.buildAction { context -> + KoreUtils.playWithKore(context, item.url.toUri()) + }, + ) + } + /** * @param queueFromHere returns a play queue for the list that contains [item], with the * queue index pointing to [item], used to build actions like "Play playlist from here". @@ -176,65 +230,10 @@ data class LongPressAction( queueFromHere: (() -> PlayQueue)?, /* TODO isKodiEnabled: Boolean, */ ): List { - return buildPlayerActionList { SinglePlayQueue(item) } + + return buildPlayerActionList { context -> fetchItemInfoIfSparse(context, item) } + (queueFromHere?.let { buildPlayerFromHereActionList(queueFromHere) } ?: listOf()) + buildShareActionList(item) + - listOf( - Type.Download.buildAction { context -> - SparseItemUtil.fetchStreamInfoAndSaveToDatabase( - context, item.serviceId, item.url - ) { info -> - val downloadDialog = DownloadDialog(context, info) - val fragmentManager = context.findFragmentActivity() - .supportFragmentManager - downloadDialog.show(fragmentManager, "downloadDialog") - } - }, - Type.AddToPlaylist.buildAction { context -> - PlaylistDialog.createCorrespondingDialog( - context, - listOf(StreamEntity(item)) - ) { dialog: PlaylistDialog -> - val tag = if (dialog is PlaylistAppendDialog) "append" else "create" - dialog.show( - context.findFragmentActivity().supportFragmentManager, - "StreamDialogEntry@${tag}_playlist" - ) - } - }, - Type.ShowChannelDetails.buildAction { context -> - SparseItemUtil.fetchUploaderUrlIfSparse( - context, item.serviceId, item.url, item.uploaderUrl - ) { url: String -> - NavigationHelper.openChannelFragment( - context.findFragmentActivity().supportFragmentManager, - item.serviceId, - url, - item.uploaderName, - ) - } - }, - Type.MarkAsWatched.buildAction { context -> - HistoryRecordManager(context) - .markAsWatched(item) - .doOnError { error -> - ErrorUtil.showSnackbar( - context, - ErrorInfo( - error, - UserAction.OPEN_INFO_ITEM_DIALOG, - "Got an error when trying to mark as watched" - ) - ) - } - .onErrorComplete() - .observeOn(AndroidSchedulers.mainThread()) - .subscribe() - }, - Type.PlayWithKodi.buildAction { context -> - KoreUtils.playWithKore(context, item.url.toUri()) - }, - ) + buildAdditionalStreamActionList(item) } @JvmStatic @@ -248,6 +247,38 @@ data class LongPressAction( return fromStreamInfoItem(item.toStreamInfoItem(), queueFromHere) } + @JvmStatic + fun fromPlayQueueItem( + item: PlayQueueItem, + playQueueFromWhichToDelete: PlayQueue, + showDetails: Boolean, + ): List { + // TODO decide if it's fine to just convert to StreamInfoItem here (it poses an + // unnecessary dependency on the extractor, when we want to just look at data; maybe + // using something like LongPressable would work) + val streamInfoItem = item.toStreamInfoItem() + return buildShareActionList(streamInfoItem) + + buildAdditionalStreamActionList(streamInfoItem) + + if (showDetails) { + listOf( + Type.ShowDetails.buildAction { context -> + // playQueue is null since we don't want any queue change + NavigationHelper.openVideoDetail( + context, item.serviceId, item.url, item.title, null, false + ) + } + ) + } else { + listOf() + } + + listOf( + Type.Remove.buildAction { + val index = playQueueFromWhichToDelete.indexOf(item) + playQueueFromWhichToDelete.remove(index) + } + ) + } + @JvmStatic fun fromStreamStatisticsEntry( item: StreamStatisticsEntry, @@ -256,16 +287,13 @@ data class LongPressAction( return fromStreamEntity(item.streamEntity, queueFromHere) + listOf( Type.Delete.buildAction { context -> - HistoryRecordManager(context) - .deleteStreamHistoryAndState(item.streamId) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe { - Toast.makeText( - context, - R.string.one_item_deleted, - Toast.LENGTH_SHORT - ).show() - } + withContext(Dispatchers.IO) { + HistoryRecordManager(context) + .deleteStreamHistoryAndState(item.streamId) + .await() + } + Toast.makeText(context, R.string.one_item_deleted, Toast.LENGTH_SHORT) + .show() } ) } diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressMenu.kt b/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressMenu.kt index 5857f1e77..f92027c39 100644 --- a/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressMenu.kt +++ b/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressMenu.kt @@ -7,6 +7,9 @@ import android.content.Context import android.content.res.Configuration import android.view.ViewGroup import android.view.ViewGroup.LayoutParams +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.focusable import androidx.compose.foundation.isSystemInDarkTheme @@ -32,6 +35,7 @@ import androidx.compose.material.icons.automirrored.filled.OpenInNew import androidx.compose.material.icons.automirrored.filled.PlaylistPlay import androidx.compose.material.icons.filled.Tune import androidx.compose.material3.BottomSheetDefaults +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -48,6 +52,7 @@ import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -74,11 +79,18 @@ import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import androidx.lifecycle.viewmodel.compose.viewModel import coil3.compose.AsyncImage +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.schabi.newpipe.R +import org.schabi.newpipe.error.ErrorInfo +import org.schabi.newpipe.error.ErrorUtil +import org.schabi.newpipe.error.UserAction.LONG_PRESS_MENU_ACTION import org.schabi.newpipe.extractor.stream.StreamType import org.schabi.newpipe.ui.components.common.ScaffoldWithToolbar import org.schabi.newpipe.ui.components.menu.LongPressAction.Type.EnqueueNext import org.schabi.newpipe.ui.components.menu.LongPressAction.Type.ShowChannelDetails +import org.schabi.newpipe.ui.discardAllTouchesIf import org.schabi.newpipe.ui.theme.AppTheme import org.schabi.newpipe.ui.theme.customColors import org.schabi.newpipe.util.Either @@ -129,9 +141,10 @@ fun LongPressMenu( val isHeaderEnabled by viewModel.isHeaderEnabled.collectAsState() val actionArrangement by viewModel.actionArrangement.collectAsState() var showEditor by rememberSaveable { mutableStateOf(false) } + var isLoading by remember { mutableStateOf(false) } val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - if (showEditor) { + if (showEditor && !isLoading) { // we can't put the editor in a bottom sheet, because it relies on dragging gestures Dialog( onDismissRequest = { showEditor = false }, @@ -153,19 +166,33 @@ fun LongPressMenu( } } + val ctx = LocalContext.current + // run actions on the main thread! + val coroutineScope = rememberCoroutineScope { Dispatchers.Main } + fun runActionAndDismiss(action: LongPressAction) { + if (isLoading) { + return + } + isLoading = true + coroutineScope.launch { + try { + action.action(ctx) + } catch (t: Throwable) { + ErrorUtil.showSnackbar( + ctx, ErrorInfo(t, LONG_PRESS_MENU_ACTION, "Running action ${action.type}") + ) + } + onDismissRequest() + } + } + // show a clickable uploader in the header if an uploader action is available and the // "show channel details" action is not enabled as a standalone action - val ctx = LocalContext.current val onUploaderClick by remember { derivedStateOf { longPressActions.firstOrNull { it.type == ShowChannelDetails } ?.takeIf { !actionArrangement.contains(ShowChannelDetails) } - ?.let { showChannelDetailsAction -> - { - showChannelDetailsAction.action(ctx) - onDismissRequest() - } - } + ?.let { showChannelAction -> { runActionAndDismiss(showChannelAction) } } } } @@ -174,12 +201,27 @@ fun LongPressMenu( onDismissRequest = onDismissRequest, dragHandle = { LongPressMenuDragHandle(onEditActions = { showEditor = true }) }, ) { - LongPressMenuContent( - header = longPressable.takeIf { isHeaderEnabled }, - onUploaderClick = onUploaderClick, - actions = enabledLongPressActions, - onDismissRequest = onDismissRequest, - ) + Box(modifier = Modifier.discardAllTouchesIf(isLoading)) { + LongPressMenuContent( + header = longPressable.takeIf { isHeaderEnabled }, + onUploaderClick = onUploaderClick, + actions = enabledLongPressActions, + runActionAndDismiss = ::runActionAndDismiss, + ) + // importing makes the ColumnScope overload be resolved, so we use qualified path... + androidx.compose.animation.AnimatedVisibility( + visible = isLoading, + enter = fadeIn(), + exit = fadeOut(), + modifier = Modifier + .matchParentSize() + .background(MaterialTheme.colorScheme.surfaceContainerLow), + ) { + Box(contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + } + } } } } @@ -189,7 +231,7 @@ private fun LongPressMenuContent( header: LongPressable?, onUploaderClick: (() -> Unit)?, actions: List, - onDismissRequest: () -> Unit, + runActionAndDismiss: (LongPressAction) -> Unit, ) { BoxWithConstraints( modifier = Modifier @@ -203,7 +245,6 @@ private fun LongPressMenuContent( // width for the landscape/reduced header, measured in button widths val headerWidthInButtonsReducedSpan = 4 val buttonsPerRow = (this.maxWidth / MinButtonWidth).toInt() - val ctx = LocalContext.current Column { var actionIndex = if (header != null) -1 else 0 // -1 indicates the header @@ -230,10 +271,7 @@ private fun LongPressMenuContent( LongPressMenuButton( icon = action.type.icon, text = stringResource(action.type.label), - onClick = { - action.action(ctx) - onDismissRequest() - }, + onClick = { runActionAndDismiss(action) }, enabled = action.enabled(false), modifier = Modifier .height(buttonHeight) @@ -296,7 +334,12 @@ fun LongPressMenuDragHandle(onEditActions: () -> Unit) { // the focus to "nothing focused". Ideally it would be great to focus the first item in // the long press menu, but then there would need to be a way to ignore the UP from the // DPAD after an externally-triggered long press. - Box(Modifier.size(1.dp).focusable().onFocusChanged { showFocusTrap = !it.isFocused }) + Box( + Modifier + .size(1.dp) + .focusable() + .onFocusChanged { showFocusTrap = !it.isFocused } + ) } BottomSheetDefaults.DragHandle( modifier = Modifier.align(Alignment.Center) @@ -693,7 +736,7 @@ private fun LongPressMenuPreview( actions = LongPressAction.Type.entries // disable Enqueue actions just to show it off .map { t -> t.buildAction({ t != EnqueueNext }) { } }, - onDismissRequest = {}, + runActionAndDismiss = {}, ) } } diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressable.kt b/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressable.kt index 8c9c1a1eb..ebafa71b8 100644 --- a/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressable.kt +++ b/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressable.kt @@ -11,6 +11,7 @@ import org.schabi.newpipe.extractor.stream.StreamInfoItem import org.schabi.newpipe.extractor.stream.StreamType import org.schabi.newpipe.extractor.stream.StreamType.AUDIO_LIVE_STREAM import org.schabi.newpipe.extractor.stream.StreamType.LIVE_STREAM +import org.schabi.newpipe.player.playqueue.PlayQueueItem import org.schabi.newpipe.util.Either import org.schabi.newpipe.util.image.ImageStrategy import java.time.OffsetDateTime @@ -71,6 +72,19 @@ data class LongPressable( decoration = Decoration.from(item.streamType, item.duration), ) + @JvmStatic + fun fromPlayQueueItem(item: PlayQueueItem) = LongPressable( + title = item.title, + url = item.url.takeIf { it.isNotBlank() }, + thumbnailUrl = ImageStrategy.choosePreferredImage(item.thumbnails), + uploader = item.uploader.takeIf { it.isNotBlank() }, + uploaderUrl = item.uploaderUrl?.takeIf { it.isNotBlank() }, + viewCount = null, + streamType = item.streamType, + uploadDate = null, + decoration = Decoration.from(item.streamType, item.duration), + ) + @JvmStatic fun fromPlaylistMetadataEntry(item: PlaylistMetadataEntry) = LongPressable( // many fields are null because this is a local playlist @@ -118,7 +132,7 @@ data class LongPressable( title = item.name, url = item.url?.takeIf { it.isNotBlank() }, thumbnailUrl = ImageStrategy.choosePreferredImage(item.thumbnails), - uploader = item.uploaderName.takeIf { it.isNotBlank() }, + uploader = item.uploaderName?.takeIf { it.isNotBlank() }, uploaderUrl = item.uploaderUrl?.takeIf { it.isNotBlank() }, viewCount = null, streamType = null, diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/menu/SparseItemUtil.kt b/app/src/main/java/org/schabi/newpipe/ui/components/menu/SparseItemUtil.kt new file mode 100644 index 000000000..33f03ad5c --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/ui/components/menu/SparseItemUtil.kt @@ -0,0 +1,106 @@ +package org.schabi.newpipe.ui.components.menu + +import android.content.Context +import android.widget.Toast +import androidx.annotation.MainThread +import io.reactivex.rxjava3.schedulers.Schedulers +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.rx3.await +import kotlinx.coroutines.withContext +import org.schabi.newpipe.NewPipeDatabase +import org.schabi.newpipe.R +import org.schabi.newpipe.database.stream.model.StreamEntity +import org.schabi.newpipe.extractor.stream.StreamInfo +import org.schabi.newpipe.extractor.stream.StreamInfoItem +import org.schabi.newpipe.extractor.utils.Utils +import org.schabi.newpipe.player.playqueue.SinglePlayQueue +import org.schabi.newpipe.util.ExtractorHelper +import org.schabi.newpipe.util.StreamTypeUtil + +// Utilities for fetching additional data for stream items when needed. + +/** + * Use this to certainly obtain an single play queue with all of the data filled in when the + * stream info item you are handling might be sparse, e.g. because it was fetched via a + * [org.schabi.newpipe.extractor.feed.FeedExtractor]. FeedExtractors provide a fast and + * lightweight method to fetch info, but the info might be incomplete (see + * [org.schabi.newpipe.local.feed.service.FeedLoadService] for more details). + * + * @param context Android context + * @param item item which is checked and eventually loaded completely + * @return a [SinglePlayQueue] with full data (fetched if necessary) + */ +@MainThread +suspend fun fetchItemInfoIfSparse( + context: Context, + item: StreamInfoItem, +): SinglePlayQueue { + if ((StreamTypeUtil.isLiveStream(item.streamType) || item.duration >= 0) && + !Utils.isNullOrEmpty(item.uploaderUrl) + ) { + // if the duration is >= 0 (provided that the item is not a livestream) and there is an + // uploader url, probably all info is already there, so there is no need to fetch it + return SinglePlayQueue(item) + } + + // either the duration or the uploader url are not available, so fetch more info + val streamInfo = fetchStreamInfoAndSaveToDatabase(context, item.serviceId, item.url) + return SinglePlayQueue(streamInfo) +} + +/** + * Use this to certainly obtain an uploader url when the stream info item or play queue item you + * are handling might not have the uploader url (e.g. because it was fetched with + * [org.schabi.newpipe.extractor.feed.FeedExtractor]). A toast is shown if loading details is + * required. + * + * @param context Android context + * @param serviceId serviceId of the item + * @param url item url + * @param uploaderUrl uploaderUrl of the item; if null or empty will be fetched + * @return the original or the fetched uploader URL (may still be null if the extractor didn't + * provide one) + */ +@MainThread +suspend fun fetchUploaderUrlIfSparse( + context: Context, + serviceId: Int, + url: String, + uploaderUrl: String?, +): String? { + if (!uploaderUrl.isNullOrEmpty()) { + return uploaderUrl + } + val streamInfo = fetchStreamInfoAndSaveToDatabase(context, serviceId, url) + return streamInfo.uploaderUrl +} + +/** + * Loads the stream info corresponding to the given data on an I/O thread, stores the result in + * the database, and returns. A toast will be shown to the user about loading stream details, so + * this needs to be called on the main thread. + * + * @param context Android context + * @param serviceId service id of the stream to load + * @param url url of the stream to load + * @return the fetched [StreamInfo] + */ +@MainThread +suspend fun fetchStreamInfoAndSaveToDatabase( + context: Context, + serviceId: Int, + url: String, +): StreamInfo { + Toast.makeText(context, R.string.loading_stream_details, Toast.LENGTH_SHORT).show() + + return withContext(Dispatchers.IO) { + val streamInfo = ExtractorHelper.getStreamInfo(serviceId, url, false) + .subscribeOn(Schedulers.io()) + .await() + // save to database + NewPipeDatabase.getInstance(context) + .streamDAO() + .upsert(StreamEntity(streamInfo)) + return@withContext streamInfo + } +} diff --git a/app/src/main/java/org/schabi/newpipe/util/SparseItemUtil.java b/app/src/main/java/org/schabi/newpipe/util/SparseItemUtil.java deleted file mode 100644 index 05f26f178..000000000 --- a/app/src/main/java/org/schabi/newpipe/util/SparseItemUtil.java +++ /dev/null @@ -1,127 +0,0 @@ -package org.schabi.newpipe.util; - -import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty; - -import android.content.Context; -import android.widget.Toast; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import org.schabi.newpipe.NewPipeDatabase; -import org.schabi.newpipe.R; -import org.schabi.newpipe.database.stream.model.StreamEntity; -import org.schabi.newpipe.error.ErrorInfo; -import org.schabi.newpipe.error.ErrorUtil; -import org.schabi.newpipe.error.UserAction; -import org.schabi.newpipe.extractor.stream.StreamInfo; -import org.schabi.newpipe.extractor.stream.StreamInfoItem; -import org.schabi.newpipe.player.playqueue.SinglePlayQueue; - -import java.util.function.Consumer; - -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; -import io.reactivex.rxjava3.core.Completable; -import io.reactivex.rxjava3.schedulers.Schedulers; - -/** - * Utility class for fetching additional data for stream items when needed. - */ -public final class SparseItemUtil { - private SparseItemUtil() { - } - - /** - * Use this to certainly obtain an single play queue with all of the data filled in when the - * stream info item you are handling might be sparse, e.g. because it was fetched via a {@link - * org.schabi.newpipe.extractor.feed.FeedExtractor}. FeedExtractors provide a fast and - * lightweight method to fetch info, but the info might be incomplete (see - * {@link org.schabi.newpipe.local.feed.service.FeedLoadService} for more details). - * - * @param context Android context - * @param item item which is checked and eventually loaded completely - * @param callback callback to call with the single play queue built from the original item if - * all info was available, otherwise from the fetched {@link - * org.schabi.newpipe.extractor.stream.StreamInfo} - */ - public static void fetchItemInfoIfSparse(@NonNull final Context context, - @NonNull final StreamInfoItem item, - @NonNull final Consumer callback) { - if ((StreamTypeUtil.isLiveStream(item.getStreamType()) || item.getDuration() >= 0) - && !isNullOrEmpty(item.getUploaderUrl())) { - // if the duration is >= 0 (provided that the item is not a livestream) and there is an - // uploader url, probably all info is already there, so there is no need to fetch it - callback.accept(new SinglePlayQueue(item)); - return; - } - - // either the duration or the uploader url are not available, so fetch more info - fetchStreamInfoAndSaveToDatabase(context, item.getServiceId(), item.getUrl(), - streamInfo -> callback.accept(new SinglePlayQueue(streamInfo))); - } - - /** - * Use this to certainly obtain an uploader url when the stream info item or play queue item you - * are handling might not have the uploader url (e.g. because it was fetched with {@link - * org.schabi.newpipe.extractor.feed.FeedExtractor}). A toast is shown if loading details is - * required. - * - * @param context Android context - * @param serviceId serviceId of the item - * @param url item url - * @param uploaderUrl uploaderUrl of the item; if null or empty will be fetched - * @param callback callback to be called with either the original uploaderUrl, if it was a - * valid url, otherwise with the uploader url obtained by fetching the {@link - * org.schabi.newpipe.extractor.stream.StreamInfo} corresponding to the item - */ - public static void fetchUploaderUrlIfSparse(@NonNull final Context context, - final int serviceId, - @NonNull final String url, - @Nullable final String uploaderUrl, - @NonNull final Consumer callback) { - if (!isNullOrEmpty(uploaderUrl)) { - callback.accept(uploaderUrl); - return; - } - fetchStreamInfoAndSaveToDatabase(context, serviceId, url, - streamInfo -> callback.accept(streamInfo.getUploaderUrl())); - } - - /** - * Loads the stream info corresponding to the given data on an I/O thread, stores the result in - * the database and calls the callback on the main thread with the result. A toast will be shown - * to the user about loading stream details, so this needs to be called on the main thread. - * - * @param context Android context - * @param serviceId service id of the stream to load - * @param url url of the stream to load - * @param callback callback to be called with the result - */ - public static void fetchStreamInfoAndSaveToDatabase(@NonNull final Context context, - final int serviceId, - @NonNull final String url, - final Consumer callback) { - Toast.makeText(context, R.string.loading_stream_details, Toast.LENGTH_SHORT).show(); - ExtractorHelper.getStreamInfo(serviceId, url, false) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(result -> { - // save to database in the background (not on main thread) - Completable.fromAction(() -> NewPipeDatabase.getInstance(context) - .streamDAO().upsert(new StreamEntity(result))) - .subscribeOn(Schedulers.io()) - .observeOn(Schedulers.io()) - .doOnError(throwable -> - ErrorUtil.createNotification(context, - new ErrorInfo(throwable, UserAction.REQUESTED_STREAM, - "Saving stream info to database", result))) - .subscribe(); - - // call callback on main thread with the obtained result - callback.accept(result); - }, throwable -> ErrorUtil.createNotification(context, - new ErrorInfo(throwable, UserAction.REQUESTED_STREAM, - "Loading stream info: " + url, serviceId, url) - )); - } -} From 70c502d31f6e4e7a67a6288f90cbe1e2f9272a17 Mon Sep 17 00:00:00 2001 From: Stypox Date: Mon, 2 Feb 2026 20:28:20 +0100 Subject: [PATCH 56/87] Fix formatting with new ktlint rules --- .../org/schabi/newpipe/error/UserAction.kt | 2 +- .../fragments/detail/VideoDetailFragment.kt | 5 +- .../main/java/org/schabi/newpipe/ktx/Scope.kt | 3 +- .../schabi/newpipe/local/feed/FeedFragment.kt | 4 +- .../org/schabi/newpipe/ui/GestureModifiers.kt | 2 +- .../java/org/schabi/newpipe/ui/Toolbar.kt | 2 +- .../components/common/ScaffoldWithToolbar.kt | 2 +- .../components/items/stream/StreamListItem.kt | 4 +- .../ui/components/menu/LongPressAction.kt | 84 +++++++++-------- .../ui/components/menu/LongPressMenu.kt | 91 ++++++++++--------- .../ui/components/menu/LongPressMenuEditor.kt | 43 +++++---- .../menu/LongPressMenuEditorState.kt | 19 ++-- .../ui/components/menu/LongPressable.kt | 29 +++--- .../ui/components/menu/SparseItemUtil.kt | 6 +- .../menu/icons/BackgroundFromHere.kt | 2 +- .../ui/components/menu/icons/PlayFromHere.kt | 2 +- .../ui/components/menu/icons/PopupFromHere.kt | 2 +- .../schabi/newpipe/ui/theme/CustomColors.kt | 2 +- .../java/org/schabi/newpipe/util/Either.kt | 8 +- .../util/text/FixedHeightCenteredText.kt | 4 +- 20 files changed, 165 insertions(+), 151 deletions(-) diff --git a/app/src/main/java/org/schabi/newpipe/error/UserAction.kt b/app/src/main/java/org/schabi/newpipe/error/UserAction.kt index 1b0d35755..27fff3d99 100644 --- a/app/src/main/java/org/schabi/newpipe/error/UserAction.kt +++ b/app/src/main/java/org/schabi/newpipe/error/UserAction.kt @@ -40,5 +40,5 @@ enum class UserAction(val message: String) { GETTING_MAIN_SCREEN_TAB("getting main screen tab"), PLAY_ON_POPUP("play on popup"), SUBSCRIPTIONS("loading subscriptions"), - LONG_PRESS_MENU_ACTION("long press menu action"), + LONG_PRESS_MENU_ACTION("long press menu action") } diff --git a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.kt b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.kt index 6d8a20630..9cdcb6cbd 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 @@ -1425,7 +1425,10 @@ class VideoDetailFragment : if (info.viewCount >= 0) { binding.detailViewCountView.text = Localization.localizeViewCount( - activity, false, info.streamType, info.viewCount + activity, + false, + info.streamType, + info.viewCount ) binding.detailViewCountView.visibility = View.VISIBLE } else { diff --git a/app/src/main/java/org/schabi/newpipe/ktx/Scope.kt b/app/src/main/java/org/schabi/newpipe/ktx/Scope.kt index decf1c3a6..fd6f3069e 100644 --- a/app/src/main/java/org/schabi/newpipe/ktx/Scope.kt +++ b/app/src/main/java/org/schabi/newpipe/ktx/Scope.kt @@ -8,5 +8,4 @@ package org.schabi.newpipe.ktx * .letIf(someCondition) { padding(right = 4.dp) } * ``` */ -inline fun T.letIf(condition: Boolean, block: T.() -> T): T = - if (condition) block(this) else this +inline fun T.letIf(condition: Boolean, block: T.() -> T): T = if (condition) block(this) else this 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 30f47a613..322e94935 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 @@ -425,8 +425,8 @@ class FeedFragment : BaseStateFragment() { // just in case Log.w(TAG, "Could not get full list of items on long press") return@fromStreamEntity SinglePlayQueue(item.stream.toStreamInfoItem()) - }, - ), + } + ) ) return true } diff --git a/app/src/main/java/org/schabi/newpipe/ui/GestureModifiers.kt b/app/src/main/java/org/schabi/newpipe/ui/GestureModifiers.kt index 164f28e72..2fe995823 100644 --- a/app/src/main/java/org/schabi/newpipe/ui/GestureModifiers.kt +++ b/app/src/main/java/org/schabi/newpipe/ui/GestureModifiers.kt @@ -79,7 +79,7 @@ fun Modifier.detectDragGestures( } handleDragGestureChange( change.position.toIntOffset(), - change.positionChange(), + change.positionChange() ) change.consume() } 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 40a1458af..23a597a96 100644 --- a/app/src/main/java/org/schabi/newpipe/ui/Toolbar.kt +++ b/app/src/main/java/org/schabi/newpipe/ui/Toolbar.kt @@ -39,7 +39,7 @@ fun NavigationIcon() { Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back), - modifier = Modifier.padding(horizontal = SizeTokens.SpacingExtraSmall), + 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 82b3ffaf6..050f03970 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 @@ -42,7 +42,7 @@ fun ScaffoldWithToolbar( IconButton(onClick = onBackClick) { Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = stringResource(R.string.back), + contentDescription = stringResource(R.string.back) ) } }, 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 3078a4aff..dac6d32ae 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 @@ -36,7 +36,7 @@ import org.schabi.newpipe.ui.theme.AppTheme fun StreamListItem( stream: StreamInfoItem, showProgress: Boolean, - onClick: (StreamInfoItem) -> Unit = {}, + onClick: (StreamInfoItem) -> Unit = {} ) { var showLongPressMenu by rememberSaveable { mutableStateOf(false) } @@ -81,7 +81,7 @@ fun StreamListItem( longPressable = LongPressable.fromStreamInfoItem(stream), // TODO queueFromHere: allow playing the whole list starting from one stream longPressActions = LongPressAction.fromStreamInfoItem(stream, null), - onDismissRequest = { showLongPressMenu = false }, + 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 95042e9e9..757ccf665 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,7 +60,7 @@ data class LongPressAction( val type: Type, @MainThread val action: suspend (context: Context) -> Unit, - val enabled: (isPlayerRunning: Boolean) -> Boolean = { true }, + val enabled: (isPlayerRunning: Boolean) -> Boolean = { true } ) { enum class Type( /** @@ -69,7 +69,7 @@ data class LongPressAction( */ val id: Int, @StringRes val label: Int, - val icon: ImageVector, + val icon: ImageVector ) { Enqueue(0, R.string.enqueue, Icons.Default.AddToQueue), EnqueueNext(1, R.string.enqueue_next_stream, Icons.Default.QueuePlayNext), @@ -92,7 +92,7 @@ data class LongPressAction( UnsetPlaylistThumbnail(18, R.string.unset_playlist_thumbnail, Icons.Default.HideImage), Unsubscribe(19, R.string.unsubscribe, Icons.Default.Delete), ShowDetails(20, R.string.play_queue_stream_detail, Icons.Default.Info), - Remove(21, R.string.play_queue_remove, Icons.Default.Delete), + Remove(21, R.string.play_queue_remove, Icons.Default.Delete) ; // TODO allow actions to return disposables @@ -100,7 +100,7 @@ data class LongPressAction( fun buildAction( enabled: (isPlayerRunning: Boolean) -> Boolean = { true }, - action: suspend (context: Context) -> Unit, + action: suspend (context: Context) -> Unit ) = LongPressAction(this, action, enabled) companion object { @@ -109,7 +109,7 @@ data class LongPressAction( val DefaultEnabledActions: List = listOf( ShowDetails, Enqueue, EnqueueNext, Background, Popup, BackgroundFromHere, Download, AddToPlaylist, Share, OpenInBrowser, MarkAsWatched, Delete, - Rename, SetAsPlaylistThumbnail, UnsetPlaylistThumbnail, Unsubscribe, Remove, + Rename, SetAsPlaylistThumbnail, UnsetPlaylistThumbnail, Unsubscribe, Remove ) } } @@ -133,7 +133,7 @@ data class LongPressAction( }, Type.Play.buildAction { context -> NavigationHelper.playOnMainPlayer(context, queue(context), false) - }, + } ) } @@ -147,7 +147,7 @@ data class LongPressAction( }, Type.PlayFromHere.buildAction { context -> NavigationHelper.playOnMainPlayer(context, queueFromHere(), false) - }, + } ) } @@ -158,7 +158,7 @@ data class LongPressAction( }, Type.OpenInBrowser.buildAction { context -> ShareUtils.openUrlInBrowser(context, item.url) - }, + } ) } @@ -169,7 +169,7 @@ data class LongPressAction( }, Type.OpenInBrowser.buildAction { context -> ShareUtils.openUrlInBrowser(context, url) - }, + } ) } @@ -200,13 +200,16 @@ data class LongPressAction( }, Type.ShowChannelDetails.buildAction { context -> val uploaderUrl = fetchUploaderUrlIfSparse( - context, item.serviceId, item.url, item.uploaderUrl + context, + item.serviceId, + item.url, + item.uploaderUrl ) NavigationHelper.openChannelFragment( context.findFragmentActivity().supportFragmentManager, item.serviceId, uploaderUrl, - item.uploaderName, + item.uploaderName ) }, Type.MarkAsWatched.buildAction { context -> @@ -216,7 +219,7 @@ data class LongPressAction( }, Type.PlayWithKodi.buildAction { context -> KoreUtils.playWithKore(context, item.url.toUri()) - }, + } ) } @@ -227,7 +230,7 @@ data class LongPressAction( @JvmStatic fun fromStreamInfoItem( item: StreamInfoItem, - queueFromHere: (() -> PlayQueue)?, + queueFromHere: (() -> PlayQueue)? /* TODO isKodiEnabled: Boolean, */ ): List { return buildPlayerActionList { context -> fetchItemInfoIfSparse(context, item) } + @@ -239,7 +242,7 @@ data class LongPressAction( @JvmStatic fun fromStreamEntity( item: StreamEntity, - queueFromHere: (() -> PlayQueue)?, + 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 @@ -251,38 +254,43 @@ data class LongPressAction( fun fromPlayQueueItem( item: PlayQueueItem, playQueueFromWhichToDelete: PlayQueue, - showDetails: Boolean, + showDetails: Boolean ): List { // TODO decide if it's fine to just convert to StreamInfoItem here (it poses an // unnecessary dependency on the extractor, when we want to just look at data; maybe // using something like LongPressable would work) val streamInfoItem = item.toStreamInfoItem() return buildShareActionList(streamInfoItem) + - buildAdditionalStreamActionList(streamInfoItem) + - if (showDetails) { - listOf( - Type.ShowDetails.buildAction { context -> - // playQueue is null since we don't want any queue change - NavigationHelper.openVideoDetail( - context, item.serviceId, item.url, item.title, null, false - ) - } - ) - } else { - listOf() - } + + buildAdditionalStreamActionList(streamInfoItem) + + if (showDetails) { listOf( - Type.Remove.buildAction { - val index = playQueueFromWhichToDelete.indexOf(item) - playQueueFromWhichToDelete.remove(index) + Type.ShowDetails.buildAction { context -> + // playQueue is null since we don't want any queue change + NavigationHelper.openVideoDetail( + context, + item.serviceId, + item.url, + item.title, + null, + false + ) } ) + } else { + listOf() + } + + listOf( + Type.Remove.buildAction { + val index = playQueueFromWhichToDelete.indexOf(item) + playQueueFromWhichToDelete.remove(index) + } + ) } @JvmStatic fun fromStreamStatisticsEntry( item: StreamStatisticsEntry, - queueFromHere: (() -> PlayQueue)?, + queueFromHere: (() -> PlayQueue)? ): List { return fromStreamEntity(item.streamEntity, queueFromHere) + listOf( @@ -304,7 +312,7 @@ data class LongPressAction( queueFromHere: (() -> PlayQueue)?, // TODO possibly embed these two actions here onDelete: Runnable, - onSetAsPlaylistThumbnail: Runnable, + onSetAsPlaylistThumbnail: Runnable ): List { return fromStreamEntity(item.streamEntity, queueFromHere) + listOf( @@ -318,7 +326,7 @@ data class LongPressAction( item: PlaylistMetadataEntry, onRename: Runnable, onDelete: Runnable, - unsetPlaylistThumbnail: Runnable?, + unsetPlaylistThumbnail: Runnable? ): List { return listOf( Type.Rename.buildAction { onRename.run() }, @@ -332,7 +340,7 @@ data class LongPressAction( @JvmStatic fun fromPlaylistRemoteEntity( item: PlaylistRemoteEntity, - onDelete: Runnable, + onDelete: Runnable ): List { return buildPlayerActionList { PlaylistPlayQueue(item.serviceId, item.url) } + buildShareActionList( @@ -341,14 +349,14 @@ data class LongPressAction( item.thumbnailUrl ) + listOf( - Type.Delete.buildAction { onDelete.run() }, + Type.Delete.buildAction { onDelete.run() } ) } @JvmStatic fun fromChannelInfoItem( item: ChannelInfoItem, - onUnsubscribe: Runnable?, + onUnsubscribe: Runnable? ): List { return buildPlayerActionList { ChannelTabPlayQueue(item.serviceId, item.url) } + buildShareActionList(item) + @@ -358,7 +366,7 @@ data class LongPressAction( context.findFragmentActivity().supportFragmentManager, item.serviceId, item.url, - item.name, + item.name ) }, onUnsubscribe?.let { r -> Type.Unsubscribe.buildAction { r.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 f92027c39..74a7cb4d6 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 @@ -79,6 +79,7 @@ import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import androidx.lifecycle.viewmodel.compose.viewModel import coil3.compose.AsyncImage +import java.time.OffsetDateTime import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -97,12 +98,11 @@ 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 fun openLongPressMenuInActivity( activity: Activity, longPressable: LongPressable, - longPressActions: List, + longPressActions: List ) { activity.addContentView( getLongPressMenuView(activity, longPressable, longPressActions), @@ -113,7 +113,7 @@ fun openLongPressMenuInActivity( fun getLongPressMenuView( context: Context, longPressable: LongPressable, - longPressActions: List, + longPressActions: List ): ComposeView { return ComposeView(context).apply { setContent { @@ -121,7 +121,7 @@ fun getLongPressMenuView( LongPressMenu( longPressable = longPressable, longPressActions = longPressActions, - onDismissRequest = { (this.parent as ViewGroup).removeView(this) }, + onDismissRequest = { (this.parent as ViewGroup).removeView(this) } ) } } @@ -135,7 +135,7 @@ internal val ThumbnailHeight = 60.dp fun LongPressMenu( longPressable: LongPressable, longPressActions: List, - onDismissRequest: () -> Unit, + onDismissRequest: () -> Unit ) { val viewModel: LongPressMenuViewModel = viewModel() val isHeaderEnabled by viewModel.isHeaderEnabled.collectAsState() @@ -152,7 +152,7 @@ fun LongPressMenu( ) { ScaffoldWithToolbar( title = stringResource(R.string.long_press_menu_actions_editor), - onBackClick = { showEditor = false }, + onBackClick = { showEditor = false } ) { paddingValues -> LongPressMenuEditor(modifier = Modifier.padding(paddingValues)) } @@ -179,7 +179,8 @@ fun LongPressMenu( action.action(ctx) } catch (t: Throwable) { ErrorUtil.showSnackbar( - ctx, ErrorInfo(t, LONG_PRESS_MENU_ACTION, "Running action ${action.type}") + ctx, + ErrorInfo(t, LONG_PRESS_MENU_ACTION, "Running action ${action.type}") ) } onDismissRequest() @@ -199,14 +200,14 @@ fun LongPressMenu( ModalBottomSheet( sheetState = sheetState, onDismissRequest = onDismissRequest, - dragHandle = { LongPressMenuDragHandle(onEditActions = { showEditor = true }) }, + dragHandle = { LongPressMenuDragHandle(onEditActions = { showEditor = true }) } ) { Box(modifier = Modifier.discardAllTouchesIf(isLoading)) { LongPressMenuContent( header = longPressable.takeIf { isHeaderEnabled }, onUploaderClick = onUploaderClick, actions = enabledLongPressActions, - runActionAndDismiss = ::runActionAndDismiss, + runActionAndDismiss = ::runActionAndDismiss ) // importing makes the ColumnScope overload be resolved, so we use qualified path... androidx.compose.animation.AnimatedVisibility( @@ -215,7 +216,7 @@ fun LongPressMenu( exit = fadeOut(), modifier = Modifier .matchParentSize() - .background(MaterialTheme.colorScheme.surfaceContainerLow), + .background(MaterialTheme.colorScheme.surfaceContainerLow) ) { Box(contentAlignment = Alignment.Center) { CircularProgressIndicator() @@ -231,7 +232,7 @@ private fun LongPressMenuContent( header: LongPressable?, onUploaderClick: (() -> Unit)?, actions: List, - runActionAndDismiss: (LongPressAction) -> Unit, + runActionAndDismiss: (LongPressAction) -> Unit ) { BoxWithConstraints( modifier = Modifier @@ -251,7 +252,7 @@ private fun LongPressMenuContent( while (actionIndex < actions.size) { Row( verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth() ) { var rowIndex = 0 while (rowIndex < buttonsPerRow) { @@ -263,7 +264,7 @@ private fun LongPressMenuContent( modifier = Modifier .height(buttonHeight) .fillMaxWidth() - .weight((buttonsPerRow - rowIndex).toFloat()), + .weight((buttonsPerRow - rowIndex).toFloat()) ) break } else if (actionIndex >= 0) { @@ -276,7 +277,7 @@ private fun LongPressMenuContent( modifier = Modifier .height(buttonHeight) .fillMaxWidth() - .weight(1F), + .weight(1F) ) rowIndex += 1 } else if (maxHeaderWidthInButtonsFullSpan >= buttonsPerRow) { @@ -290,7 +291,7 @@ private fun LongPressMenuContent( // leave the height as small as possible, since it's the // only item on the row anyway .fillMaxWidth() - .weight(maxHeaderWidthInButtonsFullSpan.toFloat()), + .weight(maxHeaderWidthInButtonsFullSpan.toFloat()) ) rowIndex += maxHeaderWidthInButtonsFullSpan } else { @@ -306,7 +307,7 @@ private fun LongPressMenuContent( .padding(start = 8.dp, top = 11.dp, bottom = 11.dp) .heightIn(min = ThumbnailHeight) .fillMaxWidth() - .weight(headerWidthInButtonsReducedSpan.toFloat()), + .weight(headerWidthInButtonsReducedSpan.toFloat()) ) rowIndex += headerWidthInButtonsReducedSpan } @@ -357,7 +358,7 @@ fun LongPressMenuDragHandle(onEditActions: () -> Unit) { tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier .padding(2.dp) - .size(16.dp), + .size(16.dp) ) } } @@ -378,7 +379,7 @@ private fun LongPressMenuDragHandlePreview() { fun LongPressMenuHeader( item: LongPressable, onUploaderClick: (() -> Unit)?, - modifier: Modifier = Modifier, + modifier: Modifier = Modifier ) { val ctx = LocalContext.current @@ -386,7 +387,7 @@ fun LongPressMenuHeader( color = MaterialTheme.colorScheme.surfaceContainer, contentColor = MaterialTheme.colorScheme.onSurfaceVariant, shape = MaterialTheme.shapes.large, - modifier = modifier, + modifier = modifier ) { Row(verticalAlignment = Alignment.CenterVertically) { Box { @@ -413,12 +414,12 @@ fun LongPressMenuHeader( modifier = Modifier .align(Alignment.BottomEnd) .padding(4.dp) - .clip(MaterialTheme.shapes.medium), + .clip(MaterialTheme.shapes.medium) ) { Text( text = Localization.getDurationString(decoration.duration), style = MaterialTheme.typography.bodySmall, - modifier = Modifier.padding(vertical = 2.dp, horizontal = 4.dp), + modifier = Modifier.padding(vertical = 2.dp, horizontal = 4.dp) ) } } @@ -433,7 +434,7 @@ fun LongPressMenuHeader( modifier = Modifier .align(Alignment.BottomEnd) .padding(4.dp) - .clip(MaterialTheme.shapes.medium), + .clip(MaterialTheme.shapes.medium) ) { Text( text = stringResource(R.string.duration_live).uppercase(), @@ -451,16 +452,16 @@ fun LongPressMenuHeader( modifier = Modifier .align(Alignment.TopEnd) .size(width = 40.dp, height = ThumbnailHeight) - .clip(MaterialTheme.shapes.large), + .clip(MaterialTheme.shapes.large) ) { Column( verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth() ) { Icon( Icons.AutoMirrored.Default.PlaylistPlay, - contentDescription = null, + contentDescription = null ) Text( text = Localization.localizeStreamCountMini( @@ -468,7 +469,7 @@ fun LongPressMenuHeader( decoration.itemCount ), style = MaterialTheme.typography.labelMedium, - maxLines = 1, + maxLines = 1 ) } } @@ -479,7 +480,7 @@ fun LongPressMenuHeader( } Column( - modifier = Modifier.padding(vertical = 8.dp), + modifier = Modifier.padding(vertical = 8.dp) ) { Text( text = item.title, @@ -487,14 +488,14 @@ fun LongPressMenuHeader( maxLines = 1, modifier = Modifier .fillMaxWidth() - .fadedMarquee(edgeWidth = 12.dp), + .fadedMarquee(edgeWidth = 12.dp) ) val subtitle = getSubtitleAnnotatedString( item = item, showLink = onUploaderClick != null, linkColor = MaterialTheme.customColors.onSurfaceVariantLink, - ctx = ctx, + ctx = ctx ) if (subtitle.isNotBlank()) { Spacer(Modifier.height(1.dp)) @@ -509,7 +510,7 @@ fun LongPressMenuHeader( Modifier.clickable(onClick = onUploaderClick) } .fillMaxWidth() - .fadedMarquee(edgeWidth = 12.dp), + .fadedMarquee(edgeWidth = 12.dp) ) } } @@ -521,7 +522,7 @@ fun getSubtitleAnnotatedString( item: LongPressable, showLink: Boolean, linkColor: Color, - ctx: Context, + ctx: Context ) = buildAnnotatedString { var shouldAddSeparator = false if (showLink) { @@ -574,13 +575,13 @@ fun getSubtitleInlineContent() = mapOf( placeholder = Placeholder( width = MaterialTheme.typography.bodyMedium.fontSize, height = MaterialTheme.typography.bodyMedium.fontSize, - placeholderVerticalAlign = PlaceholderVerticalAlign.Center, + placeholderVerticalAlign = PlaceholderVerticalAlign.Center ) ) { Icon( imageVector = Icons.AutoMirrored.Filled.OpenInNew, contentDescription = null, - tint = MaterialTheme.customColors.onSurfaceVariantLink, + tint = MaterialTheme.customColors.onSurfaceVariantLink ) } ) @@ -591,7 +592,7 @@ fun LongPressMenuButton( text: String, onClick: () -> Unit, modifier: Modifier = Modifier, - enabled: Boolean = true, + 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 @@ -601,18 +602,18 @@ fun LongPressMenuButton( shape = MaterialTheme.shapes.large, contentPadding = PaddingValues(start = 3.dp, top = 8.dp, end = 3.dp, bottom = 2.dp), border = null, - modifier = modifier, + modifier = modifier ) { Column(horizontalAlignment = Alignment.CenterHorizontally) { Icon( imageVector = icon, contentDescription = null, - modifier = Modifier.size(32.dp), + modifier = Modifier.size(32.dp) ) FixedHeightCenteredText( text = text, lines = 2, - style = MaterialTheme.typography.bodySmall, + style = MaterialTheme.typography.bodySmall ) } } @@ -650,7 +651,7 @@ private class LongPressablePreviews : CollectionPreviewParameterProvider t.buildAction({ t != EnqueueNext }) { } }, - runActionAndDismiss = {}, + runActionAndDismiss = {} ) } } 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 d705654a9..aa6026e7d 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 @@ -64,12 +64,12 @@ import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameter import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.toSize +import kotlin.math.floor import org.schabi.newpipe.R import org.schabi.newpipe.ktx.letIf import org.schabi.newpipe.ui.detectDragGestures import org.schabi.newpipe.ui.theme.AppTheme import org.schabi.newpipe.util.text.FixedHeightCenteredText -import kotlin.math.floor /** * When making changes to this composable and to [LongPressMenuEditorState], make sure to test the @@ -112,7 +112,7 @@ fun LongPressMenuEditor(modifier: Modifier = Modifier) { .detectDragGestures( beginDragGesture = state::beginDragGesture, handleDragGestureChange = state::handleDragGestureChange, - endDragGesture = state::completeDragGestureAndCleanUp, + endDragGesture = state::completeDragGestureAndCleanUp ) // `.focusTarget().onKeyEvent()` handles DPAD on Android TVs .focusTarget() @@ -120,12 +120,12 @@ fun LongPressMenuEditor(modifier: Modifier = Modifier) { // same width as the LongPressMenu columns = GridCells.Adaptive(MinButtonWidth), userScrollEnabled = false, - state = gridState, + state = gridState ) { itemsIndexed( state.items, key = { _, item -> item.stableUniqueKey() }, - span = { _, item -> GridItemSpan(item.columnSpan ?: maxLineSpan) }, + span = { _, item -> GridItemSpan(item.columnSpan ?: maxLineSpan) } ) { i, item -> ItemInListUi( item = item, @@ -151,7 +151,7 @@ fun LongPressMenuEditor(modifier: Modifier = Modifier) { .size(size) .offset { state.activeDragPosition } .offset(-size.width / 2, -size.height / 2) - .offset((-24).dp, (-24).dp), + .offset((-24).dp, (-24).dp) ) } } @@ -162,7 +162,7 @@ private fun Subheader( selected: Boolean, @StringRes title: Int, @StringRes description: Int, - modifier: Modifier = Modifier, + modifier: Modifier = Modifier ) { Column( modifier = modifier @@ -177,7 +177,7 @@ private fun Subheader( Text( text = stringResource(description), fontStyle = FontStyle.Italic, - style = MaterialTheme.typography.bodyMedium, + style = MaterialTheme.typography.bodyMedium ) } } @@ -190,7 +190,7 @@ private fun ActionOrHeaderBox( contentColor: Color, modifier: Modifier = Modifier, backgroundColor: Color = Color.Transparent, - horizontalPadding: Dp = 3.dp, + horizontalPadding: Dp = 3.dp ) { Surface( color = backgroundColor, @@ -199,19 +199,19 @@ private fun ActionOrHeaderBox( border = BorderStroke(2.dp, contentColor.copy(alpha = 1f)).takeIf { selected }, modifier = modifier.padding( horizontal = horizontalPadding, - vertical = 5.dp, - ), + vertical = 5.dp + ) ) { Column(horizontalAlignment = Alignment.CenterHorizontally) { Icon( imageVector = icon, contentDescription = null, - modifier = Modifier.size(32.dp), + modifier = Modifier.size(32.dp) ) FixedHeightCenteredText( text = stringResource(text), lines = 2, - style = MaterialTheme.typography.bodySmall, + style = MaterialTheme.typography.bodySmall ) } } @@ -221,7 +221,7 @@ private fun ActionOrHeaderBox( private fun ItemInListUi( item: ItemInList, selected: Boolean, - modifier: Modifier = Modifier, + modifier: Modifier = Modifier ) { when (item) { ItemInList.EnabledCaption -> { @@ -229,26 +229,29 @@ private fun ItemInListUi( modifier = modifier, selected = selected, title = R.string.long_press_menu_enabled_actions, - description = R.string.long_press_menu_enabled_actions_description, + 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, + 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, + contentColor = MaterialTheme.colorScheme.onSurface ) } + ItemInList.HeaderBox -> { ActionOrHeaderBox( modifier = modifier, @@ -257,9 +260,10 @@ private fun ItemInListUi( text = R.string.long_press_menu_header, contentColor = MaterialTheme.colorScheme.onSurfaceVariant, backgroundColor = MaterialTheme.colorScheme.surfaceContainer, - horizontalPadding = 12.dp, + horizontalPadding = 12.dp ) } + ItemInList.NoneMarker -> { ActionOrHeaderBox( modifier = modifier, @@ -267,16 +271,17 @@ private fun ItemInListUi( 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), + 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), + contentColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f) ) } } diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressMenuEditorState.kt b/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressMenuEditorState.kt index f33f434c0..c1cf57a74 100644 --- a/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressMenuEditorState.kt +++ b/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressMenuEditorState.kt @@ -20,14 +20,14 @@ import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.type import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntSize +import kotlin.math.abs +import kotlin.math.max +import kotlin.math.min import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.launch -import kotlin.math.abs -import kotlin.math.max -import kotlin.math.min private const val TAG = "LongPressMenuEditorStat" @@ -43,7 +43,7 @@ private const val TAG = "LongPressMenuEditorStat" class LongPressMenuEditorState( context: Context, val gridState: LazyGridState, - val coroutineScope: CoroutineScope, + val coroutineScope: CoroutineScope ) { val items = run { // We get the current arrangement once and do not observe on purpose. @@ -99,10 +99,10 @@ class LongPressMenuEditorState( private fun autoScrollSpeedFromTouchPos( touchPos: IntOffset, maxSpeed: Float = 20f, - scrollIfCloseToBorderPercent: Float = 0.2f, + scrollIfCloseToBorderPercent: Float = 0.2f ): Float { val heightPosRatio = touchPos.y.toFloat() / - (gridState.layoutInfo.viewportEndOffset - gridState.layoutInfo.viewportStartOffset) + (gridState.layoutInfo.viewportEndOffset - gridState.layoutInfo.viewportStartOffset) // just a linear piecewise function, sets higher speeds the closer the finger is to the border return maxSpeed * max( // proportionally positive speed when close to the bottom border @@ -390,11 +390,12 @@ class LongPressMenuEditorState( sealed class ItemInList( val isDraggable: Boolean = false, val isCaption: Boolean = false, - open val columnSpan: Int? = 1, + // if null, then the item will occupy all of the line + open val columnSpan: Int? = 1 ) { // decoration items (i.e. text subheaders) - object EnabledCaption : ItemInList(isCaption = true, columnSpan = null /* i.e. all line */) - object HiddenCaption : ItemInList(isCaption = true, columnSpan = null /* i.e. all line */) + object EnabledCaption : ItemInList(isCaption = true, columnSpan = null) // i.e. span all line + object HiddenCaption : ItemInList(isCaption = true, columnSpan = null) // i.e. span all line // actual draggable actions (+ a header) object HeaderBox : ItemInList(isDraggable = true, columnSpan = 2) 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 ebafa71b8..194cfdb9a 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,7 @@ package org.schabi.newpipe.ui.components.menu import androidx.compose.runtime.Stable +import java.time.OffsetDateTime import org.schabi.newpipe.database.playlist.PlaylistMetadataEntry import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity import org.schabi.newpipe.database.stream.model.StreamEntity @@ -14,7 +15,6 @@ import org.schabi.newpipe.extractor.stream.StreamType.LIVE_STREAM import org.schabi.newpipe.player.playqueue.PlayQueueItem import org.schabi.newpipe.util.Either import org.schabi.newpipe.util.image.ImageStrategy -import java.time.OffsetDateTime @Stable data class LongPressable( @@ -26,7 +26,7 @@ data class LongPressable( val viewCount: Long?, val streamType: StreamType?, // only used to format the view count properly val uploadDate: Either?, - val decoration: Decoration?, + val decoration: Decoration? ) { sealed interface Decoration { data class Duration(val duration: Long) : Decoration @@ -34,12 +34,11 @@ data class LongPressable( 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) } - } + internal fun from(streamType: StreamType, duration: Long) = if (streamType == LIVE_STREAM || streamType == AUDIO_LIVE_STREAM) { + Live + } else { + duration.takeIf { it > 0 }?.let { Duration(it) } + } } } @@ -55,7 +54,7 @@ data class LongPressable( streamType = item.streamType, uploadDate = item.uploadDate?.let { Either.right(it.offsetDateTime()) } ?: item.textualUploadDate?.let { Either.left(it) }, - decoration = Decoration.from(item.streamType, item.duration), + decoration = Decoration.from(item.streamType, item.duration) ) @JvmStatic @@ -69,7 +68,7 @@ data class LongPressable( streamType = item.streamType, uploadDate = item.uploadDate?.let { Either.right(it) } ?: item.textualUploadDate?.let { Either.left(it) }, - decoration = Decoration.from(item.streamType, item.duration), + decoration = Decoration.from(item.streamType, item.duration) ) @JvmStatic @@ -82,7 +81,7 @@ data class LongPressable( viewCount = null, streamType = item.streamType, uploadDate = null, - decoration = Decoration.from(item.streamType, item.duration), + decoration = Decoration.from(item.streamType, item.duration) ) @JvmStatic @@ -96,7 +95,7 @@ data class LongPressable( viewCount = null, streamType = null, uploadDate = null, - decoration = Decoration.Playlist(item.streamCount), + decoration = Decoration.Playlist(item.streamCount) ) @JvmStatic @@ -111,7 +110,7 @@ data class LongPressable( uploadDate = null, decoration = Decoration.Playlist( item.streamCount ?: ListExtractor.ITEM_COUNT_UNKNOWN - ), + ) ) @JvmStatic @@ -124,7 +123,7 @@ data class LongPressable( viewCount = null, streamType = null, uploadDate = null, - decoration = null, + decoration = null ) @JvmStatic @@ -137,7 +136,7 @@ data class LongPressable( viewCount = null, streamType = null, uploadDate = null, - decoration = Decoration.Playlist(item.streamCount), + decoration = Decoration.Playlist(item.streamCount) ) } } diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/menu/SparseItemUtil.kt b/app/src/main/java/org/schabi/newpipe/ui/components/menu/SparseItemUtil.kt index 33f03ad5c..e10a0e9a9 100644 --- a/app/src/main/java/org/schabi/newpipe/ui/components/menu/SparseItemUtil.kt +++ b/app/src/main/java/org/schabi/newpipe/ui/components/menu/SparseItemUtil.kt @@ -33,7 +33,7 @@ import org.schabi.newpipe.util.StreamTypeUtil @MainThread suspend fun fetchItemInfoIfSparse( context: Context, - item: StreamInfoItem, + item: StreamInfoItem ): SinglePlayQueue { if ((StreamTypeUtil.isLiveStream(item.streamType) || item.duration >= 0) && !Utils.isNullOrEmpty(item.uploaderUrl) @@ -66,7 +66,7 @@ suspend fun fetchUploaderUrlIfSparse( context: Context, serviceId: Int, url: String, - uploaderUrl: String?, + uploaderUrl: String? ): String? { if (!uploaderUrl.isNullOrEmpty()) { return uploaderUrl @@ -89,7 +89,7 @@ suspend fun fetchUploaderUrlIfSparse( suspend fun fetchStreamInfoAndSaveToDatabase( context: Context, serviceId: Int, - url: String, + url: String ): StreamInfo { Toast.makeText(context, R.string.loading_stream_details, Toast.LENGTH_SHORT).show() 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 573aa445c..9c3e2ce91 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 @@ -146,6 +146,6 @@ private fun BackgroundFromHerePreview() { Icon( imageVector = Icons.Filled.BackgroundFromHere, contentDescription = null, - modifier = Modifier.size(240.dp), + 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 index 0af14bbe3..f736b40dc 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 @@ -122,6 +122,6 @@ private fun PlayFromHerePreview() { Icon( imageVector = Icons.Filled.PlayFromHere, contentDescription = null, - modifier = Modifier.size(240.dp), + 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 index b33648a96..f456b48d0 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 @@ -158,6 +158,6 @@ private fun PopupFromHerePreview() { Icon( imageVector = Icons.Filled.PopupFromHere, contentDescription = null, - modifier = Modifier.size(240.dp), + modifier = Modifier.size(240.dp) ) } 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 index aac91ee04..832d71fc6 100644 --- a/app/src/main/java/org/schabi/newpipe/ui/theme/CustomColors.kt +++ b/app/src/main/java/org/schabi/newpipe/ui/theme/CustomColors.kt @@ -9,7 +9,7 @@ import androidx.compose.ui.graphics.Color @Immutable data class CustomColors( - val onSurfaceVariantLink: Color = Color.Unspecified, + val onSurfaceVariantLink: Color = Color.Unspecified ) val onSurfaceVariantLinkLight = Color(0xFF5060B0) diff --git a/app/src/main/java/org/schabi/newpipe/util/Either.kt b/app/src/main/java/org/schabi/newpipe/util/Either.kt index 9d1f8f0f2..e5b2b9451 100644 --- a/app/src/main/java/org/schabi/newpipe/util/Either.kt +++ b/app/src/main/java/org/schabi/newpipe/util/Either.kt @@ -9,7 +9,7 @@ import kotlin.reflect.safeCast data class Either( val value: Any, val classA: KClass, - val classB: KClass, + val classB: KClass ) { inline fun match(ifLeft: (A) -> R, ifRight: (B) -> R): R { return classA.safeCast(value)?.let { ifLeft(it) } @@ -17,9 +17,7 @@ data class Either( } 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) + inline fun left(a: A): Either = Either(a, A::class, B::class) + inline fun right(b: B): Either = Either(b, A::class, B::class) } } 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 index 57de24269..18ca425e6 100644 --- a/app/src/main/java/org/schabi/newpipe/util/text/FixedHeightCenteredText.kt +++ b/app/src/main/java/org/schabi/newpipe/util/text/FixedHeightCenteredText.kt @@ -18,7 +18,7 @@ fun FixedHeightCenteredText( text: String, lines: Int, modifier: Modifier = Modifier, - style: TextStyle = LocalTextStyle.current, + style: TextStyle = LocalTextStyle.current ) { Box(modifier = modifier) { // this allows making the box always the same height (i.e. the height of [lines] text @@ -26,7 +26,7 @@ fun FixedHeightCenteredText( Text( text = "", style = style, - minLines = lines, + minLines = lines ) Text( text = text, From ec75ddabdacb06297a573330bfeb58978476bf7b Mon Sep 17 00:00:00 2001 From: Stypox Date: Tue, 3 Feb 2026 15:25:38 +0100 Subject: [PATCH 57/87] Only show Enqueue and EnqueueNext if player open --- .../ui/components/menu/LongPressAction.kt | 20 ++++++++++++++----- .../ui/components/menu/LongPressMenu.kt | 3 ++- 2 files changed, 17 insertions(+), 6 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 757ccf665..4e33e9282 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 @@ -45,6 +45,7 @@ import org.schabi.newpipe.local.dialog.PlaylistAppendDialog import org.schabi.newpipe.local.dialog.PlaylistDialog import org.schabi.newpipe.local.history.HistoryRecordManager import org.schabi.newpipe.local.playlist.LocalPlaylistManager +import org.schabi.newpipe.player.helper.PlayerHolder import org.schabi.newpipe.player.playqueue.ChannelTabPlayQueue import org.schabi.newpipe.player.playqueue.PlayQueue import org.schabi.newpipe.player.playqueue.PlayQueueItem @@ -60,7 +61,7 @@ data class LongPressAction( val type: Type, @MainThread val action: suspend (context: Context) -> Unit, - val enabled: (isPlayerRunning: Boolean) -> Boolean = { true } + val enabled: () -> Boolean = { true } ) { enum class Type( /** @@ -95,11 +96,10 @@ data class LongPressAction( Remove(21, R.string.play_queue_remove, 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 }, + enabled: () -> Boolean = { true }, action: suspend (context: Context) -> Unit ) = LongPressAction(this, action, enabled) @@ -119,10 +119,20 @@ data class LongPressAction( queue: suspend (Context) -> PlayQueue ): List { return listOf( - Type.Enqueue.buildAction({ isPlayerRunning -> isPlayerRunning }) { context -> + // TODO once NewPlayer will be used, make it so that the enabled states of Enqueue + // and EnqueueNext are a State<> that changes realtime based on the actual evolving + // player state + Type.Enqueue.buildAction( + enabled = { PlayerHolder.isPlayQueueReady } + ) { context -> NavigationHelper.enqueueOnPlayer(context, queue(context)) }, - Type.EnqueueNext.buildAction({ isPlayerRunning -> isPlayerRunning }) { context -> + Type.EnqueueNext.buildAction( + enabled = { + PlayerHolder.isPlayQueueReady && + (PlayerHolder.queuePosition < PlayerHolder.queueSize - 1) + } + ) { context -> NavigationHelper.enqueueNextOnPlayer(context, queue(context)) }, Type.Background.buildAction { context -> 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 74a7cb4d6..c7ce4fa0a 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 @@ -88,6 +88,7 @@ import org.schabi.newpipe.error.ErrorInfo import org.schabi.newpipe.error.ErrorUtil import org.schabi.newpipe.error.UserAction.LONG_PRESS_MENU_ACTION import org.schabi.newpipe.extractor.stream.StreamType +import org.schabi.newpipe.player.helper.PlayerHolder 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 @@ -273,7 +274,7 @@ private fun LongPressMenuContent( icon = action.type.icon, text = stringResource(action.type.label), onClick = { runActionAndDismiss(action) }, - enabled = action.enabled(false), + enabled = action.enabled(), modifier = Modifier .height(buttonHeight) .fillMaxWidth() From 5e0b307c5e3168d1acdc245366a8bde6d21a70f1 Mon Sep 17 00:00:00 2001 From: Stypox Date: Tue, 3 Feb 2026 16:29:43 +0100 Subject: [PATCH 58/87] Allow playing local playlists directly --- .../playqueue/LocalPlaylistPlayQueue.kt | 49 +++++++++++++++++++ .../ui/components/menu/LongPressAction.kt | 22 ++++----- 2 files changed, 60 insertions(+), 11 deletions(-) create mode 100644 app/src/main/java/org/schabi/newpipe/player/playqueue/LocalPlaylistPlayQueue.kt diff --git a/app/src/main/java/org/schabi/newpipe/player/playqueue/LocalPlaylistPlayQueue.kt b/app/src/main/java/org/schabi/newpipe/player/playqueue/LocalPlaylistPlayQueue.kt new file mode 100644 index 000000000..ab0be643f --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/playqueue/LocalPlaylistPlayQueue.kt @@ -0,0 +1,49 @@ +package org.schabi.newpipe.player.playqueue + +import android.util.Log +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.disposables.Disposable +import io.reactivex.rxjava3.schedulers.Schedulers +import org.schabi.newpipe.App +import org.schabi.newpipe.NewPipeDatabase +import org.schabi.newpipe.database.playlist.PlaylistMetadataEntry +import org.schabi.newpipe.local.playlist.LocalPlaylistManager + +class LocalPlaylistPlayQueue(info: PlaylistMetadataEntry) : PlayQueue(0, listOf()) { + private val playlistId: Long = info.uid + private var fetchDisposable: Disposable? = null + override var isComplete: Boolean = false + private set + + override fun fetch() { + if (isComplete) { + return + } + isComplete = true + + fetchDisposable = LocalPlaylistManager(NewPipeDatabase.getInstance(App.instance)) + .getPlaylistStreams(playlistId) + .firstOrError() + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { streamEntries -> + append(streamEntries.map { PlayQueueItem(it.toStreamInfoItem()) }) + }, + { e -> + Log.e(TAG, "Error fetching local playlist", e) + notifyChange() + } + ) + } + + override fun dispose() { + super.dispose() + fetchDisposable?.dispose() + fetchDisposable = null + } + + companion object { + private val TAG: String = LocalPlaylistPlayQueue::class.java.getSimpleName() + } +} 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 4e33e9282..d2099e3f4 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 @@ -25,6 +25,7 @@ import androidx.compose.material.icons.filled.Share import androidx.compose.ui.graphics.vector.ImageVector import androidx.core.net.toUri import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.reactive.awaitFirst import kotlinx.coroutines.rx3.await import kotlinx.coroutines.rx3.awaitSingle import kotlinx.coroutines.withContext @@ -47,6 +48,7 @@ import org.schabi.newpipe.local.history.HistoryRecordManager import org.schabi.newpipe.local.playlist.LocalPlaylistManager import org.schabi.newpipe.player.helper.PlayerHolder import org.schabi.newpipe.player.playqueue.ChannelTabPlayQueue +import org.schabi.newpipe.player.playqueue.LocalPlaylistPlayQueue import org.schabi.newpipe.player.playqueue.PlayQueue import org.schabi.newpipe.player.playqueue.PlayQueueItem import org.schabi.newpipe.player.playqueue.PlaylistPlayQueue @@ -93,10 +95,7 @@ data class LongPressAction( UnsetPlaylistThumbnail(18, R.string.unset_playlist_thumbnail, Icons.Default.HideImage), Unsubscribe(19, R.string.unsubscribe, Icons.Default.Delete), ShowDetails(20, R.string.play_queue_stream_detail, Icons.Default.Info), - Remove(21, R.string.play_queue_remove, Icons.Default.Delete) - ; - - // TODO add actions that use the whole list the item belongs to (see wholeListQueue) + Remove(21, R.string.play_queue_remove, Icons.Default.Delete); fun buildAction( enabled: () -> Boolean = { true }, @@ -338,13 +337,14 @@ data class LongPressAction( 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() } - ) + return buildPlayerActionList { LocalPlaylistPlayQueue(item) } + + listOf( + Type.Rename.buildAction { onRename.run() }, + Type.Delete.buildAction { onDelete.run() }, + Type.UnsetPlaylistThumbnail.buildAction( + enabled = { unsetPlaylistThumbnail != null } + ) { unsetPlaylistThumbnail?.run() } + ) } @JvmStatic From 378fdef27a0fb2605285f66ef4030299cc419955 Mon Sep 17 00:00:00 2001 From: Stypox Date: Tue, 3 Feb 2026 16:40:13 +0100 Subject: [PATCH 59/87] Fix opening channel fragment from anywhere --- .../schabi/newpipe/ui/components/menu/LongPressAction.kt | 9 ++++----- 1 file changed, 4 insertions(+), 5 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 d2099e3f4..a79318b28 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 @@ -25,7 +25,6 @@ import androidx.compose.material.icons.filled.Share import androidx.compose.ui.graphics.vector.ImageVector import androidx.core.net.toUri import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.reactive.awaitFirst import kotlinx.coroutines.rx3.await import kotlinx.coroutines.rx3.awaitSingle import kotlinx.coroutines.withContext @@ -214,8 +213,8 @@ data class LongPressAction( item.url, item.uploaderUrl ) - NavigationHelper.openChannelFragment( - context.findFragmentActivity().supportFragmentManager, + NavigationHelper.openChannelFragmentUsingIntent( + context, item.serviceId, uploaderUrl, item.uploaderName @@ -372,8 +371,8 @@ data class LongPressAction( buildShareActionList(item) + listOfNotNull( Type.ShowChannelDetails.buildAction { context -> - NavigationHelper.openChannelFragment( - context.findFragmentActivity().supportFragmentManager, + NavigationHelper.openChannelFragmentUsingIntent( + context, item.serviceId, item.url, item.name From 96a57803a6c9c3946e31638f9a51824e3a637701 Mon Sep 17 00:00:00 2001 From: Stypox Date: Sat, 7 Feb 2026 09:45:27 +0100 Subject: [PATCH 60/87] Add reset button to long press menu editor --- .../ui/components/menu/LongPressMenu.kt | 7 +- .../ui/components/menu/LongPressMenuEditor.kt | 160 ++++++++++++------ .../menu/LongPressMenuEditorState.kt | 31 +++- app/src/main/res/values/strings.xml | 4 +- 4 files changed, 130 insertions(+), 72 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 c7ce4fa0a..400f91f28 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 @@ -151,12 +151,7 @@ fun LongPressMenu( 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)) - } + LongPressMenuEditorPage { showEditor = false } } } else { val enabledLongPressActions by remember { 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 aa6026e7d..36bfd5fa6 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 @@ -38,16 +38,24 @@ 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.material.icons.filled.RestartAlt +import androidx.compose.material3.AlertDialog 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.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TextButton 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.rememberCoroutineScope +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.focus.focusTarget @@ -67,6 +75,7 @@ import androidx.compose.ui.unit.toSize import kotlin.math.floor import org.schabi.newpipe.R import org.schabi.newpipe.ktx.letIf +import org.schabi.newpipe.ui.components.common.ScaffoldWithToolbar import org.schabi.newpipe.ui.detectDragGestures import org.schabi.newpipe.ui.theme.AppTheme import org.schabi.newpipe.util.text.FixedHeightCenteredText @@ -87,7 +96,7 @@ import org.schabi.newpipe.util.text.FixedHeightCenteredText * @author This composable was originally copied from FlorisBoard. */ @Composable -fun LongPressMenuEditor(modifier: Modifier = Modifier) { +fun LongPressMenuEditorPage(onBackClick: () -> Unit) { val context = LocalContext.current val gridState = rememberLazyGridState() val coroutineScope = rememberCoroutineScope() @@ -101,62 +110,103 @@ fun LongPressMenuEditor(modifier: Modifier = Modifier) { } } - // test scrolling on Android TV by adding `.padding(horizontal = 350.dp)` here - 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()) + ScaffoldWithToolbar( + title = stringResource(R.string.long_press_menu_actions_editor), + onBackClick = onBackClick, + actions = { + ResetToDefaultsButton(state::resetToDefaults) + } + ) { paddingValues -> + // test scrolling on Android TV by adding `.padding(horizontal = 350.dp)` here + BoxWithConstraints(Modifier.padding(paddingValues)) { + // 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 = state::beginDragGesture, - handleDragGestureChange = state::handleDragGestureChange, - endDragGesture = state::completeDragGestureAndCleanUp - ) - // `.focusTarget().onKeyEvent()` handles DPAD on Android TVs - .focusTarget() - .onKeyEvent { event -> state.onKeyEvent(event, columns) }, - // same width as the LongPressMenu - columns = GridCells.Adaptive(MinButtonWidth), - userScrollEnabled = false, - state = gridState - ) { - itemsIndexed( - state.items, - key = { _, item -> item.stableUniqueKey() }, - span = { _, item -> GridItemSpan(item.columnSpan ?: maxLineSpan) } - ) { i, item -> - ItemInListUi( - item = item, - selected = state.currentlyFocusedItem == i, - // We only want placement animations: fade in/out animations interfere with - // items being replaced by a drag marker while being dragged around, and a fade - // in/out animation there does not make sense as the item was just "picked up". - // Furthermore there are strange moving animation artifacts when moving and - // releasing items quickly before their fade-out animation finishes. - modifier = Modifier.animateItem(fadeInSpec = null, fadeOutSpec = null) - ) - } - } - state.activeDragItem?.let { activeDragItem -> - // draw it the same size as the selected item, - val size = with(LocalDensity.current) { - remember(state.activeDragSize) { state.activeDragSize.toSize().toDpSize() } - } - ItemInListUi( - item = activeDragItem, - selected = true, + LazyVerticalGrid( modifier = Modifier - .size(size) - .offset { state.activeDragPosition } - .offset(-size.width / 2, -size.height / 2) - .offset((-24).dp, (-24).dp) - ) + .safeDrawingPadding() + .detectDragGestures( + beginDragGesture = state::beginDragGesture, + handleDragGestureChange = state::handleDragGestureChange, + endDragGesture = state::completeDragGestureAndCleanUp + ) + // `.focusTarget().onKeyEvent()` handles DPAD on Android TVs + .focusTarget() + .onKeyEvent { event -> state.onKeyEvent(event, columns) }, + // same width as the LongPressMenu + columns = GridCells.Adaptive(MinButtonWidth), + userScrollEnabled = false, + state = gridState + ) { + itemsIndexed( + state.items, + key = { _, item -> item.stableUniqueKey() }, + span = { _, item -> GridItemSpan(item.columnSpan ?: maxLineSpan) } + ) { i, item -> + ItemInListUi( + item = item, + selected = state.currentlyFocusedItem == i, + // We only want placement animations: fade in/out animations interfere with + // items being replaced by a drag marker while being dragged around, and a + // fade in/out animation there does not make sense as the item was just + // "picked up". Furthermore there are strange moving animation artifacts + // when moving and releasing items quickly before their fade-out animation + // finishes. + modifier = Modifier.animateItem(fadeInSpec = null, fadeOutSpec = null) + ) + } + } + state.activeDragItem?.let { activeDragItem -> + // draw it the same size as the selected item, + val size = with(LocalDensity.current) { + remember(state.activeDragSize) { state.activeDragSize.toSize().toDpSize() } + } + ItemInListUi( + item = activeDragItem, + selected = true, + modifier = Modifier + .size(size) + .offset { state.activeDragPosition } + .offset(-size.width / 2, -size.height / 2) + .offset((-24).dp, (-24).dp) + ) + } } } } +@Composable +private fun ResetToDefaultsButton(onClick: () -> Unit) { + var showDialog by rememberSaveable { mutableStateOf(false) } + + if (showDialog) { + AlertDialog( + onDismissRequest = { showDialog = false }, + text = { Text(stringResource(R.string.long_press_menu_reset_to_defaults_confirm)) }, + confirmButton = { + TextButton(onClick = { + onClick() + showDialog = false + }) { + Text(stringResource(R.string.ok)) + } + }, + dismissButton = { + TextButton(onClick = { showDialog = false }) { + Text(stringResource(R.string.cancel)) + } + } + ) + } + + IconButton(onClick = { showDialog = true }) { + Icon( + imageVector = Icons.Default.RestartAlt, + contentDescription = stringResource(R.string.playback_reset) + ) + } +} + @Composable private fun Subheader( selected: Boolean, @@ -290,11 +340,9 @@ private fun ItemInListUi( @Preview @Preview(device = "spec:width=1080px,height=1000px,dpi=440") @Composable -private fun LongPressMenuEditorPreview() { +private fun LongPressMenuEditorPagePreview() { AppTheme { - Surface { - LongPressMenuEditor() - } + LongPressMenuEditorPage { } } } @@ -313,7 +361,7 @@ private fun QuickActionButtonPreview( ItemInListUi( item = itemInList, selected = itemInList.stableUniqueKey() % 2 == 0, - modifier = Modifier.width(MinButtonWidth) + modifier = Modifier.width(MinButtonWidth * (itemInList.columnSpan ?: 4)) ) } } diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressMenuEditorState.kt b/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressMenuEditorState.kt index c1cf57a74..89f005105 100644 --- a/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressMenuEditorState.kt +++ b/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressMenuEditorState.kt @@ -20,6 +20,7 @@ import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.type import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntSize +import kotlin.collections.ifEmpty import kotlin.math.abs import kotlin.math.max import kotlin.math.min @@ -49,7 +50,22 @@ class LongPressMenuEditorState( // We get the current arrangement once and do not observe on purpose. val isHeaderEnabled = loadIsHeaderEnabledFromSettings(context) val actionArrangement = loadLongPressActionArrangementFromSettings(context) - sequence { + return@run buildItemsInList(isHeaderEnabled, actionArrangement).toMutableStateList() + } + + // variables for handling drag, focus, and autoscrolling when finger is at top/bottom + var activeDragItem by mutableStateOf(null) + var activeDragPosition by mutableStateOf(IntOffset.Zero) + var activeDragSize by mutableStateOf(IntSize.Zero) + var currentlyFocusedItem by mutableIntStateOf(-1) + var autoScrollJob by mutableStateOf(null) + var autoScrollSpeed by mutableFloatStateOf(0f) + + private fun buildItemsInList( + isHeaderEnabled: Boolean, + actionArrangement: List + ): List { + return sequence { yield(ItemInList.EnabledCaption) if (isHeaderEnabled) { yield(ItemInList.HeaderBox) @@ -69,16 +85,13 @@ class LongPressMenuEditorState( .map { ItemInList.Action(it) } .ifEmpty { if (isHeaderEnabled) listOf(ItemInList.NoneMarker) else listOf() } ) - }.toList().toMutableStateList() + }.toList() } - // variables for handling drag, focus, and autoscrolling when finger is at top/bottom - var activeDragItem by mutableStateOf(null) - var activeDragPosition by mutableStateOf(IntOffset.Zero) - var activeDragSize by mutableStateOf(IntSize.Zero) - var currentlyFocusedItem by mutableIntStateOf(-1) - var autoScrollJob by mutableStateOf(null) - var autoScrollSpeed by mutableFloatStateOf(0f) + fun resetToDefaults() { + items.clear() + items.addAll(buildItemsInList(true, LongPressAction.Type.DefaultEnabledActions)) + } private fun findItemForOffsetOrClosestInRow(offset: IntOffset): LazyGridItemInfo? { var closestItemInRow: LazyGridItemInfo? = null diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7644ed5ef..7f6195382 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -907,7 +907,9 @@ Reorder the actions by long pressing them and then dragging them around Hidden actions: Drag the header or the actions to this section to hide them - Header with title, thumbnail, and clickable channel + Header with thumbnail, title, clickable channel Back + Reset to defaults + Are you sure you want to reset to the default actions? Reorder and hide actions From b3b6cf30dc6c4264398563f5d9ff6cd85591d704 Mon Sep 17 00:00:00 2001 From: Stypox Date: Sat, 7 Feb 2026 11:42:01 +0100 Subject: [PATCH 61/87] Add bg/popup/play shuffled actions --- .../newpipe/player/playqueue/PlayQueue.kt | 54 ++++++++++ .../ui/components/menu/LongPressAction.kt | 79 ++++++++++----- .../menu/icons/BackgroundShuffled.kt | 99 +++++++++++++++++++ .../ui/components/menu/icons/PlayShuffled.kt | 63 ++++++++++++ .../ui/components/menu/icons/PopupShuffled.kt | 80 +++++++++++++++ app/src/main/res/values/strings.xml | 3 + 6 files changed, 353 insertions(+), 25 deletions(-) create mode 100644 app/src/main/java/org/schabi/newpipe/ui/components/menu/icons/BackgroundShuffled.kt create mode 100644 app/src/main/java/org/schabi/newpipe/ui/components/menu/icons/PlayShuffled.kt create mode 100644 app/src/main/java/org/schabi/newpipe/ui/components/menu/icons/PopupShuffled.kt diff --git a/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueue.kt b/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueue.kt index 1daf311a7..ca0da509a 100644 --- a/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueue.kt +++ b/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueue.kt @@ -7,6 +7,7 @@ import io.reactivex.rxjava3.subjects.PublishSubject import java.io.Serializable import java.util.Collections import java.util.concurrent.atomic.AtomicInteger +import kotlinx.coroutines.reactive.awaitFirst import org.schabi.newpipe.player.playqueue.PlayQueueEvent.AppendEvent import org.schabi.newpipe.player.playqueue.PlayQueueEvent.ErrorEvent import org.schabi.newpipe.player.playqueue.PlayQueueEvent.InitEvent @@ -434,6 +435,59 @@ abstract class PlayQueue internal constructor( broadcast(ReorderEvent(originalIndex, 0)) } + /** + * Repeatedly calls [fetch] until [isComplete] is `true` or some error happens, then shuffles + * the whole queue without preserving [index]. [fetch] will be called at most 10 times to avoid + * infinite loops, e.g. in case the playlist being fetched is infinite. This must be called only + * to initialize the queue in an already shuffled state, and must not be called when the queue + * is already being used e.g. by the player. The preconditions, which are also maintained as + * postconditions, are thus that the queue is in a disposed / uninitialized state, and that + * [index] is 0. + */ + suspend fun fetchAllAndShuffle() { + if (eventBroadcast != null || this.index != 0) { + throw UnsupportedOperationException( + "Can call fetchAllAndShuffle() only on an uninitialized PlayQueue" + ) + } + + if (!isComplete) { + init() + var fetchCount = 0 + while (!isComplete) { + if (fetchCount >= 10) { + // Maybe the playlist is infinite, and anyway we don't want to overload the + // servers by making too many requests. For reference, making 10 fetch requests + // will mean fetching at most 1000 items on YouTube playlists, though this + // changes among services. + break + } + fetchCount += 1 + + fetch() + + // Since `fetch()` does not return a Completable we can listen on, we have to wait + // for events in `broadcastReceiver` produced by `fetch()`. This works reliably + // because all `fetch()` implementations are supposed to notify all events (both + // completion and errors) to `broadcastReceiver`. + val event = broadcastReceiver!! + .filter { !InitEvent::class.isInstance(it) } + .awaitFirst() + if (event !is AppendEvent || event.amount <= 0) { + break // an AppendEvent with amount 0 indicates that an error occurred + } + } + dispose() + } + + // Can't shuffle a list that's empty or only has one element + if (size() <= 2) { + return + } + backup = streams.toMutableList() + streams.shuffle() + } + /** * Unshuffles the current play queue if a backup play queue exists. * 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 a79318b28..71c487165 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 @@ -52,8 +52,11 @@ import org.schabi.newpipe.player.playqueue.PlayQueue import org.schabi.newpipe.player.playqueue.PlayQueueItem import org.schabi.newpipe.player.playqueue.PlaylistPlayQueue import org.schabi.newpipe.ui.components.menu.icons.BackgroundFromHere +import org.schabi.newpipe.ui.components.menu.icons.BackgroundShuffled import org.schabi.newpipe.ui.components.menu.icons.PlayFromHere +import org.schabi.newpipe.ui.components.menu.icons.PlayShuffled import org.schabi.newpipe.ui.components.menu.icons.PopupFromHere +import org.schabi.newpipe.ui.components.menu.icons.PopupShuffled import org.schabi.newpipe.util.NavigationHelper import org.schabi.newpipe.util.external_communication.KoreUtils import org.schabi.newpipe.util.external_communication.ShareUtils @@ -73,28 +76,31 @@ data class LongPressAction( @StringRes val label: Int, val icon: ImageVector ) { - Enqueue(0, R.string.enqueue, Icons.Default.AddToQueue), - EnqueueNext(1, R.string.enqueue_next_stream, Icons.Default.QueuePlayNext), - Background(2, R.string.controls_background_title, Icons.Default.Headset), - Popup(3, R.string.controls_popup_title, Icons.Default.PictureInPicture), - Play(4, R.string.play, Icons.Default.PlayArrow), - BackgroundFromHere(5, R.string.background_from_here, Icons.Default.BackgroundFromHere), - PopupFromHere(6, R.string.popup_from_here, Icons.Default.PopupFromHere), - PlayFromHere(7, R.string.play_from_here, Icons.Default.PlayFromHere), - PlayWithKodi(8, R.string.play_with_kodi_title, Icons.Default.Cast), - Download(9, R.string.download, Icons.Default.Download), - AddToPlaylist(10, R.string.add_to_playlist, Icons.AutoMirrored.Default.PlaylistAdd), - Share(11, R.string.share, Icons.Default.Share), - OpenInBrowser(12, R.string.open_in_browser, Icons.Default.OpenInBrowser), - ShowChannelDetails(13, R.string.show_channel_details, Icons.Default.Person), - MarkAsWatched(14, R.string.mark_as_watched, Icons.Default.Done), - Delete(15, R.string.delete, Icons.Default.Delete), - Rename(16, R.string.rename, Icons.Default.Edit), - SetAsPlaylistThumbnail(17, R.string.set_as_playlist_thumbnail, Icons.Default.Image), - UnsetPlaylistThumbnail(18, R.string.unset_playlist_thumbnail, Icons.Default.HideImage), - Unsubscribe(19, R.string.unsubscribe, Icons.Default.Delete), - ShowDetails(20, R.string.play_queue_stream_detail, Icons.Default.Info), - Remove(21, R.string.play_queue_remove, Icons.Default.Delete); + ShowDetails(0, R.string.play_queue_stream_detail, Icons.Default.Info), + Enqueue(1, R.string.enqueue, Icons.Default.AddToQueue), + EnqueueNext(2, R.string.enqueue_next_stream, Icons.Default.QueuePlayNext), + Background(3, R.string.controls_background_title, Icons.Default.Headset), + Popup(4, R.string.controls_popup_title, Icons.Default.PictureInPicture), + Play(5, R.string.play, Icons.Default.PlayArrow), + BackgroundFromHere(6, R.string.background_from_here, Icons.Default.BackgroundFromHere), + PopupFromHere(7, R.string.popup_from_here, Icons.Default.PopupFromHere), + PlayFromHere(8, R.string.play_from_here, Icons.Default.PlayFromHere), + BackgroundShuffled(9, R.string.background_shuffled, Icons.Default.BackgroundShuffled), + PopupShuffled(10, R.string.popup_shuffled, Icons.Default.PopupShuffled), + PlayShuffled(11, R.string.play_shuffled, Icons.Default.PlayShuffled), + PlayWithKodi(12, R.string.play_with_kodi_title, Icons.Default.Cast), + Download(13, R.string.download, Icons.Default.Download), + AddToPlaylist(14, R.string.add_to_playlist, Icons.AutoMirrored.Default.PlaylistAdd), + Share(15, R.string.share, Icons.Default.Share), + OpenInBrowser(16, R.string.open_in_browser, Icons.Default.OpenInBrowser), + ShowChannelDetails(17, R.string.show_channel_details, Icons.Default.Person), + MarkAsWatched(18, R.string.mark_as_watched, Icons.Default.Done), + Rename(19, R.string.rename, Icons.Default.Edit), + SetAsPlaylistThumbnail(20, R.string.set_as_playlist_thumbnail, Icons.Default.Image), + UnsetPlaylistThumbnail(21, R.string.unset_playlist_thumbnail, Icons.Default.HideImage), + Delete(22, R.string.delete, Icons.Default.Delete), + Unsubscribe(23, R.string.unsubscribe, Icons.Default.Delete), + Remove(24, R.string.play_queue_remove, Icons.Default.Delete); fun buildAction( enabled: () -> Boolean = { true }, @@ -105,9 +111,9 @@ data class LongPressAction( // ShowChannelDetails is not enabled by default, since navigating to channel details can // also be done by clicking on the uploader name in the long press menu header val DefaultEnabledActions: List = listOf( - ShowDetails, Enqueue, EnqueueNext, Background, Popup, BackgroundFromHere, Download, - AddToPlaylist, Share, OpenInBrowser, MarkAsWatched, Delete, - Rename, SetAsPlaylistThumbnail, UnsetPlaylistThumbnail, Unsubscribe, Remove + ShowDetails, Enqueue, EnqueueNext, Background, Popup, BackgroundFromHere, + BackgroundShuffled, Download, AddToPlaylist, Share, OpenInBrowser, MarkAsWatched, + Rename, SetAsPlaylistThumbnail, UnsetPlaylistThumbnail, Delete, Unsubscribe, Remove ) } } @@ -159,6 +165,25 @@ data class LongPressAction( ) } + private fun buildPlayerShuffledActionList(queue: suspend (Context) -> PlayQueue): List { + val shuffledQueue: suspend (Context) -> PlayQueue = { context -> + val q = queue(context) + q.fetchAllAndShuffle() + q + } + return listOf( + Type.BackgroundShuffled.buildAction { context -> + NavigationHelper.playOnBackgroundPlayer(context, shuffledQueue(context), true) + }, + Type.PopupShuffled.buildAction { context -> + NavigationHelper.playOnPopupPlayer(context, shuffledQueue(context), true) + }, + Type.PlayShuffled.buildAction { context -> + NavigationHelper.playOnMainPlayer(context, shuffledQueue(context), false) + } + ) + } + private fun buildShareActionList(item: InfoItem): List { return listOf( Type.Share.buildAction { context -> @@ -337,6 +362,7 @@ data class LongPressAction( unsetPlaylistThumbnail: Runnable? ): List { return buildPlayerActionList { LocalPlaylistPlayQueue(item) } + + buildPlayerShuffledActionList { LocalPlaylistPlayQueue(item) } + listOf( Type.Rename.buildAction { onRename.run() }, Type.Delete.buildAction { onDelete.run() }, @@ -352,6 +378,7 @@ data class LongPressAction( onDelete: Runnable ): List { return buildPlayerActionList { PlaylistPlayQueue(item.serviceId, item.url) } + + buildPlayerShuffledActionList { PlaylistPlayQueue(item.serviceId, item.url) } + buildShareActionList( item.orderingName ?: "", item.orderingName ?: "", @@ -368,6 +395,7 @@ data class LongPressAction( onUnsubscribe: Runnable? ): List { return buildPlayerActionList { ChannelTabPlayQueue(item.serviceId, item.url) } + + buildPlayerShuffledActionList { ChannelTabPlayQueue(item.serviceId, item.url) } + buildShareActionList(item) + listOfNotNull( Type.ShowChannelDetails.buildAction { context -> @@ -385,6 +413,7 @@ data class LongPressAction( @JvmStatic fun fromPlaylistInfoItem(item: PlaylistInfoItem): List { return buildPlayerActionList { PlaylistPlayQueue(item.serviceId, item.url) } + + buildPlayerShuffledActionList { PlaylistPlayQueue(item.serviceId, item.url) } + buildShareActionList(item) } } diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/menu/icons/BackgroundShuffled.kt b/app/src/main/java/org/schabi/newpipe/ui/components/menu/icons/BackgroundShuffled.kt new file mode 100644 index 000000000..a359f326d --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/ui/components/menu/icons/BackgroundShuffled.kt @@ -0,0 +1,99 @@ +@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 [androidx.compose.material.icons.filled.Headset] + * and the tiny arrow in [androidx.compose.material.icons.filled.ContentPasteGo]. + */ +val Icons.Filled.BackgroundShuffled: ImageVector by lazy { + materialIcon(name = "Filled.BackgroundShuffled") { + materialPath { + 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(13f, 12f) + moveToRelative(3.145f, 2.135f) + lineToRelative(-2.140f, -2.135f) + lineToRelative(-1.005f, 1.005f) + lineToRelative(2.135f, 2.135f) + close() + moveToRelative(1.505f, -2.135f) + lineToRelative(1.170f, 1.170f) + lineToRelative(-5.820f, 5.815f) + lineToRelative(1.005f, 1.005f) + lineToRelative(5.825f, -5.820f) + lineToRelative(1.170f, 1.170f) + lineToRelative(0.000f, -3.340f) + close() + moveToRelative(1.215f, 4.855f) + lineToRelative(-1.005f, 1.005f) + lineToRelative(0.965f, 0.965f) + lineToRelative(-1.175f, 1.175f) + lineToRelative(3.350f, 0.000f) + lineToRelative(0.000f, -3.350f) + lineToRelative(-1.170f, 1.170f) + close() + } + /* + val thickness = 0.15f + materialPath { + moveTo(13f, 12f) + moveToRelative(3.295f - thickness, 2.585f - 3 * thickness) + lineToRelative(-2.590f + 3 * thickness, -2.585f + 3 * thickness) + lineToRelative(-0.705f - 2 * thickness, 0.705f + 2 * thickness) + lineToRelative(2.585f - 3 * thickness, 2.585f - 3 * thickness) + close() + moveToRelative(1.955f - 3 * thickness, -2.585f + 3 * thickness) + lineToRelative(1.020f + thickness, 1.020f + thickness) + lineToRelative(-6.270f + 3 * thickness, 6.265f - 3 * thickness) + lineToRelative(0.705f + 2 * thickness, 0.705f + 2 * thickness) + lineToRelative(6.275f - 3 * thickness, -6.270f + 3 * thickness) + lineToRelative(1.020f + thickness, 1.020f + thickness) + lineToRelative(0f, -2.74f - 4 * thickness) + close() + moveToRelative(0.165f + 7 * thickness, 4.705f + thickness) + lineToRelative(-0.705f - 2 * thickness, 0.705f + 2 * thickness) + lineToRelative(1.565f - 4 * thickness, 1.565f - 4 * thickness) + lineToRelative(-1.025f - thickness, 1.025f + thickness) + lineToRelative(2.750f + 4 * thickness, 0f) + lineToRelative(0f, -2.750f - 4 * thickness) + lineToRelative(-1.020f - thickness, 1.020f + thickness) + close() + } + */ + } +} + +@Preview(showBackground = true, backgroundColor = 0xFFFFFFFF) +@Composable +private fun BackgroundShuffledPreview() { + Icon( + imageVector = Icons.Filled.BackgroundShuffled, + contentDescription = null, + modifier = Modifier.size(240.dp) + ) +} diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/menu/icons/PlayShuffled.kt b/app/src/main/java/org/schabi/newpipe/ui/components/menu/icons/PlayShuffled.kt new file mode 100644 index 000000000..584c0be08 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/ui/components/menu/icons/PlayShuffled.kt @@ -0,0 +1,63 @@ +@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 [androidx.compose.material.icons.filled.PlayArrow] + * and the tiny arrow in [androidx.compose.material.icons.filled.ContentPasteGo]. + */ +val Icons.Filled.PlayShuffled: ImageVector by lazy { + materialIcon(name = "Filled.PlayShuffled") { + materialPath { + moveTo(2.5f, 2.5f) + verticalLineToRelative(14.0f) + lineToRelative(11.0f, -7.0f) + close() + } + materialPath { + moveTo(14f, 12f) + moveToRelative(3.145f, 2.135f) + lineToRelative(-2.140f, -2.135f) + lineToRelative(-1.005f, 1.005f) + lineToRelative(2.135f, 2.135f) + close() + moveToRelative(1.505f, -2.135f) + lineToRelative(1.170f, 1.170f) + lineToRelative(-5.820f, 5.815f) + lineToRelative(1.005f, 1.005f) + lineToRelative(5.825f, -5.820f) + lineToRelative(1.170f, 1.170f) + lineToRelative(0.000f, -3.340f) + close() + moveToRelative(1.215f, 4.855f) + lineToRelative(-1.005f, 1.005f) + lineToRelative(0.965f, 0.965f) + lineToRelative(-1.175f, 1.175f) + lineToRelative(3.350f, 0.000f) + lineToRelative(0.000f, -3.350f) + lineToRelative(-1.170f, 1.170f) + close() + } + } +} + +@Preview(showBackground = true, backgroundColor = 0xFFFFFFFF) +@Composable +private fun PlayFromHerePreview() { + Icon( + imageVector = Icons.Filled.PlayShuffled, + contentDescription = null, + modifier = Modifier.size(240.dp) + ) +} diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/menu/icons/PopupShuffled.kt b/app/src/main/java/org/schabi/newpipe/ui/components/menu/icons/PopupShuffled.kt new file mode 100644 index 000000000..456d8ffd5 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/ui/components/menu/icons/PopupShuffled.kt @@ -0,0 +1,80 @@ +@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 [androidx.compose.material.icons.filled.PictureInPicture] + * and the tiny arrow in [androidx.compose.material.icons.filled.ContentPasteGo]. + */ +val Icons.Filled.PopupShuffled: ImageVector by lazy { + materialIcon(name = "Filled.PopupShuffled") { + materialPath { + moveTo(19.0f, 5.0f) + horizontalLineToRelative(-8.0f) + verticalLineToRelative(5.0f) + horizontalLineToRelative(8.0f) + verticalLineToRelative(-5.0f) + close() + 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(10f) + verticalLineToRelative(-2.0f) + horizontalLineToRelative(-10f) + 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() + } + materialPath { + moveTo(15f, 12f) + moveToRelative(3.145f, 2.135f) + lineToRelative(-2.140f, -2.135f) + lineToRelative(-1.005f, 1.005f) + lineToRelative(2.135f, 2.135f) + close() + moveToRelative(1.505f, -2.135f) + lineToRelative(1.170f, 1.170f) + lineToRelative(-5.820f, 5.815f) + lineToRelative(1.005f, 1.005f) + lineToRelative(5.825f, -5.820f) + lineToRelative(1.170f, 1.170f) + lineToRelative(0.000f, -3.340f) + close() + moveToRelative(1.215f, 4.855f) + lineToRelative(-1.005f, 1.005f) + lineToRelative(0.965f, 0.965f) + lineToRelative(-1.175f, 1.175f) + lineToRelative(3.350f, 0.000f) + lineToRelative(0.000f, -3.350f) + lineToRelative(-1.170f, 1.170f) + close() + } + } +} + +@Preview(showBackground = true, backgroundColor = 0xFFFFFFFF) +@Composable +private fun PopupFromHerePreview() { + Icon( + imageVector = Icons.Filled.PopupShuffled, + 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 7f6195382..b671431f7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -912,4 +912,7 @@ Reset to defaults Are you sure you want to reset to the default actions? Reorder and hide actions + Background\nshuffled + Popup\nshuffled + Play\nshuffled From f495cc075e6666db86413fdc22dd9d026c289917 Mon Sep 17 00:00:00 2001 From: Stypox Date: Sat, 7 Feb 2026 11:56:44 +0100 Subject: [PATCH 62/87] Fix player shuffle state not starting out synchronized with queue --- app/src/main/java/org/schabi/newpipe/player/Player.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/java/org/schabi/newpipe/player/Player.java b/app/src/main/java/org/schabi/newpipe/player/Player.java index 25aed782c..4e8672153 100644 --- a/app/src/main/java/org/schabi/newpipe/player/Player.java +++ b/app/src/main/java/org/schabi/newpipe/player/Player.java @@ -611,6 +611,8 @@ public final class Player implements PlaybackListener, Listener { R.string.playback_skip_silence_key), getPlaybackSkipSilence()); final PlaybackParameters savedParameters = retrievePlaybackParametersFromPrefs(this); setPlaybackParameters(savedParameters.speed, savedParameters.pitch, playbackSkipSilence); + // synchronize the player shuffled state with the queue state + simpleExoPlayer.setShuffleModeEnabled(queue.isShuffled()); playQueue = queue; playQueue.init(); From 59841e966e45a05427d1879fe5950104eda15aa1 Mon Sep 17 00:00:00 2001 From: Stypox Date: Sat, 7 Feb 2026 12:56:04 +0100 Subject: [PATCH 63/87] Correctly handle Kodi action in long press menu Automatically enable/disable it when changing the "Show play with Kodi" setting Include it in the default actions if the "Show play with Kodi" setting is enabled Hide the Kodi action if Kodi would not support the service. --- .../settings/VideoAudioSettingsFragment.java | 4 ++ .../ui/components/menu/LongPressAction.kt | 25 ++++---- .../ui/components/menu/LongPressMenuEditor.kt | 2 +- .../menu/LongPressMenuEditorState.kt | 4 +- .../components/menu/LongPressMenuSettings.kt | 58 ++++++++++++++++++- 5 files changed, 73 insertions(+), 20 deletions(-) diff --git a/app/src/main/java/org/schabi/newpipe/settings/VideoAudioSettingsFragment.java b/app/src/main/java/org/schabi/newpipe/settings/VideoAudioSettingsFragment.java index a4d52592f..a382908f0 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/VideoAudioSettingsFragment.java +++ b/app/src/main/java/org/schabi/newpipe/settings/VideoAudioSettingsFragment.java @@ -1,5 +1,7 @@ package org.schabi.newpipe.settings; +import static org.schabi.newpipe.ui.components.menu.LongPressMenuSettingsKt.addOrRemoveKodiLongPressAction; + import android.content.SharedPreferences; import android.content.res.Resources; import android.os.Bundle; @@ -49,6 +51,8 @@ public class VideoAudioSettingsFragment extends BasePreferenceFragment { updateSeekOptions(); } else if (getString(R.string.show_higher_resolutions_key).equals(key)) { updateResolutionOptions(); + } else if (getString(R.string.show_play_with_kodi_key).equals(key)) { + addOrRemoveKodiLongPressAction(requireContext()); } }; } 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 71c487165..6b58896ce 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 @@ -106,16 +106,6 @@ data class LongPressAction( enabled: () -> Boolean = { true }, action: suspend (context: Context) -> Unit ) = LongPressAction(this, action, enabled) - - companion object { - // ShowChannelDetails is not enabled by default, since navigating to channel details can - // also be done by clicking on the uploader name in the long press menu header - val DefaultEnabledActions: List = listOf( - ShowDetails, Enqueue, EnqueueNext, Background, Popup, BackgroundFromHere, - BackgroundShuffled, Download, AddToPlaylist, Share, OpenInBrowser, MarkAsWatched, - Rename, SetAsPlaylistThumbnail, UnsetPlaylistThumbnail, Delete, Unsubscribe, Remove - ) - } } companion object { @@ -249,11 +239,17 @@ data class LongPressAction( withContext(Dispatchers.IO) { HistoryRecordManager(context).markAsWatched(item).await() } - }, - Type.PlayWithKodi.buildAction { context -> - KoreUtils.playWithKore(context, item.url.toUri()) } - ) + ) + + if (KoreUtils.isServiceSupportedByKore(item.serviceId)) { + listOf( + Type.PlayWithKodi.buildAction( + enabled = { KoreUtils.isServiceSupportedByKore(item.serviceId) } + ) { context -> KoreUtils.playWithKore(context, item.url.toUri()) } + ) + } else { + listOf() + } } /** @@ -264,7 +260,6 @@ data class LongPressAction( fun fromStreamInfoItem( item: StreamInfoItem, queueFromHere: (() -> PlayQueue)? - /* TODO isKodiEnabled: Boolean, */ ): List { return buildPlayerActionList { context -> fetchItemInfoIfSparse(context, item) } + (queueFromHere?.let { buildPlayerFromHereActionList(queueFromHere) } ?: listOf()) + 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 36bfd5fa6..c790e8d80 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 @@ -114,7 +114,7 @@ fun LongPressMenuEditorPage(onBackClick: () -> Unit) { title = stringResource(R.string.long_press_menu_actions_editor), onBackClick = onBackClick, actions = { - ResetToDefaultsButton(state::resetToDefaults) + ResetToDefaultsButton { state.resetToDefaults(context) } } ) { paddingValues -> // test scrolling on Android TV by adding `.padding(horizontal = 350.dp)` here diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressMenuEditorState.kt b/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressMenuEditorState.kt index 89f005105..c1b9abbc0 100644 --- a/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressMenuEditorState.kt +++ b/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressMenuEditorState.kt @@ -88,9 +88,9 @@ class LongPressMenuEditorState( }.toList() } - fun resetToDefaults() { + fun resetToDefaults(context: Context) { items.clear() - items.addAll(buildItemsInList(true, LongPressAction.Type.DefaultEnabledActions)) + items.addAll(buildItemsInList(true, getDefaultEnabledLongPressActions(context))) } private fun findItemForOffsetOrClosestInRow(offset: IntOffset): LazyGridItemInfo? { diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressMenuSettings.kt b/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressMenuSettings.kt index be14125dc..16eaddd16 100644 --- a/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressMenuSettings.kt +++ b/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressMenuSettings.kt @@ -5,6 +5,25 @@ import android.util.Log import androidx.core.content.edit import androidx.preference.PreferenceManager import org.schabi.newpipe.R +import org.schabi.newpipe.ui.components.menu.LongPressAction.Type.AddToPlaylist +import org.schabi.newpipe.ui.components.menu.LongPressAction.Type.Background +import org.schabi.newpipe.ui.components.menu.LongPressAction.Type.BackgroundFromHere +import org.schabi.newpipe.ui.components.menu.LongPressAction.Type.BackgroundShuffled +import org.schabi.newpipe.ui.components.menu.LongPressAction.Type.Delete +import org.schabi.newpipe.ui.components.menu.LongPressAction.Type.Download +import org.schabi.newpipe.ui.components.menu.LongPressAction.Type.Enqueue +import org.schabi.newpipe.ui.components.menu.LongPressAction.Type.EnqueueNext +import org.schabi.newpipe.ui.components.menu.LongPressAction.Type.MarkAsWatched +import org.schabi.newpipe.ui.components.menu.LongPressAction.Type.OpenInBrowser +import org.schabi.newpipe.ui.components.menu.LongPressAction.Type.PlayWithKodi +import org.schabi.newpipe.ui.components.menu.LongPressAction.Type.Popup +import org.schabi.newpipe.ui.components.menu.LongPressAction.Type.Remove +import org.schabi.newpipe.ui.components.menu.LongPressAction.Type.Rename +import org.schabi.newpipe.ui.components.menu.LongPressAction.Type.SetAsPlaylistThumbnail +import org.schabi.newpipe.ui.components.menu.LongPressAction.Type.Share +import org.schabi.newpipe.ui.components.menu.LongPressAction.Type.ShowDetails +import org.schabi.newpipe.ui.components.menu.LongPressAction.Type.UnsetPlaylistThumbnail +import org.schabi.newpipe.ui.components.menu.LongPressAction.Type.Unsubscribe private const val TAG: String = "LongPressMenuSettings" @@ -20,12 +39,35 @@ fun storeIsHeaderEnabledToSettings(context: Context, enabled: Boolean) { } } +// 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. +// PlayWithKodi is only added by default if it is enabled in settings. +private val DefaultEnabledActions: List = listOf( + ShowDetails, Enqueue, EnqueueNext, Background, Popup, BackgroundFromHere, + BackgroundShuffled, Download, AddToPlaylist, Share, OpenInBrowser, MarkAsWatched, + Rename, SetAsPlaylistThumbnail, UnsetPlaylistThumbnail, Delete, Unsubscribe, Remove +) + +private fun getShowPlayWithKodi(context: Context): Boolean { + return PreferenceManager.getDefaultSharedPreferences(context) + .getBoolean(context.getString(R.string.show_play_with_kodi_key), false) +} + +fun getDefaultEnabledLongPressActions(context: Context): List { + return if (getShowPlayWithKodi(context)) { + // only include Kodi in the default actions if it is enabled in settings + DefaultEnabledActions + listOf(PlayWithKodi) + } else { + DefaultEnabledActions + } +} + fun loadLongPressActionArrangementFromSettings(context: Context): List { val key = context.getString(R.string.long_press_menu_action_arrangement_key) val items = PreferenceManager.getDefaultSharedPreferences(context) .getString(key, null) if (items == null) { - return LongPressAction.Type.DefaultEnabledActions + return getDefaultEnabledLongPressActions(context) } try { @@ -45,7 +87,7 @@ fun loadLongPressActionArrangementFromSettings(context: Context): List Date: Sat, 7 Feb 2026 13:13:30 +0100 Subject: [PATCH 64/87] Fix LongPressMenu crashing if dismissed while loading --- .../schabi/newpipe/ui/components/menu/LongPressAction.kt | 4 +++- .../schabi/newpipe/ui/components/menu/LongPressMenu.kt | 8 ++++---- 2 files changed, 7 insertions(+), 5 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 6b58896ce..f1e985deb 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 @@ -158,7 +158,9 @@ data class LongPressAction( private fun buildPlayerShuffledActionList(queue: suspend (Context) -> PlayQueue): List { val shuffledQueue: suspend (Context) -> PlayQueue = { context -> val q = queue(context) - q.fetchAllAndShuffle() + withContext(Dispatchers.IO) { + q.fetchAllAndShuffle() + } q } return 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 400f91f28..0feb56cff 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 @@ -80,16 +80,14 @@ import androidx.compose.ui.window.DialogProperties import androidx.lifecycle.viewmodel.compose.viewModel import coil3.compose.AsyncImage import java.time.OffsetDateTime +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import org.schabi.newpipe.R import org.schabi.newpipe.error.ErrorInfo import org.schabi.newpipe.error.ErrorUtil import org.schabi.newpipe.error.UserAction.LONG_PRESS_MENU_ACTION import org.schabi.newpipe.extractor.stream.StreamType -import org.schabi.newpipe.player.helper.PlayerHolder -import org.schabi.newpipe.ui.components.common.ScaffoldWithToolbar import org.schabi.newpipe.ui.components.menu.LongPressAction.Type.EnqueueNext import org.schabi.newpipe.ui.components.menu.LongPressAction.Type.ShowChannelDetails import org.schabi.newpipe.ui.discardAllTouchesIf @@ -122,7 +120,7 @@ fun getLongPressMenuView( LongPressMenu( longPressable = longPressable, longPressActions = longPressActions, - onDismissRequest = { (this.parent as ViewGroup).removeView(this) } + onDismissRequest = { (this.parent as? ViewGroup)?.removeView(this) } ) } } @@ -173,6 +171,8 @@ fun LongPressMenu( coroutineScope.launch { try { action.action(ctx) + } catch (_: CancellationException) { + // the user canceled the action, e.g. by dismissing the dialog while loading } catch (t: Throwable) { ErrorUtil.showSnackbar( ctx, From 3ee031efb9d23514dc64160ecfcbe1a186540f11 Mon Sep 17 00:00:00 2001 From: Stypox Date: Sat, 7 Feb 2026 16:10:51 +0100 Subject: [PATCH 65/87] Add accessibility label to show channel details button --- .../newpipe/ui/components/menu/LongPressMenu.kt | 11 ++++++++++- app/src/main/res/values/strings.xml | 1 + 2 files changed, 11 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 0feb56cff..d2953b85e 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 @@ -386,6 +386,7 @@ fun LongPressMenuHeader( modifier = modifier ) { Row(verticalAlignment = Alignment.CenterVertically) { + // thumbnail and decorations Box { if (item.thumbnailUrl != null) { AsyncImage( @@ -475,6 +476,7 @@ fun LongPressMenuHeader( } } + // title, channel and other textual information Column( modifier = Modifier.padding(vertical = 8.dp) ) { @@ -503,7 +505,14 @@ fun LongPressMenuHeader( modifier = if (onUploaderClick == null) { Modifier } else { - Modifier.clickable(onClick = onUploaderClick) + Modifier.clickable( + onClick = onUploaderClick, + onClickLabel = if (item.uploader != null) { + stringResource(R.string.show_channel_details_for, item.uploader) + } else { + stringResource(R.string.show_channel_details) + } + ) } .fillMaxWidth() .fadedMarquee(edgeWidth = 12.dp) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b671431f7..74b4b18d7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -436,6 +436,7 @@ Audio track Hold to enqueue Show channel details + Show channel details for %s Enqueue Enqueued Enqueue next From 35e673a5ceff25663d8f8afb03d4b16e7e6b2af2 Mon Sep 17 00:00:00 2001 From: Stypox Date: Sat, 7 Feb 2026 18:06:38 +0100 Subject: [PATCH 66/87] Add tests for LongPressMenuSettings --- .../menu/LongPressMenuSettingsTest.kt | 110 ++++++++++++++++++ .../components/menu/LongPressMenuSettings.kt | 12 +- 2 files changed, 117 insertions(+), 5 deletions(-) create mode 100644 app/src/androidTest/java/org/schabi/newpipe/ui/components/menu/LongPressMenuSettingsTest.kt diff --git a/app/src/androidTest/java/org/schabi/newpipe/ui/components/menu/LongPressMenuSettingsTest.kt b/app/src/androidTest/java/org/schabi/newpipe/ui/components/menu/LongPressMenuSettingsTest.kt new file mode 100644 index 000000000..0c57bee72 --- /dev/null +++ b/app/src/androidTest/java/org/schabi/newpipe/ui/components/menu/LongPressMenuSettingsTest.kt @@ -0,0 +1,110 @@ +package org.schabi.newpipe.ui.components.menu + +import android.content.Context +import androidx.annotation.StringRes +import androidx.preference.PreferenceManager +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.schabi.newpipe.R +import org.schabi.newpipe.ui.components.menu.LongPressAction.Type.Background +import org.schabi.newpipe.ui.components.menu.LongPressAction.Type.Enqueue +import org.schabi.newpipe.ui.components.menu.LongPressAction.Type.EnqueueNext +import org.schabi.newpipe.ui.components.menu.LongPressAction.Type.MarkAsWatched +import org.schabi.newpipe.ui.components.menu.LongPressAction.Type.PlayWithKodi +import org.schabi.newpipe.ui.components.menu.LongPressAction.Type.ShowChannelDetails +import org.schabi.newpipe.ui.components.menu.LongPressAction.Type.ShowDetails + +@RunWith(AndroidJUnit4::class) +class LongPressMenuSettingsTest { + + val ctx: Context = ApplicationProvider.getApplicationContext() + + private fun putBooleanInPrefs(@StringRes key: Int, value: Boolean) { + PreferenceManager.getDefaultSharedPreferences(ctx) + .edit().putBoolean(ctx.getString(key), value).apply() + } + + private fun putStringInPrefs(@StringRes key: Int, value: String) { + PreferenceManager.getDefaultSharedPreferences(ctx) + .edit().putString(ctx.getString(key), value).apply() + } + + private fun clearPrefs() { + PreferenceManager.getDefaultSharedPreferences(ctx) + .edit().clear().apply() + } + + @Test + fun testStoringAndLoadingPreservesIsHeaderEnabled() { + for (enabled in arrayOf(false, true)) { + storeIsHeaderEnabledToSettings(ctx, enabled) + assertEquals(enabled, loadIsHeaderEnabledFromSettings(ctx)) + } + } + + @Test + fun testStoringAndLoadingPreservesActionArrangement() { + for (actions in listOf( + listOf(), + LongPressAction.Type.entries.toList(), + listOf(Enqueue, EnqueueNext, MarkAsWatched, ShowChannelDetails), + listOf(PlayWithKodi) + )) { + storeLongPressActionArrangementToSettings(ctx, actions) + assertEquals(actions, loadLongPressActionArrangementFromSettings(ctx)) + } + } + + @Test + fun testLoadingActionArrangementUnset() { + clearPrefs() + assertEquals(getDefaultEnabledLongPressActions(ctx), loadLongPressActionArrangementFromSettings(ctx)) + } + + @Test + fun testLoadingActionArrangementInvalid() { + putStringInPrefs(R.string.long_press_menu_action_arrangement_key, "0,1,whatever,3") + assertEquals(getDefaultEnabledLongPressActions(ctx), loadLongPressActionArrangementFromSettings(ctx)) + } + + @Test + fun testLoadingActionArrangementEmpty() { + putStringInPrefs(R.string.long_press_menu_action_arrangement_key, "") + assertEquals(listOf(), loadLongPressActionArrangementFromSettings(ctx)) + } + + @Test + fun testLoadingActionArrangementDuplicates() { + putStringInPrefs(R.string.long_press_menu_action_arrangement_key, "0,1,0,3,2,3,3,3,0") + assertEquals( + // deduplicates items but retains order + listOf(ShowDetails, Enqueue, Background, EnqueueNext), + loadLongPressActionArrangementFromSettings(ctx) + ) + } + + @Test + fun testDefaultActionsIncludeKodiIffShowKodiEnabled() { + for (enabled in arrayOf(false, true)) { + putBooleanInPrefs(R.string.show_play_with_kodi_key, enabled) + val actions = getDefaultEnabledLongPressActions(ctx) + assertEquals(enabled, actions.contains(PlayWithKodi)) + } + } + + @Test + fun testAddOrRemoveKodiLongPressAction() { + for (enabled in arrayOf(false, true)) { + putBooleanInPrefs(R.string.show_play_with_kodi_key, enabled) + for (actions in listOf(listOf(Enqueue), listOf(Enqueue, PlayWithKodi))) { + storeLongPressActionArrangementToSettings(ctx, actions) + addOrRemoveKodiLongPressAction(ctx) + val newActions = getDefaultEnabledLongPressActions(ctx) + assertEquals(enabled, newActions.contains(PlayWithKodi)) + } + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressMenuSettings.kt b/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressMenuSettings.kt index 16eaddd16..f6d4621b9 100644 --- a/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressMenuSettings.kt +++ b/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressMenuSettings.kt @@ -64,17 +64,19 @@ fun getDefaultEnabledLongPressActions(context: Context): List { val key = context.getString(R.string.long_press_menu_action_arrangement_key) - val items = PreferenceManager.getDefaultSharedPreferences(context) + val ids = PreferenceManager.getDefaultSharedPreferences(context) .getString(key, null) - if (items == null) { + if (ids == null) { return getDefaultEnabledLongPressActions(context) + } else if (ids.isEmpty()) { + return emptyList() // apparently the user has disabled all buttons } try { - val actions = items.split(',') - .map { item -> + val actions = ids.split(',') + .map { id -> LongPressAction.Type.entries.first { entry -> - entry.id.toString() == item + entry.id.toString() == id } } From 48010d014be7b553d5167a2fb43bfade7ee3f01f Mon Sep 17 00:00:00 2001 From: Stypox Date: Sat, 7 Feb 2026 18:22:58 +0100 Subject: [PATCH 67/87] Extract some common test methods to InstrumentedTestUtil --- .../schabi/newpipe/InstrumentedTestUtil.kt | 39 +++++++++++++++++++ .../ui/components/common/ErrorPanelTest.kt | 32 +++++++-------- .../menu/LongPressMenuSettingsTest.kt | 25 ++---------- 3 files changed, 58 insertions(+), 38 deletions(-) create mode 100644 app/src/androidTest/java/org/schabi/newpipe/InstrumentedTestUtil.kt diff --git a/app/src/androidTest/java/org/schabi/newpipe/InstrumentedTestUtil.kt b/app/src/androidTest/java/org/schabi/newpipe/InstrumentedTestUtil.kt new file mode 100644 index 000000000..bd49efb2f --- /dev/null +++ b/app/src/androidTest/java/org/schabi/newpipe/InstrumentedTestUtil.kt @@ -0,0 +1,39 @@ +package org.schabi.newpipe + +import android.content.Context +import androidx.annotation.StringRes +import androidx.compose.ui.test.SemanticsNodeInteraction +import androidx.compose.ui.test.SemanticsNodeInteractionsProvider +import androidx.compose.ui.test.onNodeWithText +import androidx.preference.PreferenceManager +import androidx.test.core.app.ApplicationProvider + +val ctx: Context + get() = ApplicationProvider.getApplicationContext() + +fun putBooleanInPrefs(@StringRes key: Int, value: Boolean) { + PreferenceManager.getDefaultSharedPreferences(ctx) + .edit().putBoolean(ctx.getString(key), value).apply() +} + +fun putStringInPrefs(@StringRes key: Int, value: String) { + PreferenceManager.getDefaultSharedPreferences(ctx) + .edit().putString(ctx.getString(key), value).apply() +} + +fun clearPrefs() { + PreferenceManager.getDefaultSharedPreferences(ctx) + .edit().clear().apply() +} + +/** + * Same as the original `onNodeWithText` except that this takes a [StringRes] instead of a [String]. + */ +fun SemanticsNodeInteractionsProvider.onNodeWithText( + @StringRes text: Int, + substring: Boolean = false, + ignoreCase: Boolean = false, + useUnmergedTree: Boolean = false +): SemanticsNodeInteraction { + return this.onNodeWithText(ctx.getString(text), substring, ignoreCase, useUnmergedTree) +} diff --git a/app/src/androidTest/java/org/schabi/newpipe/ui/components/common/ErrorPanelTest.kt b/app/src/androidTest/java/org/schabi/newpipe/ui/components/common/ErrorPanelTest.kt index f44b76d8c..0b8f0ae80 100644 --- a/app/src/androidTest/java/org/schabi/newpipe/ui/components/common/ErrorPanelTest.kt +++ b/app/src/androidTest/java/org/schabi/newpipe/ui/components/common/ErrorPanelTest.kt @@ -1,10 +1,8 @@ package org.schabi.newpipe.ui.components.common import androidx.activity.ComponentActivity -import androidx.annotation.StringRes import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.createAndroidComposeRule -import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.test.ext.junit.runners.AndroidJUnit4 import java.net.UnknownHostException @@ -16,6 +14,7 @@ import org.schabi.newpipe.error.ErrorInfo import org.schabi.newpipe.error.UserAction import org.schabi.newpipe.extractor.exceptions.ReCaptchaException import org.schabi.newpipe.extractor.exceptions.UnsupportedContentInCountryException +import org.schabi.newpipe.onNodeWithText import org.schabi.newpipe.ui.theme.AppTheme @RunWith(AndroidJUnit4::class) @@ -30,7 +29,6 @@ class ErrorPanelTest { } } } - private fun text(@StringRes id: Int) = composeRule.activity.getString(id) /** * Test Network Error @@ -44,11 +42,11 @@ class ErrorPanelTest { ) setErrorPanel(networkErrorInfo, onRetry = {}) - composeRule.onNodeWithText(text(R.string.network_error)).assertIsDisplayed() - composeRule.onNodeWithText(text(R.string.retry), ignoreCase = true).assertIsDisplayed() - composeRule.onNodeWithText(text(R.string.error_snackbar_action), ignoreCase = true) + composeRule.onNodeWithText(R.string.network_error).assertIsDisplayed() + composeRule.onNodeWithText(R.string.retry, ignoreCase = true).assertIsDisplayed() + composeRule.onNodeWithText(R.string.error_snackbar_action, ignoreCase = true) .assertDoesNotExist() - composeRule.onNodeWithText(text(R.string.recaptcha_solve), ignoreCase = true) + composeRule.onNodeWithText(R.string.recaptcha_solve, ignoreCase = true) .assertDoesNotExist() } @@ -64,9 +62,9 @@ class ErrorPanelTest { ) setErrorPanel(unexpectedErrorInfo, onRetry = {}) - composeRule.onNodeWithText(text(R.string.error_snackbar_message)).assertIsDisplayed() - composeRule.onNodeWithText(text(R.string.retry), ignoreCase = true).assertIsDisplayed() - composeRule.onNodeWithText(text(R.string.error_snackbar_action), ignoreCase = true) + composeRule.onNodeWithText(R.string.error_snackbar_message).assertIsDisplayed() + composeRule.onNodeWithText(R.string.retry, ignoreCase = true).assertIsDisplayed() + composeRule.onNodeWithText(R.string.error_snackbar_action, ignoreCase = true) .assertIsDisplayed() } @@ -91,14 +89,14 @@ class ErrorPanelTest { onRetry = { retryClicked = true } ) - composeRule.onNodeWithText(text(R.string.recaptcha_solve), ignoreCase = true) + composeRule.onNodeWithText(R.string.recaptcha_solve, ignoreCase = true) .assertIsDisplayed() - composeRule.onNodeWithText(text(R.string.retry), ignoreCase = true) + composeRule.onNodeWithText(R.string.retry, ignoreCase = true) .assertIsDisplayed() .performClick() - composeRule.onNodeWithText(text(R.string.open_in_browser), ignoreCase = true) + composeRule.onNodeWithText(R.string.open_in_browser, ignoreCase = true) .assertIsDisplayed() - composeRule.onNodeWithText(text(R.string.error_snackbar_action), ignoreCase = true) + composeRule.onNodeWithText(R.string.error_snackbar_action, ignoreCase = true) .assertIsDisplayed() assert(retryClicked) { "onRetry callback should have been invoked" } } @@ -116,11 +114,11 @@ class ErrorPanelTest { setErrorPanel(contentNotAvailable) - composeRule.onNodeWithText(text(R.string.unsupported_content_in_country)) + composeRule.onNodeWithText(R.string.unsupported_content_in_country) .assertIsDisplayed() - composeRule.onNodeWithText(text(R.string.retry), ignoreCase = true) + composeRule.onNodeWithText(R.string.retry, ignoreCase = true) .assertDoesNotExist() - composeRule.onNodeWithText(text(R.string.error_snackbar_action), ignoreCase = true) + composeRule.onNodeWithText(R.string.error_snackbar_action, ignoreCase = true) .assertDoesNotExist() } } diff --git a/app/src/androidTest/java/org/schabi/newpipe/ui/components/menu/LongPressMenuSettingsTest.kt b/app/src/androidTest/java/org/schabi/newpipe/ui/components/menu/LongPressMenuSettingsTest.kt index 0c57bee72..8ebb78993 100644 --- a/app/src/androidTest/java/org/schabi/newpipe/ui/components/menu/LongPressMenuSettingsTest.kt +++ b/app/src/androidTest/java/org/schabi/newpipe/ui/components/menu/LongPressMenuSettingsTest.kt @@ -1,14 +1,14 @@ package org.schabi.newpipe.ui.components.menu -import android.content.Context -import androidx.annotation.StringRes -import androidx.preference.PreferenceManager -import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import org.junit.Assert.assertEquals import org.junit.Test import org.junit.runner.RunWith import org.schabi.newpipe.R +import org.schabi.newpipe.clearPrefs +import org.schabi.newpipe.ctx +import org.schabi.newpipe.putBooleanInPrefs +import org.schabi.newpipe.putStringInPrefs import org.schabi.newpipe.ui.components.menu.LongPressAction.Type.Background import org.schabi.newpipe.ui.components.menu.LongPressAction.Type.Enqueue import org.schabi.newpipe.ui.components.menu.LongPressAction.Type.EnqueueNext @@ -20,23 +20,6 @@ import org.schabi.newpipe.ui.components.menu.LongPressAction.Type.ShowDetails @RunWith(AndroidJUnit4::class) class LongPressMenuSettingsTest { - val ctx: Context = ApplicationProvider.getApplicationContext() - - private fun putBooleanInPrefs(@StringRes key: Int, value: Boolean) { - PreferenceManager.getDefaultSharedPreferences(ctx) - .edit().putBoolean(ctx.getString(key), value).apply() - } - - private fun putStringInPrefs(@StringRes key: Int, value: String) { - PreferenceManager.getDefaultSharedPreferences(ctx) - .edit().putString(ctx.getString(key), value).apply() - } - - private fun clearPrefs() { - PreferenceManager.getDefaultSharedPreferences(ctx) - .edit().clear().apply() - } - @Test fun testStoringAndLoadingPreservesIsHeaderEnabled() { for (enabled in arrayOf(false, true)) { From a92c8b2243cce88bebe4ea9ccc3cfdb587cfe989 Mon Sep 17 00:00:00 2001 From: Stypox Date: Sun, 8 Feb 2026 15:35:18 +0100 Subject: [PATCH 68/87] Setup espresso for testing --- app/build.gradle.kts | 11 ++++++++++- gradle.properties | 3 +++ gradle/libs.versions.toml | 9 +++++++-- 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index bc06dfc41..3a7aaecdb 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -55,6 +55,12 @@ configure { System.getProperty("versionNameSuffix")?.let { versionNameSuffix = it } testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + testInstrumentationRunnerArguments["disableAnalytics"] = "true" + testOptions { + emulatorControl { + enable = true + } + } } buildTypes { @@ -363,7 +369,10 @@ dependencies { testImplementation(libs.mockito.core) androidTestImplementation(libs.androidx.junit) - androidTestImplementation(libs.androidx.runner) + androidTestImplementation(libs.androidx.test.espresso) + androidTestImplementation(libs.androidx.test.espresso.device) + androidTestImplementation(libs.androidx.test.runner) + androidTestImplementation(libs.androidx.test.rules) androidTestImplementation(libs.androidx.room.testing) androidTestImplementation(libs.assertj.core) androidTestImplementation(platform(libs.androidx.compose.bom)) diff --git a/gradle.properties b/gradle.properties index a529a42c8..63d97f28c 100644 --- a/gradle.properties +++ b/gradle.properties @@ -5,3 +5,6 @@ systemProp.file.encoding=utf-8 # https://docs.gradle.org/current/userguide/configuration_cache.html org.gradle.configuration-cache=true + +# https://developer.android.com/studio/test/espresso-api#set_up_your_project_for_the_espresso_device_api +android.experimental.androidTest.enableEmulatorControl=true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ff69ad774..6bce80016 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -7,6 +7,7 @@ about-libraries = "11.2.3" acra = "5.13.1" agp = "8.13.2" +androidx-test = "1.7.0" appcompat = "1.7.1" assertj = "3.27.6" autoservice-google = "1.1.1" @@ -20,6 +21,8 @@ constraintlayout = "2.2.1" core = "1.17.0" desugar = "2.1.5" documentfile = "1.1.0" +espresso = "3.7.0" +espresso-device = "1.1.0" exoplayer = "2.19.1" fragment-compose = "1.8.9" groupie = "2.10.1" @@ -47,7 +50,6 @@ preference = "1.2.1" prettytime = "5.0.8.Final" recyclerview = "1.4.0" room = "2.7.2" # Newer versions require minSdk >= 23 -runner = "1.7.0" rxandroid = "3.0.2" rxbinding = "4.0.0" rxjava = "3.1.12" @@ -102,8 +104,11 @@ androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" } androidx-room-rxjava3 = { module = "androidx.room:room-rxjava3", version.ref = "room" } androidx-room-testing = { module = "androidx.room:room-testing", version.ref = "room" } -androidx-runner = { module = "androidx.test:runner", version.ref = "runner" } androidx-swiperefreshlayout = { module = "androidx.swiperefreshlayout:swiperefreshlayout", version.ref = "swiperefreshlayout" } +androidx-test-espresso = { module = "androidx.test.espresso:espresso-core", version.ref = "espresso" } +androidx-test-espresso-device = { module = "androidx.test.espresso:espresso-device", version.ref = "espresso-device" } +androidx-test-rules = { module = "androidx.test:rules", version.ref = "androidx-test" } +androidx-test-runner = { module = "androidx.test:runner", version.ref = "androidx-test" } androidx-webkit = { module = "androidx.webkit:webkit", version.ref = "webkit" } androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "work" } androidx-work-rxjava3 = { module = "androidx.work:work-rxjava3", version.ref = "work" } From 2a28e7a80578f6aaefd9e03f6643113cc017dd18 Mon Sep 17 00:00:00 2001 From: Stypox Date: Sun, 8 Feb 2026 15:36:33 +0100 Subject: [PATCH 69/87] Make LongPressMenu scrollable if it does not fit on screen --- .../schabi/newpipe/ui/components/menu/LongPressMenu.kt | 8 +++++++- 1 file changed, 7 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 d2953b85e..b875fd7a6 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 @@ -28,8 +28,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.rememberScrollState import androidx.compose.foundation.text.InlineTextContent import androidx.compose.foundation.text.appendInlineContent +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.OpenInNew import androidx.compose.material.icons.automirrored.filled.PlaylistPlay @@ -242,8 +244,12 @@ private fun LongPressMenuContent( // width for the landscape/reduced header, measured in button widths val headerWidthInButtonsReducedSpan = 4 val buttonsPerRow = (this.maxWidth / MinButtonWidth).toInt() + val scrollState = rememberScrollState() - Column { + Column( + modifier = Modifier + .verticalScroll(scrollState) + ) { var actionIndex = if (header != null) -1 else 0 // -1 indicates the header while (actionIndex < actions.size) { Row( From dc7ed1ce8deb99bce4d4a8f809966a40123893dc Mon Sep 17 00:00:00 2001 From: Stypox Date: Sun, 8 Feb 2026 15:37:19 +0100 Subject: [PATCH 70/87] Add 38 UI tests for LongPressMenu --- .../schabi/newpipe/InstrumentedTestUtil.kt | 36 +- .../ui/components/menu/LongPressMenuTest.kt | 651 ++++++++++++++++++ .../ui/components/menu/LongPressMenu.kt | 18 +- app/src/main/res/values/strings.xml | 1 + 4 files changed, 703 insertions(+), 3 deletions(-) create mode 100644 app/src/androidTest/java/org/schabi/newpipe/ui/components/menu/LongPressMenuTest.kt diff --git a/app/src/androidTest/java/org/schabi/newpipe/InstrumentedTestUtil.kt b/app/src/androidTest/java/org/schabi/newpipe/InstrumentedTestUtil.kt index bd49efb2f..a828f844e 100644 --- a/app/src/androidTest/java/org/schabi/newpipe/InstrumentedTestUtil.kt +++ b/app/src/androidTest/java/org/schabi/newpipe/InstrumentedTestUtil.kt @@ -4,12 +4,14 @@ import android.content.Context import androidx.annotation.StringRes import androidx.compose.ui.test.SemanticsNodeInteraction import androidx.compose.ui.test.SemanticsNodeInteractionsProvider +import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithText import androidx.preference.PreferenceManager -import androidx.test.core.app.ApplicationProvider +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.fail val ctx: Context - get() = ApplicationProvider.getApplicationContext() + get() = InstrumentationRegistry.getInstrumentation().targetContext fun putBooleanInPrefs(@StringRes key: Int, value: Boolean) { PreferenceManager.getDefaultSharedPreferences(ctx) @@ -37,3 +39,33 @@ fun SemanticsNodeInteractionsProvider.onNodeWithText( ): SemanticsNodeInteraction { return this.onNodeWithText(ctx.getString(text), substring, ignoreCase, useUnmergedTree) } + +/** + * Same as the original `onNodeWithContentDescription` except that this takes a [StringRes] instead of a [String]. + */ +fun SemanticsNodeInteractionsProvider.onNodeWithContentDescription( + @StringRes text: Int, + substring: Boolean = false, + ignoreCase: Boolean = false, + useUnmergedTree: Boolean = false +): SemanticsNodeInteraction { + return this.onNodeWithContentDescription(ctx.getString(text), substring, ignoreCase, useUnmergedTree) +} + +fun > assertInRange(l: T, r: T, value: T) { + if (l > r) { + fail("Invalid range passed to `assertInRange`: [$l, $r]") + } + if (value !in l..r) { + fail("Expected $value to be in range [$l, $r]") + } +} + +fun > assertNotInRange(l: T, r: T, value: T) { + if (l > r) { + fail("Invalid range passed to `assertInRange`: [$l, $r]") + } + if (value in l..r) { + fail("Expected $value to NOT be in range [$l, $r]") + } +} diff --git a/app/src/androidTest/java/org/schabi/newpipe/ui/components/menu/LongPressMenuTest.kt b/app/src/androidTest/java/org/schabi/newpipe/ui/components/menu/LongPressMenuTest.kt new file mode 100644 index 000000000..252a125ad --- /dev/null +++ b/app/src/androidTest/java/org/schabi/newpipe/ui/components/menu/LongPressMenuTest.kt @@ -0,0 +1,651 @@ +package org.schabi.newpipe.ui.components.menu + +import androidx.activity.ComponentActivity +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.semantics.SemanticsProperties.ProgressBarRangeInfo +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.assert +import androidx.compose.ui.test.assertHasClickAction +import androidx.compose.ui.test.assertHasNoClickAction +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.hasScrollAction +import androidx.compose.ui.test.isDisplayed +import androidx.compose.ui.test.isNotDisplayed +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onFirst +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.swipeUp +import androidx.compose.ui.unit.dp +import androidx.test.espresso.Espresso +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.assertion.ViewAssertions.doesNotExist +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.device.DeviceInteraction.Companion.setDisplaySize +import androidx.test.espresso.device.EspressoDevice.Companion.onDevice +import androidx.test.espresso.device.rules.DisplaySizeRule +import androidx.test.espresso.device.sizeclass.HeightSizeClass +import androidx.test.espresso.device.sizeclass.WidthSizeClass +import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.espresso.matcher.ViewMatchers.withText +import androidx.test.ext.junit.runners.AndroidJUnit4 +import java.time.OffsetDateTime +import java.time.temporal.ChronoUnit +import kotlinx.coroutines.delay +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.schabi.newpipe.R +import org.schabi.newpipe.assertInRange +import org.schabi.newpipe.assertNotInRange +import org.schabi.newpipe.ctx +import org.schabi.newpipe.extractor.stream.StreamType +import org.schabi.newpipe.onNodeWithContentDescription +import org.schabi.newpipe.onNodeWithText +import org.schabi.newpipe.ui.components.menu.LongPressAction.Type.BackgroundShuffled +import org.schabi.newpipe.ui.components.menu.LongPressAction.Type.Enqueue +import org.schabi.newpipe.ui.components.menu.LongPressAction.Type.PlayWithKodi +import org.schabi.newpipe.ui.components.menu.LongPressAction.Type.ShowChannelDetails +import org.schabi.newpipe.ui.components.menu.LongPressAction.Type.ShowDetails +import org.schabi.newpipe.ui.theme.AppTheme +import org.schabi.newpipe.util.Either +import org.schabi.newpipe.util.Localization + +@RunWith(AndroidJUnit4::class) +class LongPressMenuTest { + @get:Rule(order = 1) + val composeRule = createAndroidComposeRule() + + // Test rule for restoring device to its starting display size when a test case finishes. + // See https://developer.android.com/training/testing/different-screens/tools#resize-displays. + @get:Rule(order = 2) + val displaySizeRule: DisplaySizeRule = DisplaySizeRule() + + private fun getLongPressable( + title: String = "title", + url: String? = "https://example.com", + thumbnailUrl: String? = "android.resource://${ctx.packageName}/${R.drawable.placeholder_thumbnail_video}", + uploader: String? = "uploader", + uploaderUrl: String? = "https://example.com", + viewCount: Long? = 42, + streamType: StreamType? = StreamType.VIDEO_STREAM, + uploadDate: Either? = Either.left("2026"), + decoration: LongPressable.Decoration? = LongPressable.Decoration.Duration(9478) + ) = LongPressable(title, url, thumbnailUrl, uploader, uploaderUrl, viewCount, streamType, uploadDate, decoration) + + private fun setLongPressMenu( + longPressable: LongPressable = getLongPressable(), + longPressActions: List = LongPressAction.Type.entries.map { it.buildAction { } }, + onDismissRequest: () -> Unit = {}, + isHeaderEnabled: Boolean = true, + actionArrangement: List = LongPressAction.Type.entries + ) { + storeIsHeaderEnabledToSettings(ctx, isHeaderEnabled) + storeLongPressActionArrangementToSettings(ctx, actionArrangement) + composeRule.setContent { + var isMenuVisible by rememberSaveable { mutableStateOf(true) } + if (isMenuVisible) { + AppTheme { + LongPressMenu(longPressable, longPressActions, { + isMenuVisible = false + onDismissRequest() + }) + } + } + } + } + + // the three tests below all call this function to ensure that the editor button is shown + // independently of the long press menu contents + private fun assertEditorIsEnteredAndExitedProperly() { + composeRule.onNodeWithContentDescription(R.string.long_press_menu_enabled_actions_description) + .assertDoesNotExist() + composeRule.onNodeWithContentDescription(R.string.edit) + .performClick() + composeRule.waitUntil { + composeRule.onNodeWithText(R.string.long_press_menu_enabled_actions) + .isDisplayed() + } + + composeRule.onNodeWithContentDescription(R.string.edit) + .assertDoesNotExist() + Espresso.pressBack() + composeRule.waitUntil { + composeRule.onNodeWithContentDescription(R.string.edit) + .isDisplayed() + } + + composeRule.onNodeWithText(R.string.long_press_menu_enabled_actions) + .assertDoesNotExist() + } + + @Test + fun testEditorButton1() { + setLongPressMenu(isHeaderEnabled = false, actionArrangement = listOf()) + assertEditorIsEnteredAndExitedProperly() + } + + @Test + fun testEditorButton2() { + setLongPressMenu(isHeaderEnabled = true, actionArrangement = listOf(PlayWithKodi)) + assertEditorIsEnteredAndExitedProperly() + } + + @Test + fun testEditorButton3() { + setLongPressMenu(isHeaderEnabled = true, longPressActions = listOf(), actionArrangement = LongPressAction.Type.entries) + assertEditorIsEnteredAndExitedProperly() + } + + @Test + fun testShowChannelDetails1() { + var pressedCount = 0 + var dismissedCount = 0 + setLongPressMenu( + onDismissRequest = { dismissedCount += 1 }, + longPressable = getLongPressable(uploader = "A", uploaderUrl = "https://example.com"), + longPressActions = listOf(ShowChannelDetails.buildAction { pressedCount += 1 }), + actionArrangement = listOf() + ) + + composeRule.onNodeWithText(R.string.show_channel_details, substring = true) + .assertDoesNotExist() + composeRule.onNodeWithText("A", substring = true) + .assertIsDisplayed() + composeRule.onNodeWithTag("ShowChannelDetails") + .performClick() + composeRule.waitUntil { dismissedCount == 1 } + assertEquals(1, pressedCount) + } + + @Test + fun testShowChannelDetails2() { + var pressedCount = 0 + var dismissedCount = 0 + setLongPressMenu( + onDismissRequest = { dismissedCount += 1 }, + longPressable = getLongPressable(uploader = null, uploaderUrl = "https://example.com"), + longPressActions = listOf(ShowChannelDetails.buildAction { pressedCount += 1 }), + actionArrangement = listOf() + ) + + composeRule.onNodeWithText(R.string.show_channel_details, substring = true) + .assertIsDisplayed() + composeRule.onNodeWithTag("ShowChannelDetails") + .performClick() + composeRule.waitUntil { dismissedCount == 1 } + assertEquals(1, pressedCount) + } + + @Test + fun testShowChannelDetails3() { + var pressedCount = 0 + var dismissedCount = 0 + setLongPressMenu( + onDismissRequest = { dismissedCount += 1 }, + longPressable = getLongPressable(uploader = null, uploaderUrl = null), + longPressActions = listOf(ShowChannelDetails.buildAction { pressedCount += 1 }), + actionArrangement = listOf() + ) + + composeRule.onNodeWithText(R.string.show_channel_details, substring = true) + .assertIsDisplayed() + composeRule.onNodeWithTag("ShowChannelDetails") + .performClick() + composeRule.waitUntil { dismissedCount == 1 } + assertEquals(1, pressedCount) + } + + @Test + fun testShowChannelDetails4() { + setLongPressMenu( + longPressable = getLongPressable(uploader = "A", uploaderUrl = "https://example.com"), + longPressActions = listOf(), + actionArrangement = listOf() + ) + composeRule.onNodeWithTag("ShowChannelDetails") + .assertHasNoClickAction() + } + + @Test + fun testShowChannelDetails5() { + setLongPressMenu( + longPressable = getLongPressable(uploader = "A", uploaderUrl = "https://example.com"), + longPressActions = listOf(ShowChannelDetails.buildAction {}), + actionArrangement = listOf(ShowChannelDetails) + ) + composeRule.onNodeWithTag("ShowChannelDetails") + .assertHasNoClickAction() + } + + @Test + fun testHeaderContents() { + val longPressable = getLongPressable() + setLongPressMenu(longPressable = longPressable) + composeRule.onNodeWithText(longPressable.title) + .assertIsDisplayed() + composeRule.onNodeWithText(longPressable.uploader!!, substring = true) + .assertIsDisplayed() + composeRule.onNodeWithText(longPressable.uploadDate!!.value.toString(), substring = true) + .assertIsDisplayed() + } + + @Test + fun testHeaderViewCount1() { + setLongPressMenu(getLongPressable(viewCount = 0, streamType = StreamType.VIDEO_STREAM)) + composeRule.onNodeWithText(ctx.getString(R.string.no_views), substring = true) + .assertIsDisplayed() + } + + @Test + fun testHeaderViewCount2() { + setLongPressMenu(getLongPressable(viewCount = 0, streamType = StreamType.LIVE_STREAM)) + composeRule.onNodeWithText(ctx.getString(R.string.no_one_watching), substring = true) + .assertIsDisplayed() + } + + @Test + fun testHeaderUploadDate1() { + setLongPressMenu(getLongPressable(uploadDate = Either.left("abcd"))) + composeRule.onNodeWithText("abcd", substring = true) + .assertIsDisplayed() + } + + @Test + fun testHeaderUploadDate2() { + val date = OffsetDateTime.now() + .minus(2, ChronoUnit.HOURS) + .minus(50, ChronoUnit.MILLIS) + setLongPressMenu(getLongPressable(uploadDate = Either.right(date))) + composeRule.onNodeWithText("2 hours ago", substring = true) + .assertIsDisplayed() + composeRule.onNodeWithText(date.toString(), substring = true) + .assertDoesNotExist() + } + + @Test + fun testHeaderDuration() { + setLongPressMenu( + longPressable = getLongPressable(decoration = LongPressable.Decoration.Duration(123)), + isHeaderEnabled = true + ) + composeRule.onNodeWithTag("LongPressMenuHeader") + .assertIsDisplayed() + composeRule.onNodeWithTag("LongPressMenuHeaderThumbnail") + .assertIsDisplayed() + composeRule.onNodeWithText(Localization.getDurationString(123)) + .assertIsDisplayed() + } + + @Test + fun testHeaderLive() { + setLongPressMenu( + longPressable = getLongPressable(decoration = LongPressable.Decoration.Duration(123)), + isHeaderEnabled = true + ) + composeRule.onNodeWithTag("LongPressMenuHeader") + .assertIsDisplayed() + composeRule.onNodeWithTag("LongPressMenuHeaderThumbnail") + .assertIsDisplayed() + composeRule.onNodeWithText(Localization.getDurationString(123)) + .assertIsDisplayed() + } + + @Test + fun testHeaderPlaylist() { + setLongPressMenu( + longPressable = getLongPressable(decoration = LongPressable.Decoration.Duration(123)), + isHeaderEnabled = true + ) + composeRule.onNodeWithTag("LongPressMenuHeader") + .assertIsDisplayed() + composeRule.onNodeWithTag("LongPressMenuHeaderThumbnail") + .assertIsDisplayed() + composeRule.onNodeWithText(Localization.getDurationString(123)) + .assertIsDisplayed() + } + + @Test + fun testHeaderNoDecoration() { + setLongPressMenu( + longPressable = getLongPressable(decoration = null), + isHeaderEnabled = true + ) + composeRule.onNodeWithTag("LongPressMenuHeader") + .assertIsDisplayed() + composeRule.onNodeWithTag("LongPressMenuHeaderThumbnail") + .assertIsDisplayed() + } + + @Test + fun testHeaderHidden() { + setLongPressMenu( + longPressable = getLongPressable(decoration = LongPressable.Decoration.Duration(123)), + isHeaderEnabled = false + ) + composeRule.onNodeWithTag("LongPressMenuHeader") + .assertDoesNotExist() + composeRule.onNodeWithText(Localization.getDurationString(123)) + .assertDoesNotExist() + } + + @Test + fun testDurationNotShownIfNoThumbnailInHeader() { + setLongPressMenu( + longPressable = getLongPressable( + thumbnailUrl = null, + decoration = LongPressable.Decoration.Duration(123) + ) + ) + composeRule.onNodeWithTag("LongPressMenuHeaderThumbnail") + .assertDoesNotExist() + composeRule.onNodeWithText(Localization.getDurationString(123)) + .assertDoesNotExist() + } + + @Test + fun testLiveNotShownIfNoThumbnailInHeader() { + setLongPressMenu( + longPressable = getLongPressable( + thumbnailUrl = null, + decoration = LongPressable.Decoration.Live + ) + ) + composeRule.onNodeWithTag("LongPressMenuHeaderThumbnail") + .assertDoesNotExist() + composeRule.onNodeWithText(R.string.duration_live, ignoreCase = true) + .assertDoesNotExist() + } + + @Test + fun testPlaylistStillShownIfNoThumbnailInHeader() { + setLongPressMenu( + longPressable = getLongPressable( + thumbnailUrl = null, + decoration = LongPressable.Decoration.Playlist(573) + ) + ) + composeRule.onNodeWithTag("LongPressMenuHeaderThumbnail") + .assertDoesNotExist() + composeRule.onNodeWithText("573") + .assertIsDisplayed() + } + + @Test + fun testHeaderSpansAllWidthIfSmallScreen() { + onDevice().setDisplaySize( + widthSizeClass = WidthSizeClass.COMPACT, + heightSizeClass = HeightSizeClass.MEDIUM + ) + setLongPressMenu() + val row = composeRule + .onAllNodesWithTag("LongPressMenuGridRow") + .onFirst() + .fetchSemanticsNode() + .boundsInRoot + val header = composeRule.onNodeWithTag("LongPressMenuHeader") + .fetchSemanticsNode() + .boundsInRoot + assertInRange(row.left, row.left + 24.dp.value, header.left) + assertInRange(row.right - 24.dp.value, row.right, header.right) + } + + @Test + fun testHeaderIsNotFullWidthIfLargeScreen() { + onDevice().setDisplaySize( + widthSizeClass = WidthSizeClass.EXPANDED, + heightSizeClass = HeightSizeClass.MEDIUM + ) + setLongPressMenu() + val row = composeRule + .onAllNodesWithTag("LongPressMenuGridRow") + .onFirst() + .fetchSemanticsNode() + .boundsInRoot + val header = composeRule.onNodeWithTag("LongPressMenuHeader") + .fetchSemanticsNode() + .boundsInRoot + assertInRange(row.left, row.left + 24.dp.value, header.left) + assertNotInRange(row.right - 24.dp.value, row.right, header.right) + } + + // the tests below all call this function to test, under different conditions, that the shown + // actions are the intersection between the available and the enabled actions + fun assertOnlyAndAllArrangedActionsDisplayed( + availableActions: List, + actionArrangement: List, + expectedShownActions: List + ) { + setLongPressMenu( + longPressActions = availableActions.map { it.buildAction {} }, + isHeaderEnabled = ((availableActions.size + actionArrangement.size) % 2) == 0, + actionArrangement = actionArrangement + ) + for (type in LongPressAction.Type.entries) { + composeRule.onNodeWithText(type.label) + .apply { + if (type in expectedShownActions) { + assertExists() + assertHasClickAction() + } else { + assertDoesNotExist() + } + } + } + } + + @Test + fun testOnlyAndAllArrangedActionsDisplayed1() { + assertOnlyAndAllArrangedActionsDisplayed( + availableActions = LongPressAction.Type.entries, + actionArrangement = listOf(), + expectedShownActions = listOf() + ) + } + + @Test + fun testOnlyAndAllArrangedActionsDisplayed2() { + assertOnlyAndAllArrangedActionsDisplayed( + availableActions = LongPressAction.Type.entries, + actionArrangement = listOf(PlayWithKodi, ShowChannelDetails), + expectedShownActions = listOf(PlayWithKodi, ShowChannelDetails) + ) + } + + @Test + fun testOnlyAndAllArrangedActionsDisplayed3() { + assertOnlyAndAllArrangedActionsDisplayed( + availableActions = LongPressAction.Type.entries, + actionArrangement = getDefaultEnabledLongPressActions(ctx), + expectedShownActions = getDefaultEnabledLongPressActions(ctx) + ) + } + + @Test + fun testOnlyAndAllAvailableActionsDisplayed1() { + assertOnlyAndAllArrangedActionsDisplayed( + availableActions = listOf(), + actionArrangement = LongPressAction.Type.entries, + expectedShownActions = listOf() + ) + } + + @Test + fun testOnlyAndAllAvailableActionsDisplayed2() { + assertOnlyAndAllArrangedActionsDisplayed( + availableActions = listOf(PlayWithKodi, ShowChannelDetails), + actionArrangement = LongPressAction.Type.entries, + expectedShownActions = listOf(PlayWithKodi, ShowChannelDetails) + ) + } + + @Test + fun testOnlyAndAllAvailableActionsDisplayed3() { + assertOnlyAndAllArrangedActionsDisplayed( + availableActions = getDefaultEnabledLongPressActions(ctx), + actionArrangement = LongPressAction.Type.entries, + expectedShownActions = getDefaultEnabledLongPressActions(ctx) + ) + } + + @Test + fun testOnlyAndAllArrangedAndAvailableActionsDisplayed1() { + assertOnlyAndAllArrangedActionsDisplayed( + availableActions = listOf(), + actionArrangement = listOf(), + expectedShownActions = listOf() + ) + } + + @Test + fun testOnlyAndAllArrangedAndAvailableActionsDisplayed2() { + assertOnlyAndAllArrangedActionsDisplayed( + availableActions = listOf(ShowDetails, ShowChannelDetails), + actionArrangement = listOf(ShowDetails, Enqueue), + expectedShownActions = listOf(ShowDetails) + ) + } + + @Test + fun testOnlyAndAllArrangedAndAvailableActionsDisplayed3() { + assertOnlyAndAllArrangedActionsDisplayed( + availableActions = LongPressAction.Type.entries, + actionArrangement = LongPressAction.Type.entries, + expectedShownActions = LongPressAction.Type.entries + ) + } + + @Test + fun testFewActionsOnLargeScreenAreNotScrollable() { + assertOnlyAndAllArrangedActionsDisplayed( + availableActions = listOf(ShowDetails, ShowChannelDetails), + actionArrangement = listOf(ShowDetails, ShowChannelDetails), + expectedShownActions = listOf(ShowDetails, ShowChannelDetails) + ) + + // try to scroll and confirm that items don't move because the menu is not overflowing the + // screen height + composeRule.onNodeWithTag("LongPressMenuGrid") + .assert(hasScrollAction()) + val originalPosition = composeRule.onNodeWithText(ShowDetails.label) + .fetchSemanticsNode() + .positionOnScreen + composeRule.onNodeWithTag("LongPressMenuGrid") + .performTouchInput { swipeUp() } + val finalPosition = composeRule.onNodeWithText(ShowDetails.label) + .fetchSemanticsNode() + .positionOnScreen + assertEquals(originalPosition, finalPosition) + } + + @Test + fun testAllActionsOnSmallScreenAreScrollable() { + onDevice().setDisplaySize( + widthSizeClass = WidthSizeClass.COMPACT, + heightSizeClass = HeightSizeClass.COMPACT + ) + assertOnlyAndAllArrangedActionsDisplayed( + availableActions = LongPressAction.Type.entries, + actionArrangement = LongPressAction.Type.entries, + expectedShownActions = LongPressAction.Type.entries + ) + + val anItemIsNotVisible = LongPressAction.Type.entries.any { + composeRule.onNodeWithText(it.label).isNotDisplayed() + } + assertEquals(true, anItemIsNotVisible) + + // try to scroll and confirm that items move + composeRule.onNodeWithTag("LongPressMenuGrid") + .assert(hasScrollAction()) + val originalPosition = composeRule.onNodeWithText(Enqueue.label) + .fetchSemanticsNode() + .positionOnScreen + composeRule.onNodeWithTag("LongPressMenuGrid") + .performTouchInput { swipeUp() } + val finalPosition = composeRule.onNodeWithText(Enqueue.label) + .fetchSemanticsNode() + .positionOnScreen + assertNotEquals(originalPosition, finalPosition) + } + + @Test + fun testEnabledDisabledActions() { + setLongPressMenu( + longPressActions = listOf( + ShowDetails.buildAction(enabled = { true }) {}, + Enqueue.buildAction(enabled = { false }) {} + ) + ) + composeRule.onNodeWithText(ShowDetails.label) + .assertIsEnabled() + .assertHasClickAction() + composeRule.onNodeWithText(Enqueue.label) + .assertIsNotEnabled() + } + + @Test + fun testClickingActionDismissesDialog() { + var pressedCount = 0 + var dismissedCount = 0 + setLongPressMenu( + onDismissRequest = { dismissedCount += 1 }, + longPressActions = listOf(PlayWithKodi.buildAction { pressedCount += 1 }) + ) + + composeRule.onNodeWithText(PlayWithKodi.label) + .performClick() + composeRule.waitUntil { dismissedCount == 1 } + assertEquals(1, pressedCount) + } + + @Test + fun testActionLoading() { + var dismissedCount = 0 + setLongPressMenu( + onDismissRequest = { dismissedCount += 1 }, + longPressActions = listOf(BackgroundShuffled.buildAction { delay(500) }) + ) + + composeRule.onNode(SemanticsMatcher.keyIsDefined(ProgressBarRangeInfo)) + .assertDoesNotExist() + composeRule.onNodeWithText(BackgroundShuffled.label) + .performClick() + composeRule.waitUntil { + composeRule.onNode(SemanticsMatcher.keyIsDefined(ProgressBarRangeInfo)) + .isDisplayed() + } + assertEquals(0, dismissedCount) + composeRule.waitUntil { dismissedCount == 1 } + } + + @Test + fun testActionError() { + var dismissedCount = 0 + composeRule.activity.setTheme(R.style.DarkTheme) + setLongPressMenu( + onDismissRequest = { dismissedCount += 1 }, + longPressActions = listOf( + BackgroundShuffled.buildAction { throw Throwable("Whatever") } + ) + ) + + onView(withId(com.google.android.material.R.id.snackbar_text)) + .check(doesNotExist()) + composeRule.onNodeWithText(BackgroundShuffled.label) + .performClick() + composeRule.waitUntil { dismissedCount == 1 } + onView(withId(com.google.android.material.R.id.snackbar_text)) + .check(matches(withText(R.string.error_snackbar_message))) + } +} 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 b875fd7a6..830a8e90b 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 @@ -65,8 +65,11 @@ 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.platform.testTag 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.Placeholder import androidx.compose.ui.text.PlaceholderVerticalAlign import androidx.compose.ui.text.SpanStyle @@ -249,12 +252,15 @@ private fun LongPressMenuContent( Column( modifier = Modifier .verticalScroll(scrollState) + .testTag("LongPressMenuGrid") ) { var actionIndex = if (header != null) -1 else 0 // -1 indicates the header while (actionIndex < actions.size) { Row( verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth() + modifier = Modifier + .fillMaxWidth() + .testTag("LongPressMenuGridRow") ) { var rowIndex = 0 while (rowIndex < buttonsPerRow) { @@ -294,6 +300,7 @@ private fun LongPressMenuContent( // only item on the row anyway .fillMaxWidth() .weight(maxHeaderWidthInButtonsFullSpan.toFloat()) + .testTag("LongPressMenuHeader") ) rowIndex += maxHeaderWidthInButtonsFullSpan } else { @@ -310,6 +317,7 @@ private fun LongPressMenuContent( .heightIn(min = ThumbnailHeight) .fillMaxWidth() .weight(headerWidthInButtonsReducedSpan.toFloat()) + .testTag("LongPressMenuHeader") ) rowIndex += headerWidthInButtonsReducedSpan } @@ -404,6 +412,7 @@ fun LongPressMenuHeader( .height(ThumbnailHeight) .widthIn(max = ThumbnailHeight * 16 / 9) // 16:9 thumbnail at most .clip(MaterialTheme.shapes.large) + .testTag("LongPressMenuHeaderThumbnail") ) } @@ -461,6 +470,12 @@ fun LongPressMenuHeader( verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.fillMaxWidth() + .semantics(mergeDescendants = true) { + contentDescription = ctx.getString( + R.string.items_in_playlist, + decoration.itemCount + ) + } ) { Icon( Icons.AutoMirrored.Default.PlaylistPlay, @@ -522,6 +537,7 @@ fun LongPressMenuHeader( } .fillMaxWidth() .fadedMarquee(edgeWidth = 12.dp) + .testTag("ShowChannelDetails") ) } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 74b4b18d7..c166a9c52 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -916,4 +916,5 @@ Background\nshuffled Popup\nshuffled Play\nshuffled + %d items in playlist From 512b536248284d4bae585ab0e739edd07cd524c3 Mon Sep 17 00:00:00 2001 From: Stypox Date: Sun, 8 Feb 2026 20:46:57 +0100 Subject: [PATCH 71/87] Resizing display in tests is only supported on API>=24 --- .../ui/components/menu/LongPressMenuTest.kt | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/app/src/androidTest/java/org/schabi/newpipe/ui/components/menu/LongPressMenuTest.kt b/app/src/androidTest/java/org/schabi/newpipe/ui/components/menu/LongPressMenuTest.kt index 252a125ad..c3b6d4f7b 100644 --- a/app/src/androidTest/java/org/schabi/newpipe/ui/components/menu/LongPressMenuTest.kt +++ b/app/src/androidTest/java/org/schabi/newpipe/ui/components/menu/LongPressMenuTest.kt @@ -1,5 +1,6 @@ package org.schabi.newpipe.ui.components.menu +import android.os.Build import androidx.activity.ComponentActivity import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -37,6 +38,7 @@ import androidx.test.espresso.device.sizeclass.WidthSizeClass import androidx.test.espresso.matcher.ViewMatchers.withId import androidx.test.espresso.matcher.ViewMatchers.withText import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.SdkSuppress import java.time.OffsetDateTime import java.time.temporal.ChronoUnit import kotlinx.coroutines.delay @@ -44,6 +46,8 @@ import org.junit.Assert.assertEquals import org.junit.Assert.assertNotEquals import org.junit.Rule import org.junit.Test +import org.junit.rules.RuleChain +import org.junit.rules.TestRule import org.junit.runner.RunWith import org.schabi.newpipe.R import org.schabi.newpipe.assertInRange @@ -69,7 +73,12 @@ class LongPressMenuTest { // Test rule for restoring device to its starting display size when a test case finishes. // See https://developer.android.com/training/testing/different-screens/tools#resize-displays. @get:Rule(order = 2) - val displaySizeRule: DisplaySizeRule = DisplaySizeRule() + val displaySizeRule: TestRule = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + DisplaySizeRule() + } else { + RuleChain.emptyRuleChain() + } private fun getLongPressable( title: String = "title", @@ -382,6 +391,7 @@ class LongPressMenuTest { } @Test + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.N) fun testHeaderSpansAllWidthIfSmallScreen() { onDevice().setDisplaySize( widthSizeClass = WidthSizeClass.COMPACT, @@ -401,6 +411,7 @@ class LongPressMenuTest { } @Test + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.N) fun testHeaderIsNotFullWidthIfLargeScreen() { onDevice().setDisplaySize( widthSizeClass = WidthSizeClass.EXPANDED, @@ -549,6 +560,7 @@ class LongPressMenuTest { } @Test + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.N) fun testAllActionsOnSmallScreenAreScrollable() { onDevice().setDisplaySize( widthSizeClass = WidthSizeClass.COMPACT, From 03325682a18e96a6aa841e3f48f2096b27ccdc7a Mon Sep 17 00:00:00 2001 From: Stypox Date: Sun, 8 Feb 2026 20:49:35 +0100 Subject: [PATCH 72/87] Add -grpc to emulator options for CI --- .github/workflows/ci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0fa1ca84c..bf0fafde1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -101,6 +101,9 @@ jobs: api-level: ${{ matrix.api-level }} target: ${{ matrix.target }} arch: ${{ matrix.arch }} + # the default emulator options from https://github.com/ReactiveCircus/android-emulator-runner#configurations + # plus `-grpc 8554 -grpc-use-jwt` to allow Espresso device control for instrumented tests + emulator-options: -no-window -gpu swiftshader_indirect -no-snapshot -noaudio -no-boot-anim -grpc 8554 -grpc-use-jwt script: ./gradlew connectedCheck --stacktrace - name: Upload test report when tests fail # because the printed out stacktrace (console) is too short, see also #7553 From 3b19d637c98c57b7e87bb51e1a59acfedf4ecce4 Mon Sep 17 00:00:00 2001 From: Stypox Date: Mon, 9 Feb 2026 09:30:29 +0100 Subject: [PATCH 73/87] Remove unused field uploaderUrl from LongPressable --- .../ui/components/menu/LongPressMenuTest.kt | 41 ++++++++----------- .../ui/components/menu/LongPressMenu.kt | 6 --- .../ui/components/menu/LongPressable.kt | 8 ---- 3 files changed, 16 insertions(+), 39 deletions(-) diff --git a/app/src/androidTest/java/org/schabi/newpipe/ui/components/menu/LongPressMenuTest.kt b/app/src/androidTest/java/org/schabi/newpipe/ui/components/menu/LongPressMenuTest.kt index c3b6d4f7b..b17452232 100644 --- a/app/src/androidTest/java/org/schabi/newpipe/ui/components/menu/LongPressMenuTest.kt +++ b/app/src/androidTest/java/org/schabi/newpipe/ui/components/menu/LongPressMenuTest.kt @@ -162,14 +162,18 @@ class LongPressMenuTest { var dismissedCount = 0 setLongPressMenu( onDismissRequest = { dismissedCount += 1 }, - longPressable = getLongPressable(uploader = "A", uploaderUrl = "https://example.com"), + longPressable = getLongPressable(uploader = "UpLoAdEr"), longPressActions = listOf(ShowChannelDetails.buildAction { pressedCount += 1 }), actionArrangement = listOf() ) + // although ShowChannelDetails is not in the actionArrangement set in user settings (and + // thus the action will not appear in the menu), the LongPressMenu "knows" how to open a + // channel because the longPressActions that can be performed contain ShowChannelDetails, + // therefore the channel name is made clickable in the header composeRule.onNodeWithText(R.string.show_channel_details, substring = true) .assertDoesNotExist() - composeRule.onNodeWithText("A", substring = true) + composeRule.onNodeWithText("UpLoAdEr", substring = true) .assertIsDisplayed() composeRule.onNodeWithTag("ShowChannelDetails") .performClick() @@ -183,11 +187,13 @@ class LongPressMenuTest { var dismissedCount = 0 setLongPressMenu( onDismissRequest = { dismissedCount += 1 }, - longPressable = getLongPressable(uploader = null, uploaderUrl = "https://example.com"), + longPressable = getLongPressable(uploader = null), longPressActions = listOf(ShowChannelDetails.buildAction { pressedCount += 1 }), actionArrangement = listOf() ) + // if the uploader name is not present, we use "Show channel details" as the text for the + // channel opening link in the header composeRule.onNodeWithText(R.string.show_channel_details, substring = true) .assertIsDisplayed() composeRule.onNodeWithTag("ShowChannelDetails") @@ -198,41 +204,26 @@ class LongPressMenuTest { @Test fun testShowChannelDetails3() { - var pressedCount = 0 - var dismissedCount = 0 setLongPressMenu( - onDismissRequest = { dismissedCount += 1 }, - longPressable = getLongPressable(uploader = null, uploaderUrl = null), - longPressActions = listOf(ShowChannelDetails.buildAction { pressedCount += 1 }), - actionArrangement = listOf() - ) - - composeRule.onNodeWithText(R.string.show_channel_details, substring = true) - .assertIsDisplayed() - composeRule.onNodeWithTag("ShowChannelDetails") - .performClick() - composeRule.waitUntil { dismissedCount == 1 } - assertEquals(1, pressedCount) - } - - @Test - fun testShowChannelDetails4() { - setLongPressMenu( - longPressable = getLongPressable(uploader = "A", uploaderUrl = "https://example.com"), + longPressable = getLongPressable(uploader = "UpLoAdEr"), longPressActions = listOf(), actionArrangement = listOf() ) + // the longPressActions that can be performed do not contain ShowChannelDetails, so the + // LongPressMenu cannot "know" how to open channel details composeRule.onNodeWithTag("ShowChannelDetails") .assertHasNoClickAction() } @Test - fun testShowChannelDetails5() { + fun testShowChannelDetails4() { setLongPressMenu( - longPressable = getLongPressable(uploader = "A", uploaderUrl = "https://example.com"), + longPressable = getLongPressable(uploader = "UpLoAdEr"), longPressActions = listOf(ShowChannelDetails.buildAction {}), actionArrangement = listOf(ShowChannelDetails) ) + // a ShowChannelDetails button is already present among the actions, + // so the channel name isn't clickable in the header composeRule.onNodeWithTag("ShowChannelDetails") .assertHasNoClickAction() } 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 830a8e90b..9d442899a 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 @@ -674,7 +674,6 @@ private class LongPressablePreviews : CollectionPreviewParameterProvider?, @@ -49,7 +48,6 @@ data class LongPressable( 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 }, streamType = item.streamType, uploadDate = item.uploadDate?.let { Either.right(it.offsetDateTime()) } @@ -63,7 +61,6 @@ data class LongPressable( 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 }, streamType = item.streamType, uploadDate = item.uploadDate?.let { Either.right(it) } @@ -77,7 +74,6 @@ data class LongPressable( url = item.url.takeIf { it.isNotBlank() }, thumbnailUrl = ImageStrategy.choosePreferredImage(item.thumbnails), uploader = item.uploader.takeIf { it.isNotBlank() }, - uploaderUrl = item.uploaderUrl?.takeIf { it.isNotBlank() }, viewCount = null, streamType = item.streamType, uploadDate = null, @@ -91,7 +87,6 @@ data class LongPressable( url = null, thumbnailUrl = item.thumbnailUrl, uploader = null, - uploaderUrl = null, viewCount = null, streamType = null, uploadDate = null, @@ -104,7 +99,6 @@ data class LongPressable( url = item.url, thumbnailUrl = item.thumbnailUrl, uploader = item.uploader, - uploaderUrl = null, viewCount = null, streamType = null, uploadDate = null, @@ -119,7 +113,6 @@ data class LongPressable( url = item.url?.takeIf { it.isNotBlank() }, thumbnailUrl = ImageStrategy.choosePreferredImage(item.thumbnails), uploader = null, - uploaderUrl = item.url?.takeIf { it.isNotBlank() }, viewCount = null, streamType = null, uploadDate = null, @@ -132,7 +125,6 @@ data class LongPressable( 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, streamType = null, uploadDate = null, From f65094b5fd026fa7367acaf41846f500c17a0e79 Mon Sep 17 00:00:00 2001 From: Stypox Date: Mon, 9 Feb 2026 10:24:18 +0100 Subject: [PATCH 74/87] Use getPlayQueueStartingAt in BaseListFragment Instead of overriding the whole showInfoItemDialog --- .../fragments/list/BaseListFragment.java | 27 ++++++++++------ .../list/channel/ChannelTabFragment.java | 24 ++++---------- .../list/playlist/PlaylistFragment.java | 32 +++++++------------ .../ui/components/menu/LongPressAction.kt | 8 +++++ 4 files changed, 45 insertions(+), 46 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 9aecd487d..c9709d59f 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 @@ -30,6 +30,7 @@ import org.schabi.newpipe.fragments.BaseStateFragment; import org.schabi.newpipe.fragments.OnScrollBelowItemsListener; import org.schabi.newpipe.info_list.InfoListAdapter; import org.schabi.newpipe.info_list.ItemViewMode; +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.util.NavigationHelper; @@ -42,6 +43,8 @@ import java.util.List; import java.util.Queue; import java.util.function.Supplier; +import kotlin.jvm.functions.Function0; + public abstract class BaseListFragment extends BaseStateFragment implements ListViewContract, StateSaver.WriteRead, SharedPreferences.OnSharedPreferenceChangeListener { @@ -267,8 +270,12 @@ public abstract class BaseListFragment extends BaseStateFragment } @Override - public void held(final StreamInfoItem selectedItem) { - showInfoItemDialog(selectedItem); + public void held(final StreamInfoItem item) { + openLongPressMenuInActivity( + requireActivity(), + LongPressable.fromStreamInfoItem(item), + LongPressAction.fromStreamInfoItem(item, getPlayQueueStartingAt(item)) + ); } }); @@ -325,13 +332,15 @@ public abstract class BaseListFragment extends BaseStateFragment useNormalItemListScrollListener(); } - protected void showInfoItemDialog(final StreamInfoItem item) { - openLongPressMenuInActivity( - requireActivity(), - LongPressable.fromStreamInfoItem(item), - // TODO generalize obtaining queue from here when fully migrating to Compose - LongPressAction.fromStreamInfoItem(item, null) - ); + /** + * @param item an item in the list, from which the built queue should start + * @return a builder for a queue containing all of the items in this list, with the queue index + * set to the item passed as parameter; return {@code null} if no "start playing from here" + * options should be shown + */ + @Nullable + protected Function0 getPlayQueueStartingAt(@NonNull final StreamInfoItem item) { + return null; // disable "play from here" options by default (e.g. in search, kiosks) } /** 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 855b28907..c9b87f199 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,7 +1,5 @@ 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; @@ -28,8 +26,6 @@ import org.schabi.newpipe.fragments.list.BaseListInfoFragment; 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; @@ -41,6 +37,7 @@ import java.util.function.Supplier; import java.util.stream.Collectors; import io.reactivex.rxjava3.core.Single; +import kotlin.jvm.functions.Function0; public class ChannelTabFragment extends BaseListInfoFragment implements PlaylistControlViewHolder { @@ -169,19 +166,6 @@ public class ChannelTabFragment extends BaseListInfoFragment 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) @@ -196,4 +180,10 @@ public class ChannelTabFragment extends BaseListInfoFragment 0); } + + @Nullable + @Override + protected Function0 getPlayQueueStartingAt(@NonNull final StreamInfoItem item) { + return () -> getPlayQueue(streamItems -> Math.max(streamItems.indexOf(item), 0)); + } } 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 5cee18136..3f117dbae 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,7 +3,6 @@ package org.schabi.newpipe.fragments.list.playlist; import static org.schabi.newpipe.extractor.utils.Utils.isBlank; import static org.schabi.newpipe.ktx.ViewUtils.animate; import static org.schabi.newpipe.ktx.ViewUtils.animateHideRecyclerViewAllowingScrolling; -import static org.schabi.newpipe.ui.components.menu.LongPressMenuKt.openLongPressMenuInActivity; import static org.schabi.newpipe.util.ServiceHelper.getServiceById; import android.os.Bundle; @@ -46,8 +45,6 @@ import org.schabi.newpipe.local.dialog.PlaylistDialog; 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; @@ -68,6 +65,7 @@ import io.reactivex.rxjava3.core.Flowable; import io.reactivex.rxjava3.core.Single; import io.reactivex.rxjava3.disposables.CompositeDisposable; import io.reactivex.rxjava3.disposables.Disposable; +import kotlin.jvm.functions.Function0; public class PlaylistFragment extends BaseListInfoFragment implements PlaylistControlViewHolder { @@ -144,19 +142,6 @@ public class PlaylistFragment extends BaseListInfoFragment getPlayQueueStartingAt(item)) - ); - } - @Override public void onCreateOptionsMenu(@NonNull final Menu menu, @NonNull final MenuInflater inflater) { @@ -361,10 +346,6 @@ public class PlaylistFragment extends BaseListInfoFragment infoItems = new ArrayList<>(); for (final InfoItem i : infoListAdapter.getItemsList()) { @@ -381,6 +362,17 @@ public class PlaylistFragment extends BaseListInfoFragment getPlayQueueStartingAt(@NonNull final StreamInfoItem item) { + return () -> getPlayQueue(Math.max(infoListAdapter.getItemsList().indexOf(item), 0)); + } + /*////////////////////////////////////////////////////////////////////////// // Utils //////////////////////////////////////////////////////////////////////////*/ 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 f1e985deb..c0dd3b7ce 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 @@ -141,6 +141,14 @@ data class LongPressAction( ) } + /** + * Instead of queueFromHere, this function could possibly take a + * `() -> List` plus the `StreamInfoItem/StreamEntity/...` + * that was long-pressed, and take care of searching through the list to find the item + * index, and finally take care of building the queue. It would deduplicate some code in + * fragments, but it's probably not possible to do because of all the different types of + * the items involved. + */ private fun buildPlayerFromHereActionList(queueFromHere: () -> PlayQueue): List { return listOf( Type.BackgroundFromHere.buildAction { context -> From d56144483688892eb7a523824afe34060f76722d Mon Sep 17 00:00:00 2001 From: Stypox Date: Mon, 9 Feb 2026 10:39:17 +0100 Subject: [PATCH 75/87] Implement getPlayQueueStartingAt for Compose ItemList too --- .../schabi/newpipe/ui/components/items/ItemList.kt | 9 ++++++++- .../ui/components/items/stream/StreamListItem.kt | 13 +++++++++++-- .../newpipe/ui/components/video/RelatedItems.kt | 1 + 3 files changed, 20 insertions(+), 3 deletions(-) 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 520587589..977be3b53 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 @@ -19,15 +19,22 @@ import org.schabi.newpipe.extractor.playlist.PlaylistInfoItem import org.schabi.newpipe.extractor.stream.StreamInfoItem import org.schabi.newpipe.info_list.ItemViewMode import org.schabi.newpipe.ktx.findFragmentActivity +import org.schabi.newpipe.player.playqueue.PlayQueue import org.schabi.newpipe.ui.components.common.LazyColumnThemedScrollbar import org.schabi.newpipe.ui.components.items.playlist.PlaylistListItem import org.schabi.newpipe.ui.components.items.stream.StreamListItem import org.schabi.newpipe.util.DependentPreferenceHelper import org.schabi.newpipe.util.NavigationHelper +/** + * @param getPlayQueueStartingAt a builder for a queue containing all of the items in this list, + * with the queue index set to the item passed as parameter; return `null` if no "start playing from + * here" options should be shown in the long press menu + */ @Composable fun ItemList( items: List, + getPlayQueueStartingAt: ((item: StreamInfoItem) -> PlayQueue)? = null, mode: ItemViewMode = determineItemViewMode(), listHeader: LazyListScope.() -> Unit = {} ) { @@ -72,7 +79,7 @@ fun ItemList( val item = items[it] if (item is StreamInfoItem) { - StreamListItem(item, showProgress, onClick) + StreamListItem(item, showProgress, getPlayQueueStartingAt, 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 dac6d32ae..61d3f0928 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 @@ -26,16 +26,23 @@ 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.player.playqueue.PlayQueue 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 +/** + * @param getPlayQueueStartingAt a builder for a queue containing all of the items in this list, + * with the queue index set to the item passed as parameter; return `null` if no "start playing from + * here" options should be shown in the long press menu + */ @OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) @Composable fun StreamListItem( stream: StreamInfoItem, showProgress: Boolean, + getPlayQueueStartingAt: ((item: StreamInfoItem) -> PlayQueue)? = null, onClick: (StreamInfoItem) -> Unit = {} ) { var showLongPressMenu by rememberSaveable { mutableStateOf(false) } @@ -79,8 +86,10 @@ fun StreamListItem( if (showLongPressMenu) { LongPressMenu( longPressable = LongPressable.fromStreamInfoItem(stream), - // TODO queueFromHere: allow playing the whole list starting from one stream - longPressActions = LongPressAction.fromStreamInfoItem(stream, null), + longPressActions = LongPressAction.fromStreamInfoItem( + stream, + getPlayQueueStartingAt?.let { { it(stream) } } + ), onDismissRequest = { showLongPressMenu = false } ) } diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/video/RelatedItems.kt b/app/src/main/java/org/schabi/newpipe/ui/components/video/RelatedItems.kt index 3f444a9d9..1bcb0b87f 100644 --- a/app/src/main/java/org/schabi/newpipe/ui/components/video/RelatedItems.kt +++ b/app/src/main/java/org/schabi/newpipe/ui/components/video/RelatedItems.kt @@ -44,6 +44,7 @@ fun RelatedItems(info: StreamInfo) { ItemList( items = info.relatedItems, + getPlayQueueStartingAt = null, // it does not make sense to play related items "from here" mode = ItemViewMode.LIST, listHeader = { item { From 5b10a9357776c7b9a857b8d5d9bf88e8ced21303 Mon Sep 17 00:00:00 2001 From: Stypox Date: Mon, 9 Feb 2026 12:28:38 +0100 Subject: [PATCH 76/87] Embed actions impls inside LongPressAction when possible ... and add TODO comments for the `onDelete` implementations, which will be embeddable only after migrating the list fragments to Compose. Also remove incomplete and misleading toStreamInfoItem() implementations in StreamStatisticsEntry and PlaylistStreamEntry. Now one has to do statisticsEntry.streamEntry.toStreamInfoItem() instead, making it clear that the call only uses data from streamEntry. --- .../database/playlist/PlaylistStreamEntry.kt | 15 --- .../database/stream/StreamStatisticsEntry.kt | 15 --- .../fragments/list/BaseListFragment.java | 2 +- .../local/bookmark/BookmarkFragment.java | 61 +-------- .../history/StatisticsPlaylistFragment.java | 4 +- .../local/playlist/LocalPlaylistFragment.java | 24 ++-- .../local/playlist/LocalPlaylistManager.java | 10 +- .../subscription/SubscriptionFragment.kt | 13 +- .../MediaBrowserPlaybackPreparer.kt | 4 +- .../playqueue/LocalPlaylistPlayQueue.kt | 2 +- .../ui/components/menu/LongPressAction.kt | 125 +++++++++++++++--- 11 files changed, 136 insertions(+), 139 deletions(-) diff --git a/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistStreamEntry.kt b/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistStreamEntry.kt index 90fdee2d3..9c868b3bc 100644 --- a/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistStreamEntry.kt +++ b/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistStreamEntry.kt @@ -31,19 +31,4 @@ data class PlaylistStreamEntry( override val localItemType: LocalItem.LocalItemType get() = LocalItem.LocalItemType.PLAYLIST_STREAM_ITEM - - @Throws(IllegalArgumentException::class) - fun toStreamInfoItem(): StreamInfoItem { - return StreamInfoItem( - streamEntity.serviceId, - streamEntity.url, - streamEntity.title, - streamEntity.streamType - ).apply { - duration = streamEntity.duration - uploaderName = streamEntity.uploader - uploaderUrl = streamEntity.uploaderUrl - thumbnails = ImageStrategy.dbUrlToImageList(streamEntity.thumbnailUrl) - } - } } diff --git a/app/src/main/java/org/schabi/newpipe/database/stream/StreamStatisticsEntry.kt b/app/src/main/java/org/schabi/newpipe/database/stream/StreamStatisticsEntry.kt index ce74678ca..227d8816b 100644 --- a/app/src/main/java/org/schabi/newpipe/database/stream/StreamStatisticsEntry.kt +++ b/app/src/main/java/org/schabi/newpipe/database/stream/StreamStatisticsEntry.kt @@ -37,21 +37,6 @@ data class StreamStatisticsEntry( override val localItemType: LocalItem.LocalItemType get() = LocalItem.LocalItemType.STATISTIC_STREAM_ITEM - @Ignore - fun toStreamInfoItem(): StreamInfoItem { - return StreamInfoItem( - streamEntity.serviceId, - streamEntity.url, - streamEntity.title, - streamEntity.streamType - ).apply { - duration = streamEntity.duration - uploaderName = streamEntity.uploader - uploaderUrl = streamEntity.uploaderUrl - thumbnails = ImageStrategy.dbUrlToImageList(streamEntity.thumbnailUrl) - } - } - companion object { const val STREAM_LATEST_DATE = "latestAccess" const val STREAM_WATCH_COUNT = "watchCount" 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 c9709d59f..371e10934 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 @@ -297,7 +297,7 @@ public abstract class BaseListFragment extends BaseStateFragment openLongPressMenuInActivity( requireActivity(), LongPressable.fromChannelInfoItem(selectedItem), - LongPressAction.fromChannelInfoItem(selectedItem, null) + LongPressAction.fromChannelInfoItem(selectedItem, false) ); } }); 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 7f8a163a9..b7a3be6d4 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 @@ -6,8 +6,6 @@ import static org.schabi.newpipe.ui.components.menu.LongPressMenuKt.openLongPres import android.os.Bundle; import android.os.Parcelable; -import android.text.InputType; -import android.util.Log; import android.util.Pair; import android.view.LayoutInflater; import android.view.View; @@ -32,7 +30,6 @@ import org.schabi.newpipe.database.LocalItem; import org.schabi.newpipe.database.playlist.PlaylistLocalItem; import org.schabi.newpipe.database.playlist.PlaylistMetadataEntry; import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity; -import org.schabi.newpipe.databinding.DialogEditTextBinding; import org.schabi.newpipe.error.ErrorInfo; import org.schabi.newpipe.error.UserAction; import org.schabi.newpipe.local.BaseLocalListFragment; @@ -55,7 +52,6 @@ import java.util.concurrent.atomic.AtomicBoolean; import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; import io.reactivex.rxjava3.disposables.CompositeDisposable; -import io.reactivex.rxjava3.disposables.Disposable; public final class BookmarkFragment extends BaseLocalListFragment, Void> implements DebounceSavable { @@ -323,25 +319,6 @@ public final class BookmarkFragment extends BaseLocalListFragment { /*Do nothing on success*/ }, throwable -> showError( - new ErrorInfo(throwable, - UserAction.REQUESTED_BOOKMARK, - "Changing playlist name"))); - disposables.add(disposable); - } - private void deleteItem(final PlaylistLocalItem item) { if (itemListAdapter == null) { return; @@ -501,44 +478,27 @@ public final class BookmarkFragment extends BaseLocalListFragment showDeleteDialog(item.getOrderingName(), item) ) ); } private void showLocalDialog(final PlaylistMetadataEntry selectedItem) { - final boolean isThumbnailPermanent = localPlaylistManager - .getIsPlaylistThumbnailPermanent(selectedItem.getUid()); - openLongPressMenuInActivity( requireActivity(), LongPressable.fromPlaylistMetadataEntry(selectedItem), LongPressAction.fromPlaylistMetadataEntry( selectedItem, - () -> showRenameDialog(selectedItem), - () -> showDeleteDialog(selectedItem.getOrderingName(), selectedItem), - isThumbnailPermanent ? () -> unsetPermanentThumbnail(selectedItem) : null + localPlaylistManager.getIsPlaylistThumbnailPermanent(selectedItem.getUid()), + // TODO passing this parameter is bad and should be fixed when migrating the + // bookmark fragment to Compose, for more info see method javadoc + () -> showDeleteDialog(selectedItem.getOrderingName(), selectedItem) ) ); } - private void showRenameDialog(final PlaylistMetadataEntry selectedItem) { - final DialogEditTextBinding dialogBinding = - DialogEditTextBinding.inflate(getLayoutInflater()); - dialogBinding.dialogEditText.setHint(R.string.name); - dialogBinding.dialogEditText.setInputType(InputType.TYPE_CLASS_TEXT); - dialogBinding.dialogEditText.setText(selectedItem.getOrderingName()); - - new AlertDialog.Builder(activity) - .setView(dialogBinding.getRoot()) - .setPositiveButton(R.string.rename_playlist, (dialog, which) -> - changeLocalPlaylistName( - selectedItem.getUid(), - dialogBinding.dialogEditText.getText().toString())) - .setNegativeButton(R.string.cancel, null) - .show(); - } - private void showDeleteDialog(final String name, final PlaylistLocalItem item) { if (activity == null || disposables == null) { return; @@ -552,13 +512,4 @@ public final class BookmarkFragment extends BaseLocalListFragment infoItems = itemListAdapter.getItemsList(); final List streamInfoItems = new ArrayList<>(infoItems.size()); for (final LocalItem item : infoItems) { - if (item instanceof StreamStatisticsEntry) { - streamInfoItems.add(((StreamStatisticsEntry) item).toStreamInfoItem()); + if (item instanceof StreamStatisticsEntry streamStatisticsEntry) { + streamInfoItems.add(streamStatisticsEntry.getStreamEntity().toStreamInfoItem()); } } return new SinglePlayQueue(streamInfoItems, index); 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 19703d6e9..5a00a7f6a 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 @@ -451,7 +451,9 @@ public class LocalPlaylistFragment extends BaseLocalListFragment successToast.show(), throwable -> showError(new ErrorInfo(throwable, UserAction.REQUESTED_BOOKMARK, @@ -620,7 +622,7 @@ public class LocalPlaylistFragment extends BaseLocalListFragment getPlayQueueStartingAt(item), - () -> deleteItem(item), - () -> changeThumbnailStreamId(item.getStreamEntity().getUid(), true) + playlistId, + // TODO passing this parameter is bad and should be fixed when migrating the + // local playlist fragment to Compose, for more info see method javadoc + () -> deleteItem(item) ) ); } @@ -838,8 +842,8 @@ public class LocalPlaylistFragment extends BaseLocalListFragment infoItems = itemListAdapter.getItemsList(); final List streamInfoItems = new ArrayList<>(infoItems.size()); for (final LocalItem item : infoItems) { - if (item instanceof PlaylistStreamEntry) { - streamInfoItems.add(((PlaylistStreamEntry) item).toStreamInfoItem()); + if (item instanceof PlaylistStreamEntry playlistStreamEntry) { + streamInfoItems.add(playlistStreamEntry.getStreamEntity().toStreamInfoItem()); } } return new SinglePlayQueue(streamInfoItems, index); diff --git a/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistManager.java b/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistManager.java index 1480735fb..608dc9c1a 100644 --- a/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistManager.java +++ b/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistManager.java @@ -151,13 +151,9 @@ public class LocalPlaylistManager { .isThumbnailPermanent(); } - public long getAutomaticPlaylistThumbnailStreamId(final long playlistId) { - final long streamId = playlistStreamTable.getAutomaticThumbnailStreamId(playlistId) - .blockingFirst(); - if (streamId < 0) { - return PlaylistEntity.DEFAULT_THUMBNAIL_ID; - } - return streamId; + public Flowable getAutomaticPlaylistThumbnailStreamId(final long playlistId) { + return playlistStreamTable.getAutomaticThumbnailStreamId(playlistId) + .map(streamId -> (streamId >= 0 ? streamId : PlaylistEntity.DEFAULT_THUMBNAIL_ID)); } private Maybe modifyPlaylist(final long playlistId, 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 2a6d7bfb6..90e3ecc0d 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 @@ -336,18 +336,7 @@ class SubscriptionFragment : BaseStateFragment() { openLongPressMenuInActivity( requireActivity(), LongPressable.fromChannelInfoItem(selectedItem), - LongPressAction.fromChannelInfoItem( - item = selectedItem, - onUnsubscribe = { deleteChannel(selectedItem) } - ) - ) - } - - private fun deleteChannel(selectedItem: ChannelInfoItem) { - disposables.add( - subscriptionManager.deleteSubscription(selectedItem.serviceId, selectedItem.url).subscribe { - Toast.makeText(requireContext(), getString(R.string.channel_unsubscribed), Toast.LENGTH_SHORT).show() - } + LongPressAction.fromChannelInfoItem(selectedItem, true) ) } diff --git a/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserPlaybackPreparer.kt b/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserPlaybackPreparer.kt index 5a852cd5b..731407548 100644 --- a/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserPlaybackPreparer.kt +++ b/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserPlaybackPreparer.kt @@ -127,7 +127,9 @@ class MediaBrowserPlaybackPreparer( //region Building play queues from playlists and history private fun extractLocalPlayQueue(playlistId: Long, index: Int): Single { return LocalPlaylistManager(database).getPlaylistStreams(playlistId).firstOrError() - .map { items -> SinglePlayQueue(items.map { it.toStreamInfoItem() }, index) } + .map { items -> + SinglePlayQueue(items.map { it.streamEntity.toStreamInfoItem() }, index) + } } private fun extractRemotePlayQueue(playlistId: Long, index: Int): Single { diff --git a/app/src/main/java/org/schabi/newpipe/player/playqueue/LocalPlaylistPlayQueue.kt b/app/src/main/java/org/schabi/newpipe/player/playqueue/LocalPlaylistPlayQueue.kt index ab0be643f..7aa8c9f7d 100644 --- a/app/src/main/java/org/schabi/newpipe/player/playqueue/LocalPlaylistPlayQueue.kt +++ b/app/src/main/java/org/schabi/newpipe/player/playqueue/LocalPlaylistPlayQueue.kt @@ -28,7 +28,7 @@ class LocalPlaylistPlayQueue(info: PlaylistMetadataEntry) : PlayQueue(0, listOf( .observeOn(AndroidSchedulers.mainThread()) .subscribe( { streamEntries -> - append(streamEntries.map { PlayQueueItem(it.toStreamInfoItem()) }) + append(streamEntries.map { PlayQueueItem(it.streamEntity.toStreamInfoItem()) }) }, { e -> Log.e(TAG, "Error fetching local playlist", e) 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 c0dd3b7ce..e720c0492 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,9 +1,11 @@ package org.schabi.newpipe.ui.components.menu import android.content.Context +import android.text.InputType import android.widget.Toast import androidx.annotation.MainThread import androidx.annotation.StringRes +import androidx.appcompat.app.AlertDialog import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.PlaylistAdd import androidx.compose.material.icons.filled.AddToQueue @@ -24,7 +26,10 @@ import androidx.compose.material.icons.filled.QueuePlayNext import androidx.compose.material.icons.filled.Share import androidx.compose.ui.graphics.vector.ImageVector import androidx.core.net.toUri +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.reactive.awaitFirst import kotlinx.coroutines.rx3.await import kotlinx.coroutines.rx3.awaitSingle import kotlinx.coroutines.withContext @@ -35,6 +40,7 @@ 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.databinding.DialogEditTextBinding import org.schabi.newpipe.download.DownloadDialog import org.schabi.newpipe.extractor.InfoItem import org.schabi.newpipe.extractor.channel.ChannelInfoItem @@ -45,6 +51,7 @@ import org.schabi.newpipe.local.dialog.PlaylistAppendDialog import org.schabi.newpipe.local.dialog.PlaylistDialog import org.schabi.newpipe.local.history.HistoryRecordManager import org.schabi.newpipe.local.playlist.LocalPlaylistManager +import org.schabi.newpipe.local.subscription.SubscriptionManager import org.schabi.newpipe.player.helper.PlayerHolder import org.schabi.newpipe.player.playqueue.ChannelTabPlayQueue import org.schabi.newpipe.player.playqueue.LocalPlaylistPlayQueue @@ -147,7 +154,7 @@ data class LongPressAction( * that was long-pressed, and take care of searching through the list to find the item * index, and finally take care of building the queue. It would deduplicate some code in * fragments, but it's probably not possible to do because of all the different types of - * the items involved. + * the items involved. But this should be reconsidered if the types will be unified. */ private fun buildPlayerFromHereActionList(queueFromHere: () -> PlayQueue): List { return listOf( @@ -330,7 +337,7 @@ data class LongPressAction( item: StreamStatisticsEntry, queueFromHere: (() -> PlayQueue)? ): List { - return fromStreamEntity(item.streamEntity, queueFromHere) + + return fromStreamInfoItem(item.streamEntity.toStreamInfoItem(), queueFromHere) + listOf( Type.Delete.buildAction { context -> withContext(Dispatchers.IO) { @@ -344,39 +351,100 @@ data class LongPressAction( ) } + /** + * TODO [onDelete] is still passed externally to allow the calling fragment to debounce + * many deletions into a single database transaction, improving performance. This is + * however a bad pattern (which has already led to many bugs in NewPipe). Once we migrate + * the playlist fragment to Compose, we should make the database updates immediately, and + * use `collectAsLazyPagingItems()` to load data in chunks and thus avoid slowdowns. + */ @JvmStatic fun fromPlaylistStreamEntry( item: PlaylistStreamEntry, queueFromHere: (() -> PlayQueue)?, - // TODO possibly embed these two actions here - onDelete: Runnable, - onSetAsPlaylistThumbnail: Runnable + playlistId: Long, + onDelete: Runnable ): List { - return fromStreamEntity(item.streamEntity, queueFromHere) + + return fromStreamInfoItem(item.streamEntity.toStreamInfoItem(), queueFromHere) + listOf( - Type.Delete.buildAction { onDelete.run() }, - Type.SetAsPlaylistThumbnail.buildAction { onSetAsPlaylistThumbnail.run() } + Type.SetAsPlaylistThumbnail.buildAction { context -> + withContext(Dispatchers.IO) { + LocalPlaylistManager(NewPipeDatabase.getInstance(context)) + .changePlaylistThumbnail(playlistId, item.streamEntity.uid, true) + .awaitSingle() + } + Toast.makeText( + context, + R.string.playlist_thumbnail_change_success, + Toast.LENGTH_SHORT + ).show() + }, + Type.Delete.buildAction { onDelete.run() } ) } + /** + * TODO see [fromPlaylistStreamEntry] for why [onDelete] is here and why it's bad + */ @JvmStatic fun fromPlaylistMetadataEntry( item: PlaylistMetadataEntry, - onRename: Runnable, - onDelete: Runnable, - unsetPlaylistThumbnail: Runnable? + isThumbnailPermanent: Boolean, + onDelete: Runnable ): List { return buildPlayerActionList { LocalPlaylistPlayQueue(item) } + buildPlayerShuffledActionList { LocalPlaylistPlayQueue(item) } + listOf( - Type.Rename.buildAction { onRename.run() }, - Type.Delete.buildAction { onDelete.run() }, + Type.Rename.buildAction { context -> + // open the dialog and wait for its completion in the coroutine + val newName = suspendCoroutine { continuation -> + val dialogBinding = DialogEditTextBinding.inflate( + context.findFragmentActivity().layoutInflater + ) + dialogBinding.dialogEditText.setHint(R.string.name) + dialogBinding.dialogEditText.setInputType(InputType.TYPE_CLASS_TEXT) + dialogBinding.dialogEditText.setText(item.orderingName) + AlertDialog.Builder(context) + .setView(dialogBinding.getRoot()) + .setPositiveButton(R.string.rename_playlist) { _, _ -> + continuation.resume(dialogBinding.dialogEditText.getText().toString()) + } + .setNegativeButton(R.string.cancel) { _, _ -> + continuation.resume(null) + } + .setOnCancelListener { + continuation.resume(null) + } + .show() + } ?: return@buildAction + + withContext(Dispatchers.IO) { + LocalPlaylistManager(NewPipeDatabase.getInstance(context)) + .renamePlaylist(item.uid, newName) + .awaitSingle() + } + }, Type.UnsetPlaylistThumbnail.buildAction( - enabled = { unsetPlaylistThumbnail != null } - ) { unsetPlaylistThumbnail?.run() } + enabled = { isThumbnailPermanent } + ) { context -> + withContext(Dispatchers.IO) { + val localPlaylistManager = + LocalPlaylistManager(NewPipeDatabase.getInstance(context)) + val thumbnailStreamId = localPlaylistManager + .getAutomaticPlaylistThumbnailStreamId(item.uid) + .awaitFirst() + localPlaylistManager + .changePlaylistThumbnail(item.uid, thumbnailStreamId, false) + .awaitSingle() + } + }, + Type.Delete.buildAction { onDelete.run() } ) } + /** + * TODO see [fromPlaylistStreamEntry] for why [onDelete] is here and why it's bad + */ @JvmStatic fun fromPlaylistRemoteEntity( item: PlaylistRemoteEntity, @@ -397,12 +465,12 @@ data class LongPressAction( @JvmStatic fun fromChannelInfoItem( item: ChannelInfoItem, - onUnsubscribe: Runnable? + showUnsubscribe: Boolean ): List { return buildPlayerActionList { ChannelTabPlayQueue(item.serviceId, item.url) } + buildPlayerShuffledActionList { ChannelTabPlayQueue(item.serviceId, item.url) } + buildShareActionList(item) + - listOfNotNull( + listOf( Type.ShowChannelDetails.buildAction { context -> NavigationHelper.openChannelFragmentUsingIntent( context, @@ -410,9 +478,26 @@ data class LongPressAction( item.url, item.name ) - }, - onUnsubscribe?.let { r -> Type.Unsubscribe.buildAction { r.run() } } - ) + } + ) + + if (showUnsubscribe) { + listOf( + Type.Unsubscribe.buildAction { context -> + withContext(Dispatchers.IO) { + SubscriptionManager(context) + .deleteSubscription(item.serviceId, item.url) + .await() + } + Toast.makeText( + context, + context.getString(R.string.channel_unsubscribed), + Toast.LENGTH_SHORT + ).show() + } + ) + } else { + listOf() + } } @JvmStatic From 920d630409625e64325737a0ba9af388ee1c2478 Mon Sep 17 00:00:00 2001 From: Stypox Date: Mon, 9 Feb 2026 12:28:54 +0100 Subject: [PATCH 77/87] Add some documentation to tests --- .../schabi/newpipe/InstrumentedTestUtil.kt | 9 ++++ .../ui/components/menu/LongPressMenuTest.kt | 44 ++++++++++++++----- .../newpipe/local/dialog/PlaylistDialog.java | 3 +- 3 files changed, 44 insertions(+), 12 deletions(-) diff --git a/app/src/androidTest/java/org/schabi/newpipe/InstrumentedTestUtil.kt b/app/src/androidTest/java/org/schabi/newpipe/InstrumentedTestUtil.kt index a828f844e..b8b9f0415 100644 --- a/app/src/androidTest/java/org/schabi/newpipe/InstrumentedTestUtil.kt +++ b/app/src/androidTest/java/org/schabi/newpipe/InstrumentedTestUtil.kt @@ -10,6 +10,9 @@ import androidx.preference.PreferenceManager import androidx.test.platform.app.InstrumentationRegistry import org.junit.Assert.fail +/** + * Use this instead of passing contexts around in instrumented tests. + */ val ctx: Context get() = InstrumentationRegistry.getInstrumentation().targetContext @@ -52,6 +55,9 @@ fun SemanticsNodeInteractionsProvider.onNodeWithContentDescription( return this.onNodeWithContentDescription(ctx.getString(text), substring, ignoreCase, useUnmergedTree) } +/** + * Asserts that [value] is in the range [[l], [r]] (both extremes included). + */ fun > assertInRange(l: T, r: T, value: T) { if (l > r) { fail("Invalid range passed to `assertInRange`: [$l, $r]") @@ -61,6 +67,9 @@ fun > assertInRange(l: T, r: T, value: T) { } } +/** + * Asserts that [value] is NOT in the range [[l], [r]] (both extremes included). + */ fun > assertNotInRange(l: T, r: T, value: T) { if (l > r) { fail("Invalid range passed to `assertInRange`: [$l, $r]") diff --git a/app/src/androidTest/java/org/schabi/newpipe/ui/components/menu/LongPressMenuTest.kt b/app/src/androidTest/java/org/schabi/newpipe/ui/components/menu/LongPressMenuTest.kt index b17452232..caa450e9c 100644 --- a/app/src/androidTest/java/org/schabi/newpipe/ui/components/menu/LongPressMenuTest.kt +++ b/app/src/androidTest/java/org/schabi/newpipe/ui/components/menu/LongPressMenuTest.kt @@ -80,18 +80,24 @@ class LongPressMenuTest { RuleChain.emptyRuleChain() } + /** + * Utility to build a [LongPressable] with dummy data for testing. + */ private fun getLongPressable( title: String = "title", url: String? = "https://example.com", thumbnailUrl: String? = "android.resource://${ctx.packageName}/${R.drawable.placeholder_thumbnail_video}", uploader: String? = "uploader", - uploaderUrl: String? = "https://example.com", viewCount: Long? = 42, streamType: StreamType? = StreamType.VIDEO_STREAM, uploadDate: Either? = Either.left("2026"), decoration: LongPressable.Decoration? = LongPressable.Decoration.Duration(9478) - ) = LongPressable(title, url, thumbnailUrl, uploader, uploaderUrl, viewCount, streamType, uploadDate, decoration) + ) = LongPressable(title, url, thumbnailUrl, uploader, viewCount, streamType, uploadDate, decoration) + /** + * Sets up the [LongPressMenu] in the [composeRule] Compose content for running tests. Handles + * setting dialog settings via shared preferences, and closing the dialog when it is dismissed. + */ private fun setLongPressMenu( longPressable: LongPressable = getLongPressable(), longPressActions: List = LongPressAction.Type.entries.map { it.buildAction { } }, @@ -114,8 +120,10 @@ class LongPressMenuTest { } } - // the three tests below all call this function to ensure that the editor button is shown - // independently of the long press menu contents + /** + * The three tests below all call this function to ensure that the editor button is shown + * independently of the long press menu contents. + */ private fun assertEditorIsEnteredAndExitedProperly() { composeRule.onNodeWithContentDescription(R.string.long_press_menu_enabled_actions_description) .assertDoesNotExist() @@ -256,6 +264,8 @@ class LongPressMenuTest { @Test fun testHeaderUploadDate1() { + // here the upload date is an unparsed String we have to use as-is + // (e.g. the extractor could not parse it) setLongPressMenu(getLongPressable(uploadDate = Either.left("abcd"))) composeRule.onNodeWithText("abcd", substring = true) .assertIsDisplayed() @@ -263,6 +273,7 @@ class LongPressMenuTest { @Test fun testHeaderUploadDate2() { + // here the upload date is a proper OffsetDateTime that can be formatted properly val date = OffsetDateTime.now() .minus(2, ChronoUnit.HOURS) .minus(50, ChronoUnit.MILLIS) @@ -382,7 +393,7 @@ class LongPressMenuTest { } @Test - @SdkSuppress(minSdkVersion = Build.VERSION_CODES.N) + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.N) // setDisplaySize not available on API < 24 fun testHeaderSpansAllWidthIfSmallScreen() { onDevice().setDisplaySize( widthSizeClass = WidthSizeClass.COMPACT, @@ -397,12 +408,13 @@ class LongPressMenuTest { val header = composeRule.onNodeWithTag("LongPressMenuHeader") .fetchSemanticsNode() .boundsInRoot + // checks that the header is roughly as large as the row that contains it assertInRange(row.left, row.left + 24.dp.value, header.left) assertInRange(row.right - 24.dp.value, row.right, header.right) } @Test - @SdkSuppress(minSdkVersion = Build.VERSION_CODES.N) + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.N) // setDisplaySize not available on API < 24 fun testHeaderIsNotFullWidthIfLargeScreen() { onDevice().setDisplaySize( widthSizeClass = WidthSizeClass.EXPANDED, @@ -417,12 +429,15 @@ class LongPressMenuTest { val header = composeRule.onNodeWithTag("LongPressMenuHeader") .fetchSemanticsNode() .boundsInRoot + // checks that the header is definitely smaller than the row that contains it assertInRange(row.left, row.left + 24.dp.value, header.left) - assertNotInRange(row.right - 24.dp.value, row.right, header.right) + assertNotInRange(row.right - 24.dp.value, Float.MAX_VALUE, header.right) } - // the tests below all call this function to test, under different conditions, that the shown - // actions are the intersection between the available and the enabled actions + /** + * The tests below all call this function to test, under different conditions, that the shown + * actions are the intersection between the available and the enabled actions. + */ fun assertOnlyAndAllArrangedActionsDisplayed( availableActions: List, actionArrangement: List, @@ -430,7 +445,9 @@ class LongPressMenuTest { ) { setLongPressMenu( longPressActions = availableActions.map { it.buildAction {} }, - isHeaderEnabled = ((availableActions.size + actionArrangement.size) % 2) == 0, + // whether the header is enabled or not shouldn't influence the result, so enable it + // at random (but still deterministically) + isHeaderEnabled = ((expectedShownActions + availableActions).sumOf { it.id } % 2) == 0, actionArrangement = actionArrangement ) for (type in LongPressAction.Type.entries) { @@ -551,7 +568,7 @@ class LongPressMenuTest { } @Test - @SdkSuppress(minSdkVersion = Build.VERSION_CODES.N) + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.N) // setDisplaySize not available on API < 24 fun testAllActionsOnSmallScreenAreScrollable() { onDevice().setDisplaySize( widthSizeClass = WidthSizeClass.COMPACT, @@ -620,6 +637,9 @@ class LongPressMenuTest { longPressActions = listOf(BackgroundShuffled.buildAction { delay(500) }) ) + // test that the loading circle appears while the action is being performed; note that there + // is no way to test that the long press menu contents disappear, because in the current + // implementation they just become hidden below the loading circle (with touches suppressed) composeRule.onNode(SemanticsMatcher.keyIsDefined(ProgressBarRangeInfo)) .assertDoesNotExist() composeRule.onNodeWithText(BackgroundShuffled.label) @@ -643,6 +663,8 @@ class LongPressMenuTest { ) ) + // make sure that a snackbar is shown after the dialog gets dismissed, + // see https://stackoverflow.com/a/33245290 onView(withId(com.google.android.material.R.id.snackbar_text)) .check(doesNotExist()) composeRule.onNodeWithText(BackgroundShuffled.label) diff --git a/app/src/main/java/org/schabi/newpipe/local/dialog/PlaylistDialog.java b/app/src/main/java/org/schabi/newpipe/local/dialog/PlaylistDialog.java index ddc84e783..1bcc9f518 100644 --- a/app/src/main/java/org/schabi/newpipe/local/dialog/PlaylistDialog.java +++ b/app/src/main/java/org/schabi/newpipe/local/dialog/PlaylistDialog.java @@ -135,7 +135,8 @@ public abstract class PlaylistDialog extends DialogFragment implements StateSave * * @param context context used for accessing the database * @param streamEntities used for crating the dialog - * @return the {@link Maybe} to subscribe to to obtain the correct {@link PlaylistDialog} + * @return the {@link Maybe} to subscribe to to obtain the correct {@link PlaylistDialog}; the + * function inside the subscribe() will be called on the main thread */ public static Maybe createCorrespondingDialog( final Context context, From 08300951ed977a65766e1ba3e2c7eb9e158a02f5 Mon Sep 17 00:00:00 2001 From: Stypox Date: Mon, 9 Feb 2026 12:58:05 +0100 Subject: [PATCH 78/87] Add Subscribe button to LongPressMenu --- .../subscription/SubscriptionEntity.kt | 12 ++++++++ .../fragments/list/BaseListFragment.java | 10 +++++-- .../list/channel/ChannelFragment.java | 8 +----- .../local/subscription/SubscriptionManager.kt | 4 +++ .../ui/components/menu/LongPressAction.kt | 28 +++++++++++++++---- .../components/menu/LongPressMenuSettings.kt | 7 +++-- 6 files changed, 50 insertions(+), 19 deletions(-) diff --git a/app/src/main/java/org/schabi/newpipe/database/subscription/SubscriptionEntity.kt b/app/src/main/java/org/schabi/newpipe/database/subscription/SubscriptionEntity.kt index 7df9830e4..336d96e0f 100644 --- a/app/src/main/java/org/schabi/newpipe/database/subscription/SubscriptionEntity.kt +++ b/app/src/main/java/org/schabi/newpipe/database/subscription/SubscriptionEntity.kt @@ -83,5 +83,17 @@ data class SubscriptionEntity( subscriberCount = info.subscriberCount ) } + + @Ignore + fun from(info: ChannelInfoItem): SubscriptionEntity { + return SubscriptionEntity( + serviceId = info.serviceId, + url = info.url, + name = info.name, + avatarUrl = ImageStrategy.imageListToDbUrl(info.thumbnails), + description = info.description, + subscriberCount = info.subscriberCount + ) + } } } 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 371e10934..d1dd835ed 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 @@ -30,6 +30,7 @@ import org.schabi.newpipe.fragments.BaseStateFragment; import org.schabi.newpipe.fragments.OnScrollBelowItemsListener; import org.schabi.newpipe.info_list.InfoListAdapter; import org.schabi.newpipe.info_list.ItemViewMode; +import org.schabi.newpipe.local.subscription.SubscriptionManager; import org.schabi.newpipe.player.playqueue.PlayQueue; import org.schabi.newpipe.ui.components.menu.LongPressAction; import org.schabi.newpipe.ui.components.menu.LongPressable; @@ -293,11 +294,14 @@ public abstract class BaseListFragment extends BaseStateFragment } @Override - public void held(final ChannelInfoItem selectedItem) { + public void held(final ChannelInfoItem item) { + final boolean isSubscribed = new SubscriptionManager(requireContext()) + .blockingIsSubscribed(item.getServiceId(), item.getUrl()); + openLongPressMenuInActivity( requireActivity(), - LongPressable.fromChannelInfoItem(selectedItem), - LongPressAction.fromChannelInfoItem(selectedItem, false) + LongPressable.fromChannelInfoItem(item), + LongPressAction.fromChannelInfoItem(item, isSubscribed) ); } }); diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelFragment.java index be7e6efba..73cfe8920 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelFragment.java @@ -361,13 +361,7 @@ public class ChannelFragment extends BaseStateFragment if (DEBUG) { Log.d(TAG, "No subscription to this channel!"); } - final SubscriptionEntity channel = new SubscriptionEntity(); - channel.setServiceId(info.getServiceId()); - channel.setUrl(info.getUrl()); - channel.setName(info.getName()); - channel.setAvatarUrl(ImageStrategy.imageListToDbUrl(info.getAvatars())); - channel.setDescription(info.getDescription()); - channel.setSubscriberCount(info.getSubscriberCount()); + final SubscriptionEntity channel = SubscriptionEntity.from(info); channelSubscription = null; updateNotifyButton(null); subscribeButtonMonitor = monitorSubscribeButton(mapOnSubscribe(channel)); diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionManager.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionManager.kt index 5cf378cc3..cea29a1a6 100644 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionManager.kt +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionManager.kt @@ -119,6 +119,10 @@ class SubscriptionManager(context: Context) { subscriptionTable.delete(subscriptionEntity) } + fun blockingIsSubscribed(serviceId: Int, url: String): Boolean { + return !subscriptionTable.getSubscription(serviceId, url).isEmpty.blockingGet() + } + /** * Fetches the list of videos for the provided channel and saves them in the database, so that * they will be considered as "old"/"already seen" streams and the user will never be notified 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 e720c0492..084f59687 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 @@ -8,6 +8,7 @@ import androidx.annotation.StringRes import androidx.appcompat.app.AlertDialog import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.PlaylistAdd +import androidx.compose.material.icons.filled.AddCircle import androidx.compose.material.icons.filled.AddToQueue import androidx.compose.material.icons.filled.Cast import androidx.compose.material.icons.filled.Delete @@ -23,6 +24,7 @@ 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.RemoveCircle import androidx.compose.material.icons.filled.Share import androidx.compose.ui.graphics.vector.ImageVector import androidx.core.net.toUri @@ -40,6 +42,7 @@ 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.database.subscription.SubscriptionEntity import org.schabi.newpipe.databinding.DialogEditTextBinding import org.schabi.newpipe.download.DownloadDialog import org.schabi.newpipe.extractor.InfoItem @@ -105,9 +108,10 @@ data class LongPressAction( Rename(19, R.string.rename, Icons.Default.Edit), SetAsPlaylistThumbnail(20, R.string.set_as_playlist_thumbnail, Icons.Default.Image), UnsetPlaylistThumbnail(21, R.string.unset_playlist_thumbnail, Icons.Default.HideImage), - Delete(22, R.string.delete, Icons.Default.Delete), - Unsubscribe(23, R.string.unsubscribe, Icons.Default.Delete), - Remove(24, R.string.play_queue_remove, Icons.Default.Delete); + Subscribe(22, R.string.subscribe_button_title, Icons.Default.AddCircle), + Unsubscribe(23, R.string.unsubscribe, Icons.Default.RemoveCircle), + Delete(24, R.string.delete, Icons.Default.Delete), + Remove(25, R.string.play_queue_remove, Icons.Default.Delete); fun buildAction( enabled: () -> Boolean = { true }, @@ -465,7 +469,7 @@ data class LongPressAction( @JvmStatic fun fromChannelInfoItem( item: ChannelInfoItem, - showUnsubscribe: Boolean + isSubscribed: Boolean ): List { return buildPlayerActionList { ChannelTabPlayQueue(item.serviceId, item.url) } + buildPlayerShuffledActionList { ChannelTabPlayQueue(item.serviceId, item.url) } + @@ -480,7 +484,7 @@ data class LongPressAction( ) } ) + - if (showUnsubscribe) { + if (isSubscribed) { listOf( Type.Unsubscribe.buildAction { context -> withContext(Dispatchers.IO) { @@ -496,7 +500,19 @@ data class LongPressAction( } ) } else { - listOf() + listOf( + Type.Subscribe.buildAction { context -> + withContext(Dispatchers.IO) { + SubscriptionManager(context) + .insertSubscription(SubscriptionEntity.from(item)) + } + Toast.makeText( + context, + context.getString(R.string.subscribed_button_title), + Toast.LENGTH_SHORT + ).show() + } + ) } } diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressMenuSettings.kt b/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressMenuSettings.kt index f6d4621b9..a32606e4d 100644 --- a/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressMenuSettings.kt +++ b/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressMenuSettings.kt @@ -22,6 +22,7 @@ import org.schabi.newpipe.ui.components.menu.LongPressAction.Type.Rename import org.schabi.newpipe.ui.components.menu.LongPressAction.Type.SetAsPlaylistThumbnail import org.schabi.newpipe.ui.components.menu.LongPressAction.Type.Share import org.schabi.newpipe.ui.components.menu.LongPressAction.Type.ShowDetails +import org.schabi.newpipe.ui.components.menu.LongPressAction.Type.Subscribe import org.schabi.newpipe.ui.components.menu.LongPressAction.Type.UnsetPlaylistThumbnail import org.schabi.newpipe.ui.components.menu.LongPressAction.Type.Unsubscribe @@ -43,9 +44,9 @@ fun storeIsHeaderEnabledToSettings(context: Context, enabled: Boolean) { // also be done by clicking on the uploader name in the long press menu header. // PlayWithKodi is only added by default if it is enabled in settings. private val DefaultEnabledActions: List = listOf( - ShowDetails, Enqueue, EnqueueNext, Background, Popup, BackgroundFromHere, - BackgroundShuffled, Download, AddToPlaylist, Share, OpenInBrowser, MarkAsWatched, - Rename, SetAsPlaylistThumbnail, UnsetPlaylistThumbnail, Delete, Unsubscribe, Remove + ShowDetails, Enqueue, EnqueueNext, Background, Popup, BackgroundFromHere, BackgroundShuffled, + Download, AddToPlaylist, Share, OpenInBrowser, MarkAsWatched, Rename, SetAsPlaylistThumbnail, + UnsetPlaylistThumbnail, Subscribe, Unsubscribe, Delete, Remove ) private fun getShowPlayWithKodi(context: Context): Boolean { From 9c558d97e900d862606d1b4b7a0333b116e44435 Mon Sep 17 00:00:00 2001 From: Stypox Date: Mon, 9 Feb 2026 13:42:56 +0100 Subject: [PATCH 79/87] Build LongPressAction lists using builder pattern --- .../ui/components/menu/LongPressMenuTest.kt | 20 +- .../ui/components/menu/LongPressAction.kt | 576 +++++++++--------- .../ui/components/menu/LongPressMenu.kt | 2 +- 3 files changed, 291 insertions(+), 307 deletions(-) diff --git a/app/src/androidTest/java/org/schabi/newpipe/ui/components/menu/LongPressMenuTest.kt b/app/src/androidTest/java/org/schabi/newpipe/ui/components/menu/LongPressMenuTest.kt index caa450e9c..aed3a48d4 100644 --- a/app/src/androidTest/java/org/schabi/newpipe/ui/components/menu/LongPressMenuTest.kt +++ b/app/src/androidTest/java/org/schabi/newpipe/ui/components/menu/LongPressMenuTest.kt @@ -100,7 +100,7 @@ class LongPressMenuTest { */ private fun setLongPressMenu( longPressable: LongPressable = getLongPressable(), - longPressActions: List = LongPressAction.Type.entries.map { it.buildAction { } }, + longPressActions: List = LongPressAction.Type.entries.map { LongPressAction(it) { } }, onDismissRequest: () -> Unit = {}, isHeaderEnabled: Boolean = true, actionArrangement: List = LongPressAction.Type.entries @@ -171,7 +171,7 @@ class LongPressMenuTest { setLongPressMenu( onDismissRequest = { dismissedCount += 1 }, longPressable = getLongPressable(uploader = "UpLoAdEr"), - longPressActions = listOf(ShowChannelDetails.buildAction { pressedCount += 1 }), + longPressActions = listOf(LongPressAction(ShowChannelDetails) { pressedCount += 1 }), actionArrangement = listOf() ) @@ -196,7 +196,7 @@ class LongPressMenuTest { setLongPressMenu( onDismissRequest = { dismissedCount += 1 }, longPressable = getLongPressable(uploader = null), - longPressActions = listOf(ShowChannelDetails.buildAction { pressedCount += 1 }), + longPressActions = listOf(LongPressAction(ShowChannelDetails) { pressedCount += 1 }), actionArrangement = listOf() ) @@ -227,7 +227,7 @@ class LongPressMenuTest { fun testShowChannelDetails4() { setLongPressMenu( longPressable = getLongPressable(uploader = "UpLoAdEr"), - longPressActions = listOf(ShowChannelDetails.buildAction {}), + longPressActions = listOf(LongPressAction(ShowChannelDetails) {}), actionArrangement = listOf(ShowChannelDetails) ) // a ShowChannelDetails button is already present among the actions, @@ -444,7 +444,7 @@ class LongPressMenuTest { expectedShownActions: List ) { setLongPressMenu( - longPressActions = availableActions.map { it.buildAction {} }, + longPressActions = availableActions.map { LongPressAction(it) {} }, // whether the header is enabled or not shouldn't influence the result, so enable it // at random (but still deterministically) isHeaderEnabled = ((expectedShownActions + availableActions).sumOf { it.id } % 2) == 0, @@ -603,8 +603,8 @@ class LongPressMenuTest { fun testEnabledDisabledActions() { setLongPressMenu( longPressActions = listOf( - ShowDetails.buildAction(enabled = { true }) {}, - Enqueue.buildAction(enabled = { false }) {} + LongPressAction(ShowDetails, enabled = { true }) {}, + LongPressAction(Enqueue, enabled = { false }) {} ) ) composeRule.onNodeWithText(ShowDetails.label) @@ -620,7 +620,7 @@ class LongPressMenuTest { var dismissedCount = 0 setLongPressMenu( onDismissRequest = { dismissedCount += 1 }, - longPressActions = listOf(PlayWithKodi.buildAction { pressedCount += 1 }) + longPressActions = listOf(LongPressAction(PlayWithKodi) { pressedCount += 1 }) ) composeRule.onNodeWithText(PlayWithKodi.label) @@ -634,7 +634,7 @@ class LongPressMenuTest { var dismissedCount = 0 setLongPressMenu( onDismissRequest = { dismissedCount += 1 }, - longPressActions = listOf(BackgroundShuffled.buildAction { delay(500) }) + longPressActions = listOf(LongPressAction(BackgroundShuffled) { delay(500) }) ) // test that the loading circle appears while the action is being performed; note that there @@ -659,7 +659,7 @@ class LongPressMenuTest { setLongPressMenu( onDismissRequest = { dismissedCount += 1 }, longPressActions = listOf( - BackgroundShuffled.buildAction { throw Throwable("Whatever") } + LongPressAction(BackgroundShuffled) { throw Throwable("Whatever") } ) ) 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 084f59687..c3455e576 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 @@ -71,11 +71,13 @@ import org.schabi.newpipe.util.NavigationHelper import org.schabi.newpipe.util.external_communication.KoreUtils import org.schabi.newpipe.util.external_communication.ShareUtils +typealias ActionList = MutableList + data class LongPressAction( val type: Type, + val enabled: () -> Boolean = { true }, @MainThread - val action: suspend (context: Context) -> Unit, - val enabled: () -> Boolean = { true } + val action: suspend (context: Context) -> Unit ) { enum class Type( /** @@ -111,45 +113,55 @@ data class LongPressAction( Subscribe(22, R.string.subscribe_button_title, Icons.Default.AddCircle), Unsubscribe(23, R.string.unsubscribe, Icons.Default.RemoveCircle), Delete(24, R.string.delete, Icons.Default.Delete), - Remove(25, R.string.play_queue_remove, Icons.Default.Delete); - - fun buildAction( - enabled: () -> Boolean = { true }, - action: suspend (context: Context) -> Unit - ) = LongPressAction(this, action, enabled) + Remove(25, R.string.play_queue_remove, Icons.Default.Delete) } companion object { - private fun buildPlayerActionList( - queue: suspend (Context) -> PlayQueue - ): List { - return listOf( - // TODO once NewPlayer will be used, make it so that the enabled states of Enqueue - // and EnqueueNext are a State<> that changes realtime based on the actual evolving - // player state - Type.Enqueue.buildAction( - enabled = { PlayerHolder.isPlayQueueReady } - ) { context -> - NavigationHelper.enqueueOnPlayer(context, queue(context)) - }, - Type.EnqueueNext.buildAction( - enabled = { - PlayerHolder.isPlayQueueReady && - (PlayerHolder.queuePosition < PlayerHolder.queueSize - 1) - } - ) { context -> - NavigationHelper.enqueueNextOnPlayer(context, queue(context)) - }, - Type.Background.buildAction { context -> - NavigationHelper.playOnBackgroundPlayer(context, queue(context), true) - }, - Type.Popup.buildAction { context -> - NavigationHelper.playOnPopupPlayer(context, queue(context), true) - }, - Type.Play.buildAction { context -> - NavigationHelper.playOnMainPlayer(context, queue(context), false) - } - ) + + private fun ActionList.addAction( + type: Type, + enabled: () -> Boolean = { true }, + action: suspend (context: Context) -> Unit + ): ActionList { + this.add(LongPressAction(type, enabled, action)) + return this + } + + private fun ActionList.addActionIf( + condition: Boolean, + type: Type, + enabled: () -> Boolean = { true }, + action: suspend (context: Context) -> Unit + ): ActionList { + if (condition) { + addAction(type, enabled, action) + } + return this + } + + private fun ActionList.addPlayerActions(queue: suspend (Context) -> PlayQueue): ActionList { + // TODO once NewPlayer will be used, make it so that the enabled states of Enqueue + // and EnqueueNext are a State<> that changes in real time based on the actual evolving + // player state + addAction(Type.Enqueue, enabled = { PlayerHolder.isPlayQueueReady }) { context -> + NavigationHelper.enqueueOnPlayer(context, queue(context)) + } + addAction(Type.EnqueueNext, enabled = { + PlayerHolder.isPlayQueueReady && + (PlayerHolder.queuePosition < PlayerHolder.queueSize - 1) + }) { context -> + NavigationHelper.enqueueNextOnPlayer(context, queue(context)) + } + addAction(Type.Background) { context -> + NavigationHelper.playOnBackgroundPlayer(context, queue(context), true) + } + addAction(Type.Popup) { context -> + NavigationHelper.playOnPopupPlayer(context, queue(context), true) + } + addAction(Type.Play) { context -> + NavigationHelper.playOnMainPlayer(context, queue(context), false) + } + return this } /** @@ -160,21 +172,27 @@ data class LongPressAction( * fragments, but it's probably not possible to do because of all the different types of * the items involved. But this should be reconsidered if the types will be unified. */ - 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 ActionList.addPlayerFromHereActions( + queueFromHere: (() -> PlayQueue)? + ): ActionList { + if (queueFromHere == null) { + return this + } + addAction(Type.BackgroundFromHere) { context -> + NavigationHelper.playOnBackgroundPlayer(context, queueFromHere(), true) + } + addAction(Type.PopupFromHere) { context -> + NavigationHelper.playOnPopupPlayer(context, queueFromHere(), true) + } + addAction(Type.PlayFromHere) { context -> + NavigationHelper.playOnMainPlayer(context, queueFromHere(), false) + } + return this } - private fun buildPlayerShuffledActionList(queue: suspend (Context) -> PlayQueue): List { + private fun ActionList.addPlayerShuffledActions( + queue: suspend (Context) -> PlayQueue + ): ActionList { val shuffledQueue: suspend (Context) -> PlayQueue = { context -> val q = queue(context) withContext(Dispatchers.IO) { @@ -182,95 +200,89 @@ data class LongPressAction( } q } - return listOf( - Type.BackgroundShuffled.buildAction { context -> - NavigationHelper.playOnBackgroundPlayer(context, shuffledQueue(context), true) - }, - Type.PopupShuffled.buildAction { context -> - NavigationHelper.playOnPopupPlayer(context, shuffledQueue(context), true) - }, - Type.PlayShuffled.buildAction { context -> - NavigationHelper.playOnMainPlayer(context, shuffledQueue(context), false) - } - ) + addAction(Type.BackgroundShuffled) { context -> + NavigationHelper.playOnBackgroundPlayer(context, shuffledQueue(context), true) + } + addAction(Type.PopupShuffled) { context -> + NavigationHelper.playOnPopupPlayer(context, shuffledQueue(context), true) + } + addAction(Type.PlayShuffled) { context -> + NavigationHelper.playOnMainPlayer(context, shuffledQueue(context), false) + } + return this } - 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) - } - ) + private fun ActionList.addShareActions(item: InfoItem): ActionList { + addAction(Type.Share) { context -> + ShareUtils.shareText(context, item.name, item.url, item.thumbnails) + } + addAction(Type.OpenInBrowser) { context -> + ShareUtils.openUrlInBrowser(context, item.url) + } + return this } - 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) - } - ) + private fun ActionList.addShareActions( + name: String, + url: String, + thumbnailUrl: String? + ): ActionList { + addAction(Type.Share) { context -> + ShareUtils.shareText(context, name, url, thumbnailUrl) + } + addAction(Type.OpenInBrowser) { context -> + ShareUtils.openUrlInBrowser(context, url) + } + return this } - private fun buildAdditionalStreamActionList(item: StreamInfoItem): List { - return listOf( - Type.Download.buildAction { context -> - val info = fetchStreamInfoAndSaveToDatabase(context, item.serviceId, item.url) - val downloadDialog = DownloadDialog(context, info) - val fragmentManager = context.findFragmentActivity() - .supportFragmentManager - downloadDialog.show(fragmentManager, "downloadDialog") - }, - Type.AddToPlaylist.buildAction { context -> - LocalPlaylistManager(NewPipeDatabase.getInstance(context)) - .hasPlaylists() - val dialog = withContext(Dispatchers.IO) { - PlaylistDialog.createCorrespondingDialog( - context, - listOf(StreamEntity(item)) - ) - .awaitSingle() - } - val tag = if (dialog is PlaylistAppendDialog) "append" else "create" - dialog.show( - context.findFragmentActivity().supportFragmentManager, - "StreamDialogEntry@${tag}_playlist" - ) - }, - Type.ShowChannelDetails.buildAction { context -> - val uploaderUrl = fetchUploaderUrlIfSparse( - context, - item.serviceId, - item.url, - item.uploaderUrl - ) - NavigationHelper.openChannelFragmentUsingIntent( - context, - item.serviceId, - uploaderUrl, - item.uploaderName - ) - }, - Type.MarkAsWatched.buildAction { context -> - withContext(Dispatchers.IO) { - HistoryRecordManager(context).markAsWatched(item).await() - } + private fun ActionList.addAdditionalStreamActions(item: StreamInfoItem): ActionList { + addAction(Type.Download) { context -> + val info = fetchStreamInfoAndSaveToDatabase(context, item.serviceId, item.url) + val downloadDialog = DownloadDialog(context, info) + val fragmentManager = context.findFragmentActivity() + .supportFragmentManager + downloadDialog.show(fragmentManager, "downloadDialog") + } + addAction(Type.AddToPlaylist) { context -> + LocalPlaylistManager(NewPipeDatabase.getInstance(context)) + .hasPlaylists() + val dialog = withContext(Dispatchers.IO) { + PlaylistDialog.createCorrespondingDialog(context, listOf(StreamEntity(item))) + .awaitSingle() } - ) + - if (KoreUtils.isServiceSupportedByKore(item.serviceId)) { - listOf( - Type.PlayWithKodi.buildAction( - enabled = { KoreUtils.isServiceSupportedByKore(item.serviceId) } - ) { context -> KoreUtils.playWithKore(context, item.url.toUri()) } - ) - } else { - listOf() + val tag = if (dialog is PlaylistAppendDialog) "append" else "create" + dialog.show( + context.findFragmentActivity().supportFragmentManager, + "StreamDialogEntry@${tag}_playlist" + ) + } + addAction(Type.ShowChannelDetails) { context -> + val uploaderUrl = fetchUploaderUrlIfSparse( + context, + item.serviceId, + item.url, + item.uploaderUrl + ) + NavigationHelper.openChannelFragmentUsingIntent( + context, + item.serviceId, + uploaderUrl, + item.uploaderName + ) + } + addAction(Type.MarkAsWatched) { context -> + withContext(Dispatchers.IO) { + HistoryRecordManager(context).markAsWatched(item).await() } + } + if (KoreUtils.isServiceSupportedByKore(item.serviceId)) { + addAction( + Type.PlayWithKodi, + enabled = { KoreUtils.isServiceSupportedByKore(item.serviceId) } + ) { context -> KoreUtils.playWithKore(context, item.url.toUri()) } + } + return this } /** @@ -281,18 +293,19 @@ data class LongPressAction( fun fromStreamInfoItem( item: StreamInfoItem, queueFromHere: (() -> PlayQueue)? - ): List { - return buildPlayerActionList { context -> fetchItemInfoIfSparse(context, item) } + - (queueFromHere?.let { buildPlayerFromHereActionList(queueFromHere) } ?: listOf()) + - buildShareActionList(item) + - buildAdditionalStreamActionList(item) + ): ActionList { + return ArrayList() + .addPlayerActions { context -> fetchItemInfoIfSparse(context, item) } + .addPlayerFromHereActions(queueFromHere) + .addShareActions(item) + .addAdditionalStreamActions(item) } @JvmStatic fun fromStreamEntity( item: StreamEntity, queueFromHere: (() -> PlayQueue)? - ): List { + ): ActionList { // 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) @@ -304,55 +317,46 @@ data class LongPressAction( item: PlayQueueItem, playQueueFromWhichToDelete: PlayQueue, showDetails: Boolean - ): List { + ): ActionList { // TODO decide if it's fine to just convert to StreamInfoItem here (it poses an // unnecessary dependency on the extractor, when we want to just look at data; maybe // using something like LongPressable would work) val streamInfoItem = item.toStreamInfoItem() - return buildShareActionList(streamInfoItem) + - buildAdditionalStreamActionList(streamInfoItem) + - if (showDetails) { - listOf( - Type.ShowDetails.buildAction { context -> - // playQueue is null since we don't want any queue change - NavigationHelper.openVideoDetail( - context, - item.serviceId, - item.url, - item.title, - null, - false - ) - } + return ArrayList() + .addShareActions(streamInfoItem) + .addAdditionalStreamActions(streamInfoItem) + .addActionIf(showDetails, Type.ShowDetails) { context -> + // playQueue is null since we don't want any queue change + NavigationHelper.openVideoDetail( + context, + item.serviceId, + item.url, + item.title, + null, + false ) - } else { - listOf() - } + - listOf( - Type.Remove.buildAction { - val index = playQueueFromWhichToDelete.indexOf(item) - playQueueFromWhichToDelete.remove(index) - } - ) + } + .addAction(Type.Remove) { + val index = playQueueFromWhichToDelete.indexOf(item) + playQueueFromWhichToDelete.remove(index) + } } @JvmStatic fun fromStreamStatisticsEntry( item: StreamStatisticsEntry, queueFromHere: (() -> PlayQueue)? - ): List { - return fromStreamInfoItem(item.streamEntity.toStreamInfoItem(), queueFromHere) + - listOf( - Type.Delete.buildAction { context -> - withContext(Dispatchers.IO) { - HistoryRecordManager(context) - .deleteStreamHistoryAndState(item.streamId) - .await() - } - Toast.makeText(context, R.string.one_item_deleted, Toast.LENGTH_SHORT) - .show() + ): ActionList { + return fromStreamInfoItem(item.streamEntity.toStreamInfoItem(), queueFromHere) + .addAction(Type.Delete) { context -> + withContext(Dispatchers.IO) { + HistoryRecordManager(context) + .deleteStreamHistoryAndState(item.streamId) + .await() } - ) + Toast.makeText(context, R.string.one_item_deleted, Toast.LENGTH_SHORT) + .show() + } } /** @@ -368,23 +372,21 @@ data class LongPressAction( queueFromHere: (() -> PlayQueue)?, playlistId: Long, onDelete: Runnable - ): List { - return fromStreamInfoItem(item.streamEntity.toStreamInfoItem(), queueFromHere) + - listOf( - Type.SetAsPlaylistThumbnail.buildAction { context -> - withContext(Dispatchers.IO) { - LocalPlaylistManager(NewPipeDatabase.getInstance(context)) - .changePlaylistThumbnail(playlistId, item.streamEntity.uid, true) - .awaitSingle() - } - Toast.makeText( - context, - R.string.playlist_thumbnail_change_success, - Toast.LENGTH_SHORT - ).show() - }, - Type.Delete.buildAction { onDelete.run() } - ) + ): ActionList { + return fromStreamInfoItem(item.streamEntity.toStreamInfoItem(), queueFromHere) + .addAction(Type.SetAsPlaylistThumbnail) { context -> + withContext(Dispatchers.IO) { + LocalPlaylistManager(NewPipeDatabase.getInstance(context)) + .changePlaylistThumbnail(playlistId, item.streamEntity.uid, true) + .awaitSingle() + } + Toast.makeText( + context, + R.string.playlist_thumbnail_change_success, + Toast.LENGTH_SHORT + ).show() + } + .addAction(Type.Delete) { onDelete.run() } } /** @@ -395,55 +397,55 @@ data class LongPressAction( item: PlaylistMetadataEntry, isThumbnailPermanent: Boolean, onDelete: Runnable - ): List { - return buildPlayerActionList { LocalPlaylistPlayQueue(item) } + - buildPlayerShuffledActionList { LocalPlaylistPlayQueue(item) } + - listOf( - Type.Rename.buildAction { context -> - // open the dialog and wait for its completion in the coroutine - val newName = suspendCoroutine { continuation -> - val dialogBinding = DialogEditTextBinding.inflate( - context.findFragmentActivity().layoutInflater - ) - dialogBinding.dialogEditText.setHint(R.string.name) - dialogBinding.dialogEditText.setInputType(InputType.TYPE_CLASS_TEXT) - dialogBinding.dialogEditText.setText(item.orderingName) - AlertDialog.Builder(context) - .setView(dialogBinding.getRoot()) - .setPositiveButton(R.string.rename_playlist) { _, _ -> - continuation.resume(dialogBinding.dialogEditText.getText().toString()) - } - .setNegativeButton(R.string.cancel) { _, _ -> - continuation.resume(null) - } - .setOnCancelListener { - continuation.resume(null) - } - .show() - } ?: return@buildAction + ): ActionList { + return ArrayList() + .addPlayerActions { LocalPlaylistPlayQueue(item) } + .addPlayerShuffledActions { LocalPlaylistPlayQueue(item) } + .addAction(Type.Rename) { context -> + // open the dialog and wait for its completion in the coroutine + val newName = suspendCoroutine { continuation -> + val dialogBinding = DialogEditTextBinding.inflate( + context.findFragmentActivity().layoutInflater + ) + dialogBinding.dialogEditText.setHint(R.string.name) + dialogBinding.dialogEditText.setInputType(InputType.TYPE_CLASS_TEXT) + dialogBinding.dialogEditText.setText(item.orderingName) + AlertDialog.Builder(context) + .setView(dialogBinding.getRoot()) + .setPositiveButton(R.string.rename_playlist) { _, _ -> + continuation.resume(dialogBinding.dialogEditText.getText().toString()) + } + .setNegativeButton(R.string.cancel) { _, _ -> + continuation.resume(null) + } + .setOnCancelListener { + continuation.resume(null) + } + .show() + } ?: return@addAction - withContext(Dispatchers.IO) { + withContext(Dispatchers.IO) { + LocalPlaylistManager(NewPipeDatabase.getInstance(context)) + .renamePlaylist(item.uid, newName) + .awaitSingle() + } + } + .addAction( + Type.UnsetPlaylistThumbnail, + enabled = { isThumbnailPermanent } + ) { context -> + withContext(Dispatchers.IO) { + val localPlaylistManager = LocalPlaylistManager(NewPipeDatabase.getInstance(context)) - .renamePlaylist(item.uid, newName) - .awaitSingle() - } - }, - Type.UnsetPlaylistThumbnail.buildAction( - enabled = { isThumbnailPermanent } - ) { context -> - withContext(Dispatchers.IO) { - val localPlaylistManager = - LocalPlaylistManager(NewPipeDatabase.getInstance(context)) - val thumbnailStreamId = localPlaylistManager - .getAutomaticPlaylistThumbnailStreamId(item.uid) - .awaitFirst() - localPlaylistManager - .changePlaylistThumbnail(item.uid, thumbnailStreamId, false) - .awaitSingle() - } - }, - Type.Delete.buildAction { onDelete.run() } - ) + val thumbnailStreamId = localPlaylistManager + .getAutomaticPlaylistThumbnailStreamId(item.uid) + .awaitFirst() + localPlaylistManager + .changePlaylistThumbnail(item.uid, thumbnailStreamId, false) + .awaitSingle() + } + } + .addAction(Type.Delete) { onDelete.run() } } /** @@ -453,74 +455,56 @@ data class LongPressAction( fun fromPlaylistRemoteEntity( item: PlaylistRemoteEntity, onDelete: Runnable - ): List { - return buildPlayerActionList { PlaylistPlayQueue(item.serviceId, item.url) } + - buildPlayerShuffledActionList { PlaylistPlayQueue(item.serviceId, item.url) } + - buildShareActionList( - item.orderingName ?: "", - item.orderingName ?: "", - item.thumbnailUrl - ) + - listOf( - Type.Delete.buildAction { onDelete.run() } - ) + ): ActionList { + return ArrayList() + .addPlayerActions { PlaylistPlayQueue(item.serviceId, item.url) } + .addPlayerShuffledActions { PlaylistPlayQueue(item.serviceId, item.url) } + .addShareActions(item.orderingName ?: "", item.url ?: "", item.thumbnailUrl) + .addAction(Type.Delete) { onDelete.run() } } @JvmStatic fun fromChannelInfoItem( item: ChannelInfoItem, isSubscribed: Boolean - ): List { - return buildPlayerActionList { ChannelTabPlayQueue(item.serviceId, item.url) } + - buildPlayerShuffledActionList { ChannelTabPlayQueue(item.serviceId, item.url) } + - buildShareActionList(item) + - listOf( - Type.ShowChannelDetails.buildAction { context -> - NavigationHelper.openChannelFragmentUsingIntent( - context, - item.serviceId, - item.url, - item.name - ) + ): ActionList { + return ArrayList() + .addPlayerActions { ChannelTabPlayQueue(item.serviceId, item.url) } + .addPlayerShuffledActions { ChannelTabPlayQueue(item.serviceId, item.url) } + .addShareActions(item) + .addAction(Type.ShowChannelDetails) { context -> + NavigationHelper.openChannelFragmentUsingIntent( + context, + item.serviceId, + item.url, + item.name + ) + } + .addActionIf(isSubscribed, Type.Unsubscribe) { context -> + withContext(Dispatchers.IO) { + SubscriptionManager(context) + .deleteSubscription(item.serviceId, item.url) + .await() } - ) + - if (isSubscribed) { - listOf( - Type.Unsubscribe.buildAction { context -> - withContext(Dispatchers.IO) { - SubscriptionManager(context) - .deleteSubscription(item.serviceId, item.url) - .await() - } - Toast.makeText( - context, - context.getString(R.string.channel_unsubscribed), - Toast.LENGTH_SHORT - ).show() - } - ) - } else { - listOf( - Type.Subscribe.buildAction { context -> - withContext(Dispatchers.IO) { - SubscriptionManager(context) - .insertSubscription(SubscriptionEntity.from(item)) - } - Toast.makeText( - context, - context.getString(R.string.subscribed_button_title), - Toast.LENGTH_SHORT - ).show() - } - ) + Toast.makeText(context, R.string.channel_unsubscribed, Toast.LENGTH_SHORT) + .show() + } + .addActionIf(!isSubscribed, Type.Subscribe) { context -> + withContext(Dispatchers.IO) { + SubscriptionManager(context) + .insertSubscription(SubscriptionEntity.from(item)) + } + Toast.makeText(context, R.string.subscribed_button_title, Toast.LENGTH_SHORT) + .show() } } @JvmStatic - fun fromPlaylistInfoItem(item: PlaylistInfoItem): List { - return buildPlayerActionList { PlaylistPlayQueue(item.serviceId, item.url) } + - buildPlayerShuffledActionList { PlaylistPlayQueue(item.serviceId, item.url) } + - buildShareActionList(item) + fun fromPlaylistInfoItem(item: PlaylistInfoItem): ActionList { + return ArrayList() + .addPlayerActions { PlaylistPlayQueue(item.serviceId, item.url) } + .addPlayerShuffledActions { PlaylistPlayQueue(item.serviceId, item.url) } + .addShareActions(item) } } } 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 9d442899a..4a3c4a57e 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 @@ -757,7 +757,7 @@ private fun LongPressMenuPreview( onUploaderClick = {}, actions = LongPressAction.Type.entries // disable Enqueue actions just to show it off - .map { t -> t.buildAction({ t != EnqueueNext }) { } }, + .map { t -> LongPressAction(t, { t != EnqueueNext }) {} }, runActionAndDismiss = {} ) } From d075539a8a3b650014761448a821b24c2c0caa3b Mon Sep 17 00:00:00 2001 From: Stypox Date: Mon, 9 Feb 2026 15:10:35 +0100 Subject: [PATCH 80/87] Add Context.findFragmentManager() extension function --- app/src/main/java/org/schabi/newpipe/ktx/Context.kt | 5 +++++ .../schabi/newpipe/ui/components/items/ItemList.kt | 7 +++---- .../newpipe/ui/components/menu/LongPressAction.kt | 12 +++--------- .../org/schabi/newpipe/util/NavigationHelper.java | 3 +-- 4 files changed, 12 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/org/schabi/newpipe/ktx/Context.kt b/app/src/main/java/org/schabi/newpipe/ktx/Context.kt index f2f4e9613..69f6b7358 100644 --- a/app/src/main/java/org/schabi/newpipe/ktx/Context.kt +++ b/app/src/main/java/org/schabi/newpipe/ktx/Context.kt @@ -3,6 +3,7 @@ package org.schabi.newpipe.ktx import android.content.Context import android.content.ContextWrapper import androidx.fragment.app.FragmentActivity +import androidx.fragment.app.FragmentManager tailrec fun Context.findFragmentActivity(): FragmentActivity { return when (this) { @@ -11,3 +12,7 @@ tailrec fun Context.findFragmentActivity(): FragmentActivity { else -> throw IllegalStateException("Unable to find FragmentActivity") } } + +fun Context.findFragmentManager(): FragmentManager { + return findFragmentActivity().supportFragmentManager +} 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 977be3b53..5af58aa17 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 @@ -18,7 +18,7 @@ import org.schabi.newpipe.extractor.InfoItem import org.schabi.newpipe.extractor.playlist.PlaylistInfoItem import org.schabi.newpipe.extractor.stream.StreamInfoItem import org.schabi.newpipe.info_list.ItemViewMode -import org.schabi.newpipe.ktx.findFragmentActivity +import org.schabi.newpipe.ktx.findFragmentManager import org.schabi.newpipe.player.playqueue.PlayQueue import org.schabi.newpipe.ui.components.common.LazyColumnThemedScrollbar import org.schabi.newpipe.ui.components.items.playlist.PlaylistListItem @@ -41,11 +41,10 @@ fun ItemList( val context = LocalContext.current val onClick = remember { { item: InfoItem -> - val fragmentManager = context.findFragmentActivity().supportFragmentManager if (item is StreamInfoItem) { NavigationHelper.openVideoDetailFragment( context, - fragmentManager, + context.findFragmentManager(), item.serviceId, item.url, item.name, @@ -54,7 +53,7 @@ fun ItemList( ) } else if (item is PlaylistInfoItem) { NavigationHelper.openPlaylistFragment( - fragmentManager, + context.findFragmentManager(), item.serviceId, item.url, item.name 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 c3455e576..5bf536fa6 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 @@ -50,7 +50,7 @@ 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.ktx.findFragmentManager import org.schabi.newpipe.local.dialog.PlaylistDialog import org.schabi.newpipe.local.history.HistoryRecordManager import org.schabi.newpipe.local.playlist.LocalPlaylistManager @@ -240,9 +240,7 @@ data class LongPressAction( addAction(Type.Download) { context -> val info = fetchStreamInfoAndSaveToDatabase(context, item.serviceId, item.url) val downloadDialog = DownloadDialog(context, info) - val fragmentManager = context.findFragmentActivity() - .supportFragmentManager - downloadDialog.show(fragmentManager, "downloadDialog") + downloadDialog.show(context.findFragmentManager(), "downloadDialog") } addAction(Type.AddToPlaylist) { context -> LocalPlaylistManager(NewPipeDatabase.getInstance(context)) @@ -251,11 +249,7 @@ data class LongPressAction( PlaylistDialog.createCorrespondingDialog(context, listOf(StreamEntity(item))) .awaitSingle() } - val tag = if (dialog is PlaylistAppendDialog) "append" else "create" - dialog.show( - context.findFragmentActivity().supportFragmentManager, - "StreamDialogEntry@${tag}_playlist" - ) + dialog.show(context.findFragmentManager(), "addToPlaylistDialog") } addAction(Type.ShowChannelDetails) { context -> val uploaderUrl = fetchUploaderUrlIfSparse( diff --git a/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java b/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java index 0a7906b8d..f89148b38 100644 --- a/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java +++ b/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java @@ -493,8 +493,7 @@ public final class NavigationHelper { return; } try { - final var activity = ContextKt.findFragmentActivity(context); - openChannelFragment(activity.getSupportFragmentManager(), comment.getServiceId(), + openChannelFragment(ContextKt.findFragmentManager(context), comment.getServiceId(), comment.getUploaderUrl(), comment.getUploaderName()); } catch (final Exception e) { ErrorUtil.showUiErrorSnackbar(context, "Opening channel fragment", e); From 3aa5edc453ded2f68d62828dd8bac2a7b4cf7e3a Mon Sep 17 00:00:00 2001 From: Stypox Date: Mon, 9 Feb 2026 22:48:45 +0100 Subject: [PATCH 81/87] Add more documentation Especially in and around LongPressAction.kt Also add some logging / Toast when fetchAllAndShuffle() has to stop early --- .../fragments/list/BaseListFragment.java | 8 +- .../local/bookmark/BookmarkFragment.java | 2 + .../local/playlist/LocalPlaylistFragment.java | 5 + .../local/subscription/SubscriptionManager.kt | 5 + .../player/playqueue/ChannelTabPlayQueue.java | 34 ++- .../playqueue/LocalPlaylistPlayQueue.kt | 3 + .../newpipe/player/playqueue/PlayQueue.kt | 32 ++- .../settings/VideoAudioSettingsFragment.java | 2 + .../ui/components/menu/LongPressAction.kt | 233 +++++++++++++----- app/src/main/res/values/strings.xml | 1 + 10 files changed, 250 insertions(+), 75 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 d1dd835ed..faae6d429 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 @@ -295,6 +295,8 @@ public abstract class BaseListFragment extends BaseStateFragment @Override public void held(final ChannelInfoItem item) { + // Note: this does a blocking I/O call to the database. Use coroutines when + // migrating to Kotlin/Compose instead. final boolean isSubscribed = new SubscriptionManager(requireContext()) .blockingIsSubscribed(item.getServiceId(), item.getUrl()); @@ -338,9 +340,9 @@ public abstract class BaseListFragment extends BaseStateFragment /** * @param item an item in the list, from which the built queue should start - * @return a builder for a queue containing all of the items in this list, with the queue index - * set to the item passed as parameter; return {@code null} if no "start playing from here" - * options should be shown + * @return a builder for a queue containing all of the streams items in this list, with the + * queue index set to the stream item passed as parameter; return {@code null} if no "start + * playing from here" options should be shown */ @Nullable protected Function0 getPlayQueueStartingAt(@NonNull final StreamInfoItem item) { 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 b7a3be6d4..a018ffef2 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 @@ -491,6 +491,8 @@ public final class BookmarkFragment extends BaseLocalListFragment { + /** + * The channel tab link handler. + * If null, it indicates that we have yet to fetch the channel info and choose a tab from it. + */ @Nullable - ListLinkHandler linkHandler; + ListLinkHandler tabHandler; public ChannelTabPlayQueue(final int serviceId, - final ListLinkHandler linkHandler, + final ListLinkHandler tabHandler, final Page nextPage, final List streams, final int index) { - super(serviceId, linkHandler.getUrl(), nextPage, streams, index); - this.linkHandler = linkHandler; + super(serviceId, tabHandler.getUrl(), nextPage, streams, index); + this.tabHandler = tabHandler; } public ChannelTabPlayQueue(final int serviceId, @@ -36,11 +40,16 @@ public final class ChannelTabPlayQueue extends AbstractInfoPlayQueue { - linkHandler = channelInfo.getTabs() + tabHandler = channelInfo.getTabs() .stream() .filter(ChannelTabHelper::isStreamsTab) .findFirst() @@ -62,20 +72,22 @@ public final class ChannelTabPlayQueue extends AbstractInfoPlayQueue +/** + * An action that the user can perform in the long press menu of an item. What matters are lists of + * [LongPressAction], i.e. [ActionList]s, which represent a set of actions that are *applicable* for + * an item. + * + * If an action is present in an [ActionList] it does not necessarily imply that it will be shown to + * the user in the long press menu, because the user may decide which actions to show with the + * `LongPressMenuEditor`. + * + * Also, an [ActionList] may contain actions that are temporarily unavailable (e.g. enqueueing when + * no player is running; see [enabled]), but **should not** contain actions that are not + * *applicable* for an item (i.e. they wouldn't make sense). That's why you will see some actions + * being marked as not [enabled] and some not being included at all in the [ActionList] builders. + * + * @param type the [Type] of the action, describing how to identify it and represent it visually + * @param enabled a lambda that the UI layer can call at any time to check if the action is + * temporarily unavailable (e.g. the enqueue action is available only if the player is running) + * @param action will be called **at most once** to actually perform the action upon selection by + * the user; will be run on [Dispatchers.Main] (i.e. the UI/main thread) and should perform any + * I/O-heavy computation using `withContext(Dispatchers.IO)`; since this is a `suspend` function, it + * is ok if it takes a while to complete, and a loading spinner will be shown in the meantime + */ data class LongPressAction( val type: Type, val enabled: () -> Boolean = { true }, @MainThread val action: suspend (context: Context) -> Unit ) { + /** + * @param id a unique ID that allows saving and restoring a list of action types from settings. + * **MUST NOT CHANGE ACROSS APP VERSIONS!** + * @param label a string label to show in the action's button + * @param icon an icon to show in the action's button + */ enum class Type( - /** - * A unique ID that allows saving and restoring a list of action types from settings. - * MUST NOT CHANGE ACROSS APP VERSIONS! - */ val id: Int, @StringRes val label: Int, val icon: ImageVector @@ -118,6 +142,9 @@ data class LongPressAction( companion object { + /** + * Builds and adds a [LongPressAction] to the list. + */ private fun ActionList.addAction( type: Type, enabled: () -> Boolean = { true }, @@ -127,6 +154,10 @@ data class LongPressAction( return this } + /** + * Builds and adds a [LongPressAction] to the list, but **only if [condition] is `true`**. + * The difference between [condition] and [enabled] is explained in [LongPressAction]. + */ private fun ActionList.addActionIf( condition: Boolean, type: Type, @@ -139,6 +170,10 @@ data class LongPressAction( return this } + /** + * Add the typical player actions that can be performed on any [queue] of streams: + * enqueueing on an existing player and starting one among the three player types. + */ private fun ActionList.addPlayerActions(queue: suspend (Context) -> PlayQueue): ActionList { // TODO once NewPlayer will be used, make it so that the enabled states of Enqueue // and EnqueueNext are a State<> that changes in real time based on the actual evolving @@ -165,12 +200,18 @@ data class LongPressAction( } /** - * Instead of queueFromHere, this function could possibly take a - * `() -> List` plus the `StreamInfoItem/StreamEntity/...` - * that was long-pressed, and take care of searching through the list to find the item - * index, and finally take care of building the queue. It would deduplicate some code in - * fragments, but it's probably not possible to do because of all the different types of - * the items involved. But this should be reconsidered if the types will be unified. + * Add player actions that can be performed when the item (the one that the actions refer + * to), is also part of a list which can be played starting from said item, i.e. "play list + * starting from here" actions. + * + * *Note: instead of [queueFromHere], this function could possibly take a + * `() -> List` plus the `StreamInfoItem/StreamEntity/...` + * that was long-pressed, and take care of searching through the list to find the item + * index, and finally take care of building the queue. It would deduplicate some code in + * fragments, but it's probably not possible to do because of all the different types of + * the items involved. But this should be reconsidered if the types will be unified.* + * + * @param queueFromHere if `null`, this will not modify the list */ private fun ActionList.addPlayerFromHereActions( queueFromHere: (() -> PlayQueue)? @@ -190,6 +231,10 @@ data class LongPressAction( return this } + /** + * Add player actions that make sense only when [queue] (generally) contains multiple + * streams (e.g. playlists, channels), i.e. "play item's streams shuffled" actions. + */ private fun ActionList.addPlayerShuffledActions( queue: suspend (Context) -> PlayQueue ): ActionList { @@ -212,6 +257,10 @@ data class LongPressAction( return this } + /** + * Add actions that allow sharing an [InfoItem] externally. + * Also see the other overload for a more generic version. + */ private fun ActionList.addShareActions(item: InfoItem): ActionList { addAction(Type.Share) { context -> ShareUtils.shareText(context, item.name, item.url, item.thumbnails) @@ -222,6 +271,10 @@ data class LongPressAction( return this } + /** + * Add actions that allow sharing externally an item with [name], [url] and optionally + * [thumbnailUrl]. Also see the other overload for an [InfoItem]-specific version. + */ private fun ActionList.addShareActions( name: String, url: String, @@ -236,6 +289,10 @@ data class LongPressAction( return this } + /** + * Add actions that can be performed on any stream item, be it a remote stream item or a + * stream item stored in history. + */ private fun ActionList.addAdditionalStreamActions(item: StreamInfoItem): ActionList { addAction(Type.Download) { context -> val info = fetchStreamInfoAndSaveToDatabase(context, item.serviceId, item.url) @@ -271,6 +328,7 @@ data class LongPressAction( } } if (KoreUtils.isServiceSupportedByKore(item.serviceId)) { + // offer the option to play with Kodi only if Kore supports the item service addAction( Type.PlayWithKodi, enabled = { KoreUtils.isServiceSupportedByKore(item.serviceId) } @@ -280,8 +338,13 @@ 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". + * *Note: if and when stream item representations will be unified, this should be removed in + * favor a single unified `fromStreamItem` option.* + * + * @param item the remote stream item for which to create a list of possible actions + * @param queueFromHere returns a play queue containing all of the stream items in the list + * that contains [item], with the queue index pointing to [item]; if `null`, no "start + * playing from here" options will be included */ @JvmStatic fun fromStreamInfoItem( @@ -295,47 +358,32 @@ data class LongPressAction( .addAdditionalStreamActions(item) } + /** + * *Note: if and when stream item representations will be unified, this should be removed in + * favor a single unified `fromStreamItem` option.* + * + * @param item the local stream item for which to create a list of possible actions + * @param queueFromHere returns a play queue containing all of the stream items in the list + * that contains [item], with the queue index pointing to [item]; if `null`, no "start + * playing from here" options will be included + */ @JvmStatic fun fromStreamEntity( item: StreamEntity, queueFromHere: (() -> PlayQueue)? ): ActionList { - // 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(), queueFromHere) } - @JvmStatic - fun fromPlayQueueItem( - item: PlayQueueItem, - playQueueFromWhichToDelete: PlayQueue, - showDetails: Boolean - ): ActionList { - // TODO decide if it's fine to just convert to StreamInfoItem here (it poses an - // unnecessary dependency on the extractor, when we want to just look at data; maybe - // using something like LongPressable would work) - val streamInfoItem = item.toStreamInfoItem() - return ArrayList() - .addShareActions(streamInfoItem) - .addAdditionalStreamActions(streamInfoItem) - .addActionIf(showDetails, Type.ShowDetails) { context -> - // playQueue is null since we don't want any queue change - NavigationHelper.openVideoDetail( - context, - item.serviceId, - item.url, - item.title, - null, - false - ) - } - .addAction(Type.Remove) { - val index = playQueueFromWhichToDelete.indexOf(item) - playQueueFromWhichToDelete.remove(index) - } - } - + /** + * *Note: if and when stream item representations will be unified, this should be removed in + * favor a single unified `fromStreamItem` option.* + * + * @param item the history stream item for which to create a list of possible actions + * @param queueFromHere returns a play queue containing all of the stream items in the list + * that contains [item], with the queue index pointing to [item]; if `null`, no "start + * playing from here" options will be included + */ @JvmStatic fun fromStreamStatisticsEntry( item: StreamStatisticsEntry, @@ -354,11 +402,23 @@ data class LongPressAction( } /** - * TODO [onDelete] is still passed externally to allow the calling fragment to debounce - * many deletions into a single database transaction, improving performance. This is - * however a bad pattern (which has already led to many bugs in NewPipe). Once we migrate - * the playlist fragment to Compose, we should make the database updates immediately, and - * use `collectAsLazyPagingItems()` to load data in chunks and thus avoid slowdowns. + * *Note: if and when stream item representations will be unified, this should be removed in + * favor a single unified `fromStreamItem` option.* + * + * *Note: [onDelete] is still passed externally to allow the calling fragment to debounce + * many deletions into a single database transaction, improving performance. This is + * however a bad pattern (which has already led to many bugs in NewPipe). Once we migrate + * the playlist fragment to Compose, we should make the database updates immediately, and + * use `collectAsLazyPagingItems()` to load data in chunks and thus avoid slowdowns.* + * + * @param item the playlist stream item for which to create a list of possible actions + * @param queueFromHere returns a play queue containing all of the stream items in the list + * that contains [item], with the queue index pointing to [item]; if `null`, no "start + * playing from here" options will be included + * @param playlistId the playlist this stream belongs to, allows setting this item's + * thumbnail as the playlist thumbnail + * @param onDelete the action to run when the user presses on [Type.Delete], see above for + * why it is here and why it is bad */ @JvmStatic fun fromPlaylistStreamEntry( @@ -384,7 +444,60 @@ data class LongPressAction( } /** - * TODO see [fromPlaylistStreamEntry] for why [onDelete] is here and why it's bad + * *Note: if and when stream item representations will be unified, this should be removed in + * favor a single unified `fromStreamItem` option.* + * + * @param item the play queue stream item for which to create a list of possible actions + * @param playQueueFromWhichToDelete the play queue containing [item], and from which [item] + * should be removed in case the user presses the [Type.Remove] action. + * @param showDetails whether to include the option to show stream details, which only makes + * sense if the user is not already on that stream's details page + */ + @JvmStatic + fun fromPlayQueueItem( + item: PlayQueueItem, + playQueueFromWhichToDelete: PlayQueue, + showDetails: Boolean + ): ActionList { + val streamInfoItem = item.toStreamInfoItem() + return ArrayList() + .addShareActions(streamInfoItem) + .addAdditionalStreamActions(streamInfoItem) + .addActionIf(showDetails, Type.ShowDetails) { context -> + // playQueue is null since we don't want any queue change + NavigationHelper.openVideoDetail( + context, + item.serviceId, + item.url, + item.title, + null, + false + ) + } + .addAction(Type.Remove) { + val index = playQueueFromWhichToDelete.indexOf(item) + playQueueFromWhichToDelete.remove(index) + } + } + + /** + * @param item the remote playlist item (e.g. appearing in searches or channel tabs, not the + * remote playlists in bookmarks) for which to create a list of possible actions + */ + @JvmStatic + fun fromPlaylistInfoItem(item: PlaylistInfoItem): ActionList { + return ArrayList() + .addPlayerActions { PlaylistPlayQueue(item.serviceId, item.url) } + .addPlayerShuffledActions { PlaylistPlayQueue(item.serviceId, item.url) } + .addShareActions(item) + } + + /** + * @param item the local playlist item for which to create a list of possible actions + * @param isThumbnailPermanent if true, the playlist's thumbnail was set by the user, and + * can thus also be unset by the user + * @param onDelete the action to run when the user presses on [Type.Delete], see + * [fromPlaylistStreamEntry] for why it is here and why it is bad */ @JvmStatic fun fromPlaylistMetadataEntry( @@ -443,7 +556,10 @@ data class LongPressAction( } /** - * TODO see [fromPlaylistStreamEntry] for why [onDelete] is here and why it's bad + * @param item the remote bookmarked playlist item for which to create a list of possible + * actions + * @param onDelete the action to run when the user presses on [Type.Delete], see + * [fromPlaylistStreamEntry] for why it is here and why it is bad */ @JvmStatic fun fromPlaylistRemoteEntity( @@ -457,6 +573,11 @@ data class LongPressAction( .addAction(Type.Delete) { onDelete.run() } } + /** + * @param item the remote channel item for which to create a list of possible actions + * @param isSubscribed used to decide whether to show the [Type.Subscribe] or + * [Type.Unsubscribe] button + */ @JvmStatic fun fromChannelInfoItem( item: ChannelInfoItem, @@ -492,13 +613,5 @@ data class LongPressAction( .show() } } - - @JvmStatic - fun fromPlaylistInfoItem(item: PlaylistInfoItem): ActionList { - return ArrayList() - .addPlayerActions { PlaylistPlayQueue(item.serviceId, item.url) } - .addPlayerShuffledActions { PlaylistPlayQueue(item.serviceId, item.url) } - .addShareActions(item) - } } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c166a9c52..e68cd351f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -917,4 +917,5 @@ Popup\nshuffled Play\nshuffled %d items in playlist + Stopped loading after %1$d pages and %2$d items to avoid rate limits From 3fc4bc9cd3a72d2109c289949dcb415eebffee0e Mon Sep 17 00:00:00 2001 From: Stypox Date: Tue, 10 Feb 2026 11:37:46 +0100 Subject: [PATCH 82/87] Add tooltips for long press menu icons --- .../ui/components/menu/LongPressMenuTest.kt | 6 +- .../java/org/schabi/newpipe/ui/Toolbar.kt | 16 +-- .../components/common/ScaffoldWithToolbar.kt | 13 +- .../newpipe/ui/components/common/Tooltip.kt | 84 ++++++++++++ .../ui/components/menu/LongPressMenu.kt | 123 ++++++++++-------- .../ui/components/menu/LongPressMenuEditor.kt | 13 +- app/src/main/res/values/strings.xml | 1 - 7 files changed, 174 insertions(+), 82 deletions(-) create mode 100644 app/src/main/java/org/schabi/newpipe/ui/components/common/Tooltip.kt diff --git a/app/src/androidTest/java/org/schabi/newpipe/ui/components/menu/LongPressMenuTest.kt b/app/src/androidTest/java/org/schabi/newpipe/ui/components/menu/LongPressMenuTest.kt index aed3a48d4..f45ed5cc0 100644 --- a/app/src/androidTest/java/org/schabi/newpipe/ui/components/menu/LongPressMenuTest.kt +++ b/app/src/androidTest/java/org/schabi/newpipe/ui/components/menu/LongPressMenuTest.kt @@ -127,18 +127,18 @@ class LongPressMenuTest { private fun assertEditorIsEnteredAndExitedProperly() { composeRule.onNodeWithContentDescription(R.string.long_press_menu_enabled_actions_description) .assertDoesNotExist() - composeRule.onNodeWithContentDescription(R.string.edit) + composeRule.onNodeWithContentDescription(R.string.long_press_menu_actions_editor) .performClick() composeRule.waitUntil { composeRule.onNodeWithText(R.string.long_press_menu_enabled_actions) .isDisplayed() } - composeRule.onNodeWithContentDescription(R.string.edit) + composeRule.onNodeWithContentDescription(R.string.long_press_menu_actions_editor) .assertDoesNotExist() Espresso.pressBack() composeRule.waitUntil { - composeRule.onNodeWithContentDescription(R.string.edit) + composeRule.onNodeWithContentDescription(R.string.long_press_menu_actions_editor) .isDisplayed() } 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 23a597a96..4053b70a6 100644 --- a/app/src/main/java/org/schabi/newpipe/ui/Toolbar.kt +++ b/app/src/main/java/org/schabi/newpipe/ui/Toolbar.kt @@ -8,9 +8,9 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Search import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SearchBar import androidx.compose.material3.Text @@ -26,6 +26,7 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import org.schabi.newpipe.R +import org.schabi.newpipe.ui.components.common.TooltipIconButton import org.schabi.newpipe.ui.theme.AppTheme import org.schabi.newpipe.ui.theme.SizeTokens @@ -70,13 +71,12 @@ fun Toolbar( actions = { actions() if (hasSearch) { - IconButton(onClick = { isSearchActive = true }) { - Icon( - painterResource(id = R.drawable.ic_search), - contentDescription = stringResource(id = R.string.search), - tint = MaterialTheme.colorScheme.onSurface - ) - } + TooltipIconButton( + onClick = { isSearchActive = true }, + icon = Icons.Default.Search, + contentDescription = stringResource(id = R.string.search), + tint = MaterialTheme.colorScheme.onSurface + ) } } ) 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 050f03970..946c98254 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 @@ -6,8 +6,6 @@ import androidx.compose.foundation.layout.RowScope import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text @@ -39,12 +37,11 @@ fun ScaffoldWithToolbar( actionIconContentColor = MaterialTheme.colorScheme.onPrimaryContainer ), navigationIcon = { - IconButton(onClick = onBackClick) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = stringResource(R.string.back) - ) - } + TooltipIconButton( + onClick = onBackClick, + icon = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.back) + ) }, actions = actions ) diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/common/Tooltip.kt b/app/src/main/java/org/schabi/newpipe/ui/components/common/Tooltip.kt new file mode 100644 index 000000000..7119d3119 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/ui/components/common/Tooltip.kt @@ -0,0 +1,84 @@ +@file:OptIn(ExperimentalMaterial3Api::class) + +package org.schabi.newpipe.ui.components.common + +import androidx.compose.foundation.layout.Box +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.PlainTooltip +import androidx.compose.material3.Text +import androidx.compose.material3.TooltipBox +import androidx.compose.material3.TooltipDefaults +import androidx.compose.material3.rememberTooltipState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector + +/** + * Useful to show a descriptive popup tooltip when something (e.g. a button) is long pressed. This + * happens by default on XML Views buttons, but needs to be done manually in Compose. + * + * @param text the text to show in the tooltip + * @param modifier The [TooltipBox] implementation does not handle modifiers well, since it wraps + * [content] in a [Box], rendering some [content] modifiers useless. Therefore we have to wrap the + * [TooltipBox] in yet another [Box] with its own modifier, passed as a parameter here. + * @param content the content that will show a tooltip when long pressed (e.g. a button) + */ +@Composable +fun SimpleTooltipBox( + text: String, + modifier: Modifier = Modifier, + content: @Composable () -> Unit +) { + Box(modifier = modifier) { + TooltipBox( + positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(), + tooltip = { PlainTooltip { Text(text) } }, + state = rememberTooltipState(), + content = content + ) + } +} + +/** + * An [IconButton] that shows a descriptive popup tooltip when it is long pressed. + * + * @param onClick handles clicks on the button + * @param icon the icon to show inside the button + * @param contentDescription the text to use as content description for the button, + * and also to show in the tooltip + * @param modifier as described in [SimpleTooltipBox] + * @param buttonModifier a modifier for the internal [IconButton] + * @param iconModifier a modifier for the internal [Icon] + * @param tint the color of the icon + */ +@Composable +fun TooltipIconButton( + onClick: () -> Unit, + icon: ImageVector, + contentDescription: String, + modifier: Modifier = Modifier, + buttonModifier: Modifier = Modifier, + iconModifier: Modifier = Modifier, + tint: Color = LocalContentColor.current +) { + SimpleTooltipBox( + text = contentDescription, + modifier = modifier + ) { + IconButton( + onClick = onClick, + modifier = buttonModifier + ) { + Icon( + icon, + contentDescription = contentDescription, + tint = tint, + modifier = iconModifier + ) + } + } +} 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 4a3c4a57e..967b6b755 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,6 +22,7 @@ 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.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn @@ -40,7 +41,6 @@ import androidx.compose.material3.BottomSheetDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedButton @@ -70,6 +70,7 @@ 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.AnnotatedString import androidx.compose.ui.text.Placeholder import androidx.compose.ui.text.PlaceholderVerticalAlign import androidx.compose.ui.text.SpanStyle @@ -93,6 +94,8 @@ import org.schabi.newpipe.error.ErrorInfo import org.schabi.newpipe.error.ErrorUtil import org.schabi.newpipe.error.UserAction.LONG_PRESS_MENU_ACTION import org.schabi.newpipe.extractor.stream.StreamType +import org.schabi.newpipe.ui.components.common.SimpleTooltipBox +import org.schabi.newpipe.ui.components.common.TooltipIconButton import org.schabi.newpipe.ui.components.menu.LongPressAction.Type.EnqueueNext import org.schabi.newpipe.ui.components.menu.LongPressAction.Type.ShowChannelDetails import org.schabi.newpipe.ui.discardAllTouchesIf @@ -284,7 +287,6 @@ private fun LongPressMenuContent( enabled = action.enabled(), modifier = Modifier .height(buttonHeight) - .fillMaxWidth() .weight(1F) ) rowIndex += 1 @@ -355,22 +357,19 @@ fun LongPressMenuDragHandle(onEditActions: () -> Unit) { BottomSheetDefaults.DragHandle( modifier = Modifier.align(Alignment.Center) ) - IconButton( + + // show a small button here, it's not an important button and it shouldn't + // capture the user attention + TooltipIconButton( 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.Tune, - contentDescription = stringResource(R.string.edit), - // same color and height as the DragHandle - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier - .padding(2.dp) - .size(16.dp) - ) - } + icon = Icons.Default.Tune, + contentDescription = stringResource(R.string.long_press_menu_actions_editor), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.align(Alignment.CenterEnd), + iconModifier = Modifier + .padding(2.dp) + .size(16.dp) + ) } } @@ -519,32 +518,42 @@ fun LongPressMenuHeader( if (subtitle.isNotBlank()) { Spacer(Modifier.height(1.dp)) - Text( - text = subtitle, - style = MaterialTheme.typography.bodyMedium, - inlineContent = getSubtitleInlineContent(), - modifier = if (onUploaderClick == null) { - Modifier + if (onUploaderClick == null) { + LongPressMenuHeaderSubtitle(subtitle) + } else { + val label = if (item.uploader != null) { + stringResource(R.string.show_channel_details_for, item.uploader) } else { - Modifier.clickable( - onClick = onUploaderClick, - onClickLabel = if (item.uploader != null) { - stringResource(R.string.show_channel_details_for, item.uploader) - } else { - stringResource(R.string.show_channel_details) - } + stringResource(R.string.show_channel_details) + } + SimpleTooltipBox( + text = label + ) { + LongPressMenuHeaderSubtitle( + subtitle, + Modifier.clickable(onClick = onUploaderClick, onClickLabel = label) ) } - .fillMaxWidth() - .fadedMarquee(edgeWidth = 12.dp) - .testTag("ShowChannelDetails") - ) + } } } } } } +@Composable +private fun LongPressMenuHeaderSubtitle(subtitle: AnnotatedString, modifier: Modifier = Modifier) { + Text( + text = subtitle, + style = MaterialTheme.typography.bodyMedium, + inlineContent = getSubtitleInlineContent(), + modifier = modifier + .fillMaxWidth() + .fadedMarquee(edgeWidth = 12.dp) + .testTag("ShowChannelDetails") + ) +} + fun getSubtitleAnnotatedString( item: LongPressable, showLink: Boolean, @@ -618,30 +627,33 @@ fun LongPressMenuButton( icon: ImageVector, text: String, onClick: () -> Unit, - modifier: Modifier = Modifier, - enabled: Boolean = true + enabled: Boolean, + 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, - shape = MaterialTheme.shapes.large, - contentPadding = PaddingValues(start = 3.dp, top = 8.dp, end = 3.dp, bottom = 2.dp), - border = null, + SimpleTooltipBox( + text = text, modifier = modifier ) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Icon( - imageVector = icon, - contentDescription = null, - modifier = Modifier.size(32.dp) - ) - FixedHeightCenteredText( - text = text, - lines = 2, - style = MaterialTheme.typography.bodySmall - ) + OutlinedButton( + onClick = onClick, + enabled = enabled, + shape = MaterialTheme.shapes.large, + contentPadding = PaddingValues(start = 3.dp, top = 8.dp, end = 3.dp, bottom = 2.dp), + border = null, + modifier = Modifier.fillMaxSize() + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(32.dp) + ) + FixedHeightCenteredText( + text = text, + lines = 2, + style = MaterialTheme.typography.bodySmall + ) + } } } } @@ -659,6 +671,7 @@ private fun LongPressMenuButtonPreviews() { icon = entry.icon, text = stringResource(entry.label), onClick = { }, + enabled = true, modifier = Modifier.size(86.dp) ) } 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 c790e8d80..6d755984e 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 @@ -42,7 +42,6 @@ import androidx.compose.material.icons.filled.RestartAlt import androidx.compose.material3.AlertDialog 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.Surface @@ -76,6 +75,7 @@ import kotlin.math.floor import org.schabi.newpipe.R import org.schabi.newpipe.ktx.letIf import org.schabi.newpipe.ui.components.common.ScaffoldWithToolbar +import org.schabi.newpipe.ui.components.common.TooltipIconButton import org.schabi.newpipe.ui.detectDragGestures import org.schabi.newpipe.ui.theme.AppTheme import org.schabi.newpipe.util.text.FixedHeightCenteredText @@ -199,12 +199,11 @@ private fun ResetToDefaultsButton(onClick: () -> Unit) { ) } - IconButton(onClick = { showDialog = true }) { - Icon( - imageVector = Icons.Default.RestartAlt, - contentDescription = stringResource(R.string.playback_reset) - ) - } + TooltipIconButton( + onClick = { showDialog = true }, + icon = Icons.Default.RestartAlt, + contentDescription = stringResource(R.string.playback_reset) + ) } @Composable diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e68cd351f..5c6a394fb 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -872,7 +872,6 @@ 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 94608137efa29f6dc23c876784954783c0abbc78 Mon Sep 17 00:00:00 2001 From: Stypox Date: Tue, 10 Feb 2026 13:14:39 +0100 Subject: [PATCH 83/87] Add documentation to LongPressMenu and simplify code Also add 2 tests to make sure all menu buttons are the same size Also simplify some code in LongPressMenu instantiation Also make the LongPressMenuEditor appear on top of the LongPressMenu instead of closing and reopening the LongPressMenu --- .../ui/components/menu/LongPressMenuTest.kt | 80 +++--- .../ui/components/menu/LongPressAction.kt | 7 + .../ui/components/menu/LongPressMenu.kt | 271 +++++++++++------- .../components/menu/LongPressMenuViewModel.kt | 20 +- 4 files changed, 242 insertions(+), 136 deletions(-) diff --git a/app/src/androidTest/java/org/schabi/newpipe/ui/components/menu/LongPressMenuTest.kt b/app/src/androidTest/java/org/schabi/newpipe/ui/components/menu/LongPressMenuTest.kt index f45ed5cc0..e497adffd 100644 --- a/app/src/androidTest/java/org/schabi/newpipe/ui/components/menu/LongPressMenuTest.kt +++ b/app/src/androidTest/java/org/schabi/newpipe/ui/components/menu/LongPressMenuTest.kt @@ -6,6 +6,7 @@ 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.geometry.Rect import androidx.compose.ui.semantics.SemanticsProperties.ProgressBarRangeInfo import androidx.compose.ui.test.SemanticsMatcher import androidx.compose.ui.test.assert @@ -130,20 +131,17 @@ class LongPressMenuTest { composeRule.onNodeWithContentDescription(R.string.long_press_menu_actions_editor) .performClick() composeRule.waitUntil { - composeRule.onNodeWithText(R.string.long_press_menu_enabled_actions) + composeRule.onNodeWithText(R.string.long_press_menu_enabled_actions_description) .isDisplayed() } - composeRule.onNodeWithContentDescription(R.string.long_press_menu_actions_editor) - .assertDoesNotExist() Espresso.pressBack() composeRule.waitUntil { - composeRule.onNodeWithContentDescription(R.string.long_press_menu_actions_editor) - .isDisplayed() + composeRule.onNodeWithText(R.string.long_press_menu_enabled_actions_description) + .isNotDisplayed() } - - composeRule.onNodeWithText(R.string.long_press_menu_enabled_actions) - .assertDoesNotExist() + composeRule.onNodeWithContentDescription(R.string.long_press_menu_actions_editor) + .assertIsDisplayed() } @Test @@ -392,14 +390,7 @@ class LongPressMenuTest { .assertIsDisplayed() } - @Test - @SdkSuppress(minSdkVersion = Build.VERSION_CODES.N) // setDisplaySize not available on API < 24 - fun testHeaderSpansAllWidthIfSmallScreen() { - onDevice().setDisplaySize( - widthSizeClass = WidthSizeClass.COMPACT, - heightSizeClass = HeightSizeClass.MEDIUM - ) - setLongPressMenu() + private fun getFirstRowAndHeaderBounds(): Pair { val row = composeRule .onAllNodesWithTag("LongPressMenuGridRow") .onFirst() @@ -408,7 +399,26 @@ class LongPressMenuTest { val header = composeRule.onNodeWithTag("LongPressMenuHeader") .fetchSemanticsNode() .boundsInRoot + return Pair(row, header) + } + + private fun assertAllButtonsSameSize() { + composeRule.onAllNodesWithTag("LongPressMenuButton") + .fetchSemanticsNodes() + .reduce { prev, curr -> + assertInRange(prev.size.height - 1, prev.size.height + 1, curr.size.height) + assertInRange(prev.size.width - 1, prev.size.width + 1, curr.size.width) + return@reduce curr + } + } + + @Test + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.N) // setDisplaySize not available on API < 24 + fun testHeaderSpansAllWidthIfSmallScreen() { + onDevice().setDisplaySize(WidthSizeClass.COMPACT, HeightSizeClass.MEDIUM) + setLongPressMenu() // checks that the header is roughly as large as the row that contains it + val (row, header) = getFirstRowAndHeaderBounds() assertInRange(row.left, row.left + 24.dp.value, header.left) assertInRange(row.right - 24.dp.value, row.right, header.right) } @@ -416,24 +426,31 @@ class LongPressMenuTest { @Test @SdkSuppress(minSdkVersion = Build.VERSION_CODES.N) // setDisplaySize not available on API < 24 fun testHeaderIsNotFullWidthIfLargeScreen() { - onDevice().setDisplaySize( - widthSizeClass = WidthSizeClass.EXPANDED, - heightSizeClass = HeightSizeClass.MEDIUM - ) + onDevice().setDisplaySize(WidthSizeClass.EXPANDED, HeightSizeClass.MEDIUM) setLongPressMenu() - val row = composeRule - .onAllNodesWithTag("LongPressMenuGridRow") - .onFirst() - .fetchSemanticsNode() - .boundsInRoot - val header = composeRule.onNodeWithTag("LongPressMenuHeader") - .fetchSemanticsNode() - .boundsInRoot + // checks that the header is definitely smaller than the row that contains it + val (row, header) = getFirstRowAndHeaderBounds() assertInRange(row.left, row.left + 24.dp.value, header.left) assertNotInRange(row.right - 24.dp.value, Float.MAX_VALUE, header.right) } + @Test + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.N) // setDisplaySize not available on API < 24 + fun testAllButtonsSameSizeSmallScreen() { + onDevice().setDisplaySize(WidthSizeClass.COMPACT, HeightSizeClass.MEDIUM) + setLongPressMenu() + assertAllButtonsSameSize() + } + + @Test + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.N) // setDisplaySize not available on API < 24 + fun testAllButtonsSameSizeLargeScreen() { + onDevice().setDisplaySize(WidthSizeClass.EXPANDED, HeightSizeClass.MEDIUM) + setLongPressMenu() + assertAllButtonsSameSize() + } + /** * The tests below all call this function to test, under different conditions, that the shown * actions are the intersection between the available and the enabled actions. @@ -545,7 +562,7 @@ class LongPressMenuTest { } @Test - fun testFewActionsOnLargeScreenAreNotScrollable() { + fun testFewActionsOnNormalScreenAreNotScrollable() { assertOnlyAndAllArrangedActionsDisplayed( availableActions = listOf(ShowDetails, ShowChannelDetails), actionArrangement = listOf(ShowDetails, ShowChannelDetails), @@ -570,10 +587,7 @@ class LongPressMenuTest { @Test @SdkSuppress(minSdkVersion = Build.VERSION_CODES.N) // setDisplaySize not available on API < 24 fun testAllActionsOnSmallScreenAreScrollable() { - onDevice().setDisplaySize( - widthSizeClass = WidthSizeClass.COMPACT, - heightSizeClass = HeightSizeClass.COMPACT - ) + onDevice().setDisplaySize(WidthSizeClass.COMPACT, HeightSizeClass.COMPACT) assertOnlyAndAllArrangedActionsDisplayed( availableActions = LongPressAction.Type.entries, actionArrangement = LongPressAction.Type.entries, 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 a55fe7d3a..45574c041 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 @@ -102,6 +102,12 @@ data class LongPressAction( val action: suspend (context: Context) -> Unit ) { /** + * When adding a new action, make sure to pick a unique [id] for it. Also, if the newly added + * action is to be considered a default action, add it to + * `LongPressMenuSettings.DefaultEnabledActions`, and create a settings migration to add it to + * the user's actions (otherwise the action will be disabled by default and the user will never + * find out about it). + * * @param id a unique ID that allows saving and restoring a list of action types from settings. * **MUST NOT CHANGE ACROSS APP VERSIONS!** * @param label a string label to show in the action's button @@ -138,6 +144,7 @@ data class LongPressAction( Unsubscribe(23, R.string.unsubscribe, Icons.Default.RemoveCircle), Delete(24, R.string.delete, Icons.Default.Delete), Remove(25, R.string.play_queue_remove, Icons.Default.Delete) + // READ THE Type ENUM JAVADOC BEFORE ADDING OR CHANGING ACTIONS! } companion object { 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 967b6b755..d760e52af 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 @@ -5,6 +5,7 @@ package org.schabi.newpipe.ui.components.menu import android.app.Activity import android.content.Context import android.content.res.Configuration +import android.util.Log import android.view.ViewGroup import android.view.ViewGroup.LayoutParams import androidx.compose.animation.fadeIn @@ -89,6 +90,7 @@ import java.time.OffsetDateTime import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import org.schabi.newpipe.BuildConfig import org.schabi.newpipe.R import org.schabi.newpipe.error.ErrorInfo import org.schabi.newpipe.error.ErrorUtil @@ -106,44 +108,56 @@ import org.schabi.newpipe.util.Localization import org.schabi.newpipe.util.text.FixedHeightCenteredText import org.schabi.newpipe.util.text.fadedMarquee +internal val MinButtonWidth = 86.dp +internal val ThumbnailHeight = 60.dp +private const val TAG = "LongPressMenu" + +/** + * Opens the long press menu from a View UI. From a Compose UI, use [LongPressMenu] directly. + */ fun openLongPressMenuInActivity( activity: Activity, longPressable: LongPressable, longPressActions: List ) { + val composeView = ComposeView(activity) + composeView.setContent { + AppTheme { + LongPressMenu( + longPressable = longPressable, + longPressActions = longPressActions, + onDismissRequest = { (composeView.parent as? ViewGroup)?.removeView(composeView) } + ) + } + } activity.addContentView( - getLongPressMenuView(activity, longPressable, longPressActions), + composeView, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) ) } -fun getLongPressMenuView( - context: Context, - longPressable: LongPressable, - longPressActions: List -): ComposeView { - return ComposeView(context).apply { - setContent { - AppTheme { - LongPressMenu( - longPressable = longPressable, - longPressActions = longPressActions, - onDismissRequest = { (this.parent as? ViewGroup)?.removeView(this) } - ) - } - } - } -} - -internal val MinButtonWidth = 86.dp -internal val ThumbnailHeight = 60.dp - +/** + * Shows a bottom sheet menu containing a small header with the information in [longPressable], and + * then a list of actions that the user can perform on that item. + * + * @param longPressable contains information about the item that was just long-pressed, this + * information will be shown in a small header at the top of the menu, unless the user disabled it + * @param longPressActions should contain a list of all *applicable* actions for the item, and this + * composable's implementation will take care of filtering out the actions that the user has not + * disabled in settings. For more info see [LongPressAction] + * @param onDismissRequest called when the [LongPressMenu] should be closed, because the user either + * dismissed it or chose an action + */ @Composable fun LongPressMenu( longPressable: LongPressable, longPressActions: List, onDismissRequest: () -> Unit ) { + // there are three possible states for the long press menu: + // - the starting state, with the menu shown + // - the loading state, after a user presses on an action that takes some time to be performed + // - the editor state, after the user clicks on the editor button in the top right val viewModel: LongPressMenuViewModel = viewModel() val isHeaderEnabled by viewModel.isHeaderEnabled.collectAsState() val actionArrangement by viewModel.actionArrangement.collectAsState() @@ -151,86 +165,111 @@ fun LongPressMenu( var isLoading by remember { mutableStateOf(false) } val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + // intersection between applicable actions (longPressActions) and actions that the user + // enabled in settings (actionArrangement) + val enabledLongPressActions by remember { + derivedStateOf { + actionArrangement.mapNotNull { type -> + longPressActions.firstOrNull { it.type == type } + } + } + } + + val ctx = LocalContext.current + // run actions on the main thread! + val coroutineScope = rememberCoroutineScope { Dispatchers.Main } + fun runActionAndDismiss(action: LongPressAction) { + if (isLoading) { + return // shouldn't be reachable, but just in case, prevent running two actions + } + isLoading = true + coroutineScope.launch { + try { + action.action(ctx) + } catch (_: CancellationException) { + // the user canceled the action, e.g. by dismissing the dialog while loading + if (BuildConfig.DEBUG) { + Log.d(TAG, "Got CancellationException while running action ${action.type}") + } + } catch (t: Throwable) { + ErrorUtil.showSnackbar( + ctx, + ErrorInfo(t, LONG_PRESS_MENU_ACTION, "Running action ${action.type}") + ) + } + onDismissRequest() + } + } + + // show a clickable uploader in the header if an uploader action is available and the + // "show channel details" action is not enabled as a standalone action + val onUploaderClick by remember { + derivedStateOf { + longPressActions.firstOrNull { it.type == ShowChannelDetails } + ?.takeIf { !actionArrangement.contains(ShowChannelDetails) } + ?.let { showChannelAction -> { runActionAndDismiss(showChannelAction) } } + } + } + + // takes care of showing either the actions or a loading indicator in a bottom sheet + ModalBottomSheet( + sheetState = sheetState, + onDismissRequest = onDismissRequest, + dragHandle = { LongPressMenuDragHandle(onEditActions = { showEditor = true }) } + ) { + // this Box and the .matchParentSize() below make sure that once the loading starts, the + // bottom sheet menu size remains the same and the loading button is shown in the middle + Box(modifier = Modifier.discardAllTouchesIf(isLoading)) { + LongPressMenuContent( + header = longPressable.takeIf { isHeaderEnabled }, + onUploaderClick = onUploaderClick, + actions = enabledLongPressActions, + runActionAndDismiss = ::runActionAndDismiss + ) + // importing makes the ColumnScope overload be resolved, so we use qualified path... + androidx.compose.animation.AnimatedVisibility( + visible = isLoading, + enter = fadeIn(), + exit = fadeOut(), + modifier = Modifier + .matchParentSize() + .background(MaterialTheme.colorScheme.surfaceContainerLow) + ) { + Box(contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + } + } + } + + // takes care of showing the editor screen if (showEditor && !isLoading) { - // we can't put the editor in a bottom sheet, because it relies on dragging gestures + // we can't put the editor in a bottom sheet, because it relies on dragging gestures and it + // benefits from a bigger screen, so we use a fullscreen dialog instead Dialog( onDismissRequest = { showEditor = false }, properties = DialogProperties(usePlatformDefaultWidth = false) ) { LongPressMenuEditorPage { showEditor = false } } - } else { - val enabledLongPressActions by remember { - derivedStateOf { - actionArrangement.mapNotNull { type -> - longPressActions.firstOrNull { it.type == type } - } - } - } - - val ctx = LocalContext.current - // run actions on the main thread! - val coroutineScope = rememberCoroutineScope { Dispatchers.Main } - fun runActionAndDismiss(action: LongPressAction) { - if (isLoading) { - return - } - isLoading = true - coroutineScope.launch { - try { - action.action(ctx) - } catch (_: CancellationException) { - // the user canceled the action, e.g. by dismissing the dialog while loading - } catch (t: Throwable) { - ErrorUtil.showSnackbar( - ctx, - ErrorInfo(t, LONG_PRESS_MENU_ACTION, "Running action ${action.type}") - ) - } - onDismissRequest() - } - } - - // show a clickable uploader in the header if an uploader action is available and the - // "show channel details" action is not enabled as a standalone action - val onUploaderClick by remember { - derivedStateOf { - longPressActions.firstOrNull { it.type == ShowChannelDetails } - ?.takeIf { !actionArrangement.contains(ShowChannelDetails) } - ?.let { showChannelAction -> { runActionAndDismiss(showChannelAction) } } - } - } - - ModalBottomSheet( - sheetState = sheetState, - onDismissRequest = onDismissRequest, - dragHandle = { LongPressMenuDragHandle(onEditActions = { showEditor = true }) } - ) { - Box(modifier = Modifier.discardAllTouchesIf(isLoading)) { - LongPressMenuContent( - header = longPressable.takeIf { isHeaderEnabled }, - onUploaderClick = onUploaderClick, - actions = enabledLongPressActions, - runActionAndDismiss = ::runActionAndDismiss - ) - // importing makes the ColumnScope overload be resolved, so we use qualified path... - androidx.compose.animation.AnimatedVisibility( - visible = isLoading, - enter = fadeIn(), - exit = fadeOut(), - modifier = Modifier - .matchParentSize() - .background(MaterialTheme.colorScheme.surfaceContainerLow) - ) { - Box(contentAlignment = Alignment.Center) { - CircularProgressIndicator() - } - } - } - } } } +/** + * Arranges the header and the buttons in a grid according to the following constraints: + * - buttons have a minimum width, and all buttons should be exactly the same size + * - as many buttons as possible should fit in a row, with no space between them, so misclicks can + * still be caught and to leave more space for the button label text + * - the header is exactly as large as `headerWidthInButtonsReducedSpan=4` buttons, but + * `maxHeaderWidthInButtonsFullSpan=5` buttons wouldn't fit in a row then the header uses a full row + * - if the header is not using a full row, then more buttons should fit with it on the same row, + * so that the space is used efficiently e.g. in landscape or large screens + * - the menu should be vertically scrollable if there are too many actions to fit on the screen + * + * Unfortunately all these requirements mean we can't simply use a [FlowRow] but have to build a + * custom layout with [Row]s inside a [Column]. To make each item in the row have the appropriate + * size, we use [androidx.compose.foundation.layout.RowScope.weight]. + */ @Composable private fun LongPressMenuContent( header: LongPressable?, @@ -288,6 +327,7 @@ private fun LongPressMenuContent( modifier = Modifier .height(buttonHeight) .weight(1F) + .testTag("LongPressMenuButton") ) rowIndex += 1 } else if (maxHeaderWidthInButtonsFullSpan >= buttonsPerRow) { @@ -331,8 +371,12 @@ private fun LongPressMenuContent( } } +/** + * A custom [BottomSheetDefaults.DragHandle] that also shows a small button on the right, that opens + * the long press menu settings editor. + */ @Composable -fun LongPressMenuDragHandle(onEditActions: () -> Unit) { +private fun LongPressMenuDragHandle(onEditActions: () -> Unit) { var showFocusTrap by remember { mutableStateOf(true) } Box( @@ -358,7 +402,7 @@ fun LongPressMenuDragHandle(onEditActions: () -> Unit) { modifier = Modifier.align(Alignment.Center) ) - // show a small button here, it's not an important button and it shouldn't + // show a small button to open the editor, it's not an important button and it shouldn't // capture the user attention TooltipIconButton( onClick = onEditActions, @@ -384,8 +428,16 @@ private fun LongPressMenuDragHandlePreview() { } } +/** + * A box that displays information about [item]: thumbnail, playlist item count, video duration, + * title, channel, date, view count. + * + * @param item the item that was long pressed and whose info should be shown + * @param onUploaderClick if not `null`, the [Text] containing the uploader will be made clickable + * (even if `item.uploader` is `null`, in which case a placeholder uploader text will be shown) + */ @Composable -fun LongPressMenuHeader( +private fun LongPressMenuHeader( item: LongPressable, onUploaderClick: (() -> Unit)?, modifier: Modifier = Modifier @@ -500,6 +552,7 @@ fun LongPressMenuHeader( Column( modifier = Modifier.padding(vertical = 8.dp) ) { + // title Text( text = item.title, style = MaterialTheme.typography.titleMedium, @@ -509,6 +562,8 @@ fun LongPressMenuHeader( .fadedMarquee(edgeWidth = 12.dp) ) + // subtitle; see the javadocs of `getSubtitleAnnotatedString` and + // `LongPressMenuHeaderSubtitle` to understand what is happening here val subtitle = getSubtitleAnnotatedString( item = item, showLink = onUploaderClick != null, @@ -521,6 +576,7 @@ fun LongPressMenuHeader( if (onUploaderClick == null) { LongPressMenuHeaderSubtitle(subtitle) } else { + // only show the tooltip if the menu is actually clickable val label = if (item.uploader != null) { stringResource(R.string.show_channel_details_for, item.uploader) } else { @@ -541,6 +597,10 @@ fun LongPressMenuHeader( } } +/** + * Works in tandem with [getSubtitleAnnotatedString] and [getSubtitleInlineContent] to show the + * subtitle line with a small material icon next to the uploader link. + */ @Composable private fun LongPressMenuHeaderSubtitle(subtitle: AnnotatedString, modifier: Modifier = Modifier) { Text( @@ -554,13 +614,21 @@ private fun LongPressMenuHeaderSubtitle(subtitle: AnnotatedString, modifier: Mod ) } -fun getSubtitleAnnotatedString( +/** + * @param item information fields are from here and concatenated in a single string + * @param showLink if true, a small material icon next to the uploader link; requires the [Text] to + * use [getSubtitleInlineContent] later + * @param linkColor which color to make the uploader link + */ +private fun getSubtitleAnnotatedString( item: LongPressable, showLink: Boolean, linkColor: Color, ctx: Context ) = buildAnnotatedString { var shouldAddSeparator = false + + // uploader (possibly with link) if (showLink) { withStyle(SpanStyle(color = linkColor)) { if (item.uploader.isNullOrBlank()) { @@ -578,6 +646,7 @@ fun getSubtitleAnnotatedString( shouldAddSeparator = true } + // localized upload date val uploadDate = item.uploadDate?.match( { it }, { Localization.relativeTime(it) } @@ -590,6 +659,7 @@ fun getSubtitleAnnotatedString( append(uploadDate) } + // localized view count val viewCount = item.viewCount?.let { Localization.localizeViewCount(ctx, true, item.streamType, it) } @@ -606,7 +676,7 @@ fun getSubtitleAnnotatedString( * provide it to [Text] through its `inlineContent` parameter. */ @Composable -fun getSubtitleInlineContent() = mapOf( +private fun getSubtitleInlineContent() = mapOf( "open_in_new" to InlineTextContent( placeholder = Placeholder( width = MaterialTheme.typography.bodyMedium.fontSize, @@ -622,8 +692,13 @@ fun getSubtitleInlineContent() = mapOf( } ) +/** + * A button to show in the long press menu with an [icon] and a label [text]. When pressed, + * [onClick] will be called, and when long pressed a tooltip will appear with the full [text]. If + * the button should appear disabled, make sure to set [enabled]`=false`. + */ @Composable -fun LongPressMenuButton( +private fun LongPressMenuButton( icon: ImageVector, text: String, onClick: () -> Unit, diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressMenuViewModel.kt b/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressMenuViewModel.kt index eb9b57db1..8b5977231 100644 --- a/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressMenuViewModel.kt +++ b/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressMenuViewModel.kt @@ -1,6 +1,5 @@ package org.schabi.newpipe.ui.components.menu -import android.content.Context import android.content.SharedPreferences import androidx.lifecycle.ViewModel import androidx.preference.PreferenceManager @@ -11,20 +10,31 @@ import org.schabi.newpipe.App import org.schabi.newpipe.R /** - * Since view models can't have access to the UI's Context, we use [App.instance] instead to fetch - * shared preferences. This is not the best but won't be needed anyway once we will have a Hilt - * injected repository that provides access to a modern alternative to shared preferences. The whole - * thing with the shared preference listener will not be necessary with the modern alternative. + * Just handles loading preferences and listening for preference changes for [isHeaderEnabled] and + * [actionArrangement]. + * + * Note: Since view models can't have access to the UI's Context, we use [App.instance] instead to + * fetch shared preferences. This won't be needed once we will have a Hilt injected repository that + * provides access to a modern alternative to shared preferences. The whole thing with the shared + * preference listener will not be necessary with the modern alternative. */ class LongPressMenuViewModel : ViewModel() { private val _isHeaderEnabled = MutableStateFlow( loadIsHeaderEnabledFromSettings(App.instance) ) + + /** + * Whether the user wants the header be shown in the long press menu. + */ val isHeaderEnabled: StateFlow = _isHeaderEnabled.asStateFlow() private val _actionArrangement = MutableStateFlow( loadLongPressActionArrangementFromSettings(App.instance) ) + + /** + * The actions that the user wants to be shown (if they are applicable), and in which order. + */ val actionArrangement: StateFlow> = _actionArrangement.asStateFlow() private val prefs = PreferenceManager.getDefaultSharedPreferences(App.instance) From ae214a04ff075c051283962ba9adff8325ec2373 Mon Sep 17 00:00:00 2001 From: Stypox Date: Tue, 10 Feb 2026 20:32:15 +0100 Subject: [PATCH 84/87] Add 22 tests for LongPressMenuEditor Also improve some tests for LongPressMenu and add some test utility functions --- .../schabi/newpipe/InstrumentedTestUtil.kt | 63 ++ .../menu/LongPressMenuEditorTest.kt | 611 ++++++++++++++++++ .../ui/components/menu/LongPressMenuTest.kt | 62 +- .../ui/components/menu/LongPressMenuEditor.kt | 6 +- 4 files changed, 714 insertions(+), 28 deletions(-) create mode 100644 app/src/androidTest/java/org/schabi/newpipe/ui/components/menu/LongPressMenuEditorTest.kt diff --git a/app/src/androidTest/java/org/schabi/newpipe/InstrumentedTestUtil.kt b/app/src/androidTest/java/org/schabi/newpipe/InstrumentedTestUtil.kt index b8b9f0415..761de0a22 100644 --- a/app/src/androidTest/java/org/schabi/newpipe/InstrumentedTestUtil.kt +++ b/app/src/androidTest/java/org/schabi/newpipe/InstrumentedTestUtil.kt @@ -1,15 +1,31 @@ package org.schabi.newpipe +import android.app.Instrumentation import android.content.Context +import android.os.SystemClock +import android.view.MotionEvent import androidx.annotation.StringRes import androidx.compose.ui.test.SemanticsNodeInteraction import androidx.compose.ui.test.SemanticsNodeInteractionsProvider +import androidx.compose.ui.test.TouchInjectionScope +import androidx.compose.ui.test.assert +import androidx.compose.ui.test.hasScrollAction import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.swipeUp import androidx.preference.PreferenceManager import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals import org.junit.Assert.fail +/** + * Use this instead of calling `InstrumentationRegistry.getInstrumentation()` every time. + */ +val inst: Instrumentation + get() = InstrumentationRegistry.getInstrumentation() + /** * Use this instead of passing contexts around in instrumented tests. */ @@ -31,6 +47,15 @@ fun clearPrefs() { .edit().clear().apply() } +/** + * E.g. useful to tap outside dialogs to see whether they close. + */ +fun tapAtAbsoluteXY(x: Float, y: Float) { + val t = SystemClock.uptimeMillis() + inst.sendPointerSync(MotionEvent.obtain(t, t, MotionEvent.ACTION_DOWN, x, y, 0)) + inst.sendPointerSync(MotionEvent.obtain(t, t + 50, MotionEvent.ACTION_UP, x, y, 0)) +} + /** * Same as the original `onNodeWithText` except that this takes a [StringRes] instead of a [String]. */ @@ -55,6 +80,11 @@ fun SemanticsNodeInteractionsProvider.onNodeWithContentDescription( return this.onNodeWithContentDescription(ctx.getString(text), substring, ignoreCase, useUnmergedTree) } +/** + * Shorthand for `.fetchSemanticsNode().positionOnScreen`. + */ +fun SemanticsNodeInteraction.fetchPosOnScreen() = fetchSemanticsNode().positionOnScreen + /** * Asserts that [value] is in the range [[l], [r]] (both extremes included). */ @@ -78,3 +108,36 @@ fun > assertNotInRange(l: T, r: T, value: T) { fail("Expected $value to NOT be in range [$l, $r]") } } + +/** + * Tries to scroll vertically in the container [this] and uses [itemInsideScrollingContainer] to + * compute how much the container actually scrolled. Useful in tandem with [assertMoved] or + * [assertDidNotMove]. + */ +fun SemanticsNodeInteraction.scrollVerticallyAndGetOriginalAndFinalY( + itemInsideScrollingContainer: SemanticsNodeInteraction, + startY: TouchInjectionScope.() -> Float = { bottom }, + endY: TouchInjectionScope.() -> Float = { top } +): Pair { + val originalPosition = itemInsideScrollingContainer.fetchPosOnScreen() + this.performTouchInput { swipeUp(startY = startY(), endY = endY()) } + val finalPosition = itemInsideScrollingContainer.fetchPosOnScreen() + assertEquals(originalPosition.x, finalPosition.x) + return Pair(originalPosition.y, finalPosition.y) +} + +/** + * Simple assert on results from [scrollVerticallyAndGetOriginalAndFinalY]. + */ +fun Pair.assertMoved() { + val (originalY, finalY) = this + assertNotEquals(originalY, finalY) +} + +/** + * Simple assert on results from [scrollVerticallyAndGetOriginalAndFinalY]. + */ +fun Pair.assertDidNotMove() { + val (originalY, finalY) = this + assertEquals(originalY, finalY) +} diff --git a/app/src/androidTest/java/org/schabi/newpipe/ui/components/menu/LongPressMenuEditorTest.kt b/app/src/androidTest/java/org/schabi/newpipe/ui/components/menu/LongPressMenuEditorTest.kt new file mode 100644 index 000000000..190bc6861 --- /dev/null +++ b/app/src/androidTest/java/org/schabi/newpipe/ui/components/menu/LongPressMenuEditorTest.kt @@ -0,0 +1,611 @@ +package org.schabi.newpipe.ui.components.menu + +import android.os.Build +import android.view.KeyEvent +import androidx.activity.ComponentActivity +import androidx.annotation.StringRes +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.geometry.Offset +import androidx.compose.ui.test.SemanticsNodeInteraction +import androidx.compose.ui.test.TouchInjectionScope +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.getUnclippedBoundsInRoot +import androidx.compose.ui.test.isDisplayed +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.swipeUp +import androidx.compose.ui.unit.dp +import androidx.test.espresso.Espresso +import androidx.test.espresso.device.DeviceInteraction.Companion.setDisplaySize +import androidx.test.espresso.device.EspressoDevice.Companion.onDevice +import androidx.test.espresso.device.rules.DisplaySizeRule +import androidx.test.espresso.device.sizeclass.HeightSizeClass +import androidx.test.espresso.device.sizeclass.WidthSizeClass +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.SdkSuppress +import kotlin.math.absoluteValue +import kotlin.math.sign +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Rule +import org.junit.Test +import org.junit.rules.RuleChain +import org.junit.rules.TestRule +import org.junit.runner.RunWith +import org.schabi.newpipe.R +import org.schabi.newpipe.assertInRange +import org.schabi.newpipe.ctx +import org.schabi.newpipe.fetchPosOnScreen +import org.schabi.newpipe.inst +import org.schabi.newpipe.onNodeWithContentDescription +import org.schabi.newpipe.onNodeWithText +import org.schabi.newpipe.scrollVerticallyAndGetOriginalAndFinalY +import org.schabi.newpipe.tapAtAbsoluteXY +import org.schabi.newpipe.ui.components.menu.LongPressAction.Type.Enqueue +import org.schabi.newpipe.ui.components.menu.LongPressAction.Type.EnqueueNext +import org.schabi.newpipe.ui.components.menu.LongPressAction.Type.PlayWithKodi +import org.schabi.newpipe.ui.theme.AppTheme + +@RunWith(AndroidJUnit4::class) +class LongPressMenuEditorTest { + @get:Rule(order = 1) + val composeRule = createAndroidComposeRule() + + // Test rule for restoring device to its starting display size when a test case finishes. + // See https://developer.android.com/training/testing/different-screens/tools#resize-displays. + @get:Rule(order = 2) + val displaySizeRule: TestRule = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + DisplaySizeRule() + } else { + RuleChain.emptyRuleChain() + } + + /** + * Sets up the [LongPressMenuEditorPage] in the [composeRule] Compose content for running tests. + * Handles setting enabled header and actions via shared preferences, and closing the dialog + * when it is dismissed. + */ + private fun setEditor( + onDismissRequest: () -> Unit = {}, + isHeaderEnabled: Boolean = true, + actionArrangement: List = LongPressAction.Type.entries + ) { + storeIsHeaderEnabledToSettings(ctx, isHeaderEnabled) + storeLongPressActionArrangementToSettings(ctx, actionArrangement) + composeRule.setContent { + var isEditorVisible by rememberSaveable { mutableStateOf(true) } + if (isEditorVisible) { + AppTheme { + LongPressMenuEditorPage { + isEditorVisible = false + onDismissRequest() + } + } + } + } + } + + private fun closeEditorAndAssertNewSettings( + isHeaderEnabled: Boolean, + actionArrangement: List + ) { + composeRule.onNodeWithContentDescription(R.string.back) + .assertIsDisplayed() + Espresso.pressBackUnconditionally() + composeRule.waitUntil { + composeRule.onNodeWithContentDescription(R.string.back) + .runCatching { assertDoesNotExist() } + .isSuccess + } + + assertEquals(isHeaderEnabled, loadIsHeaderEnabledFromSettings(ctx)) + assertEquals(actionArrangement, loadLongPressActionArrangementFromSettings(ctx)) + } + + /** + * Checks whether the action (or the header) found by text [label] is above or below the text + * indicating that all actions below are disabled. + */ + private fun assertActionEnabledStatus( + @StringRes label: Int, + expectedEnabled: Boolean + ) { + val buttonBounds = composeRule.onNodeWithText(label) + .getUnclippedBoundsInRoot() + val hiddenActionTextBounds = composeRule.onNodeWithText(R.string.long_press_menu_hidden_actions) + .getUnclippedBoundsInRoot() + assertEquals(expectedEnabled, buttonBounds.top < hiddenActionTextBounds.top) + } + + /** + * The editor should always have all actions visible. Works as expected only if the screen is + * big enough to hold all items at once, otherwise LazyColumn will hide some lazily. + */ + private fun assertHeaderAndAllActionsExist() { + for (label in listOf(R.string.long_press_menu_header) + LongPressAction.Type.entries.map { it.label }) { + composeRule.onNodeWithText(label) + .assertExists() + } + } + + /** + * Long-press-and-move is used to change the arrangement of items in the editor. If you pass + * [longPressDurationMs]`=0` you can simulate the user just dragging across the screen, + * because there was no long press. + */ + private fun SemanticsNodeInteraction.longPressThenMove( + dx: TouchInjectionScope.() -> Int = { 0 }, + dy: TouchInjectionScope.() -> Int = { 0 }, + longPressDurationMs: Long = 1000 + ): SemanticsNodeInteraction { + return performTouchInput { + down(center) + advanceEventTime(longPressDurationMs) // perform long press + val dy = dy() + repeat(dy.absoluteValue) { + moveBy(Offset(0f, dy.sign.toFloat()), 100) + } + val dx = dx() + repeat(dx.absoluteValue) { + moveBy(Offset(dx.sign.toFloat(), 0f), 100) + } + up() + } + } + + @Test + fun pressingBackButtonCallsCallback() { + var calledCount = 0 + setEditor(onDismissRequest = { calledCount += 1 }) + composeRule.onNodeWithContentDescription(R.string.back) + .performClick() + composeRule.waitUntil { calledCount == 1 } + } + + /** + * Opens the reset dialog by pressing on the corresponding button, either with DPAD or touch. + */ + private fun openResetDialog(useDpad: Boolean) { + if (useDpad) { + inst.sendKeyDownUpSync(KeyEvent.KEYCODE_DPAD_RIGHT) + inst.sendKeyDownUpSync(KeyEvent.KEYCODE_DPAD_RIGHT) + inst.sendKeyDownUpSync(KeyEvent.KEYCODE_DPAD_CENTER) + } else { + composeRule.onNodeWithContentDescription(R.string.reset_to_defaults) + .performClick() + } + composeRule.waitUntil { + composeRule.onNodeWithText(R.string.long_press_menu_reset_to_defaults_confirm) + .isDisplayed() + } + } + + @Test + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.N) // setDisplaySize not available on API < 24 + fun testEveryActionAndHeaderExists1() { + // need a large screen to ensure all items are visible + onDevice().setDisplaySize(WidthSizeClass.EXPANDED, HeightSizeClass.EXPANDED) + setEditor(isHeaderEnabled = true, actionArrangement = LongPressAction.Type.entries.filter { it.id % 2 == 0 }) + assertHeaderAndAllActionsExist() + } + + @Test + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.N) // setDisplaySize not available on API < 24 + fun testEveryActionAndHeaderExists2() { + // need a large screen to ensure all items are visible + onDevice().setDisplaySize(WidthSizeClass.EXPANDED, HeightSizeClass.EXPANDED) + setEditor(isHeaderEnabled = false, actionArrangement = listOf()) + assertHeaderAndAllActionsExist() + } + + @Test + fun testResetButtonCancel() { + setEditor(isHeaderEnabled = false, actionArrangement = listOf(Enqueue, PlayWithKodi)) + openResetDialog(useDpad = false) + + assertActionEnabledStatus(label = Enqueue.label, expectedEnabled = true) + assertActionEnabledStatus(label = EnqueueNext.label, expectedEnabled = false) + assertActionEnabledStatus(label = R.string.long_press_menu_header, expectedEnabled = false) + composeRule.onNodeWithText(R.string.cancel) + .performClick() + assertActionEnabledStatus(label = Enqueue.label, expectedEnabled = true) + assertActionEnabledStatus(label = EnqueueNext.label, expectedEnabled = false) + assertActionEnabledStatus(label = R.string.long_press_menu_header, expectedEnabled = false) + closeEditorAndAssertNewSettings(isHeaderEnabled = false, actionArrangement = listOf(Enqueue, PlayWithKodi)) + } + + @Test + fun testResetButtonTapOutside() { + setEditor(isHeaderEnabled = true, actionArrangement = listOf(Enqueue)) + openResetDialog(useDpad = true) + + assertActionEnabledStatus(label = Enqueue.label, expectedEnabled = true) + assertActionEnabledStatus(label = EnqueueNext.label, expectedEnabled = false) + assertActionEnabledStatus(label = R.string.long_press_menu_header, expectedEnabled = true) + tapAtAbsoluteXY(200f, 200f) + assertActionEnabledStatus(label = Enqueue.label, expectedEnabled = true) + assertActionEnabledStatus(label = EnqueueNext.label, expectedEnabled = false) + assertActionEnabledStatus(label = R.string.long_press_menu_header, expectedEnabled = true) + closeEditorAndAssertNewSettings(isHeaderEnabled = true, actionArrangement = listOf(Enqueue)) + } + + @Test + fun testResetButtonPressBack() { + setEditor(isHeaderEnabled = false, actionArrangement = listOf()) + openResetDialog(useDpad = false) + + assertActionEnabledStatus(label = EnqueueNext.label, expectedEnabled = false) + assertActionEnabledStatus(label = R.string.long_press_menu_header, expectedEnabled = false) + Espresso.pressBack() + assertActionEnabledStatus(label = EnqueueNext.label, expectedEnabled = false) + assertActionEnabledStatus(label = R.string.long_press_menu_header, expectedEnabled = false) + closeEditorAndAssertNewSettings(isHeaderEnabled = false, actionArrangement = listOf()) + } + + @Test + fun testResetButtonOk() { + setEditor(isHeaderEnabled = false, actionArrangement = listOf(Enqueue, PlayWithKodi)) + openResetDialog(useDpad = true) + + assertActionEnabledStatus(label = Enqueue.label, expectedEnabled = true) + assertActionEnabledStatus(label = EnqueueNext.label, expectedEnabled = false) + assertActionEnabledStatus(label = R.string.long_press_menu_header, expectedEnabled = false) + composeRule.onNodeWithText(R.string.ok) + .performClick() + assertActionEnabledStatus(label = Enqueue.label, expectedEnabled = true) + assertActionEnabledStatus(label = EnqueueNext.label, expectedEnabled = true) + assertActionEnabledStatus(label = R.string.long_press_menu_header, expectedEnabled = true) + closeEditorAndAssertNewSettings(isHeaderEnabled = true, actionArrangement = getDefaultEnabledLongPressActions(ctx)) + } + + @Test + fun testDraggingItemToDisable() { + setEditor(isHeaderEnabled = false, actionArrangement = listOf(Enqueue, PlayWithKodi)) + assertActionEnabledStatus(label = R.string.long_press_menu_header, expectedEnabled = false) + assertActionEnabledStatus(label = Enqueue.label, expectedEnabled = true) + assertActionEnabledStatus(label = PlayWithKodi.label, expectedEnabled = true) + val kodiOriginalPos = composeRule.onNodeWithText(PlayWithKodi.label).fetchPosOnScreen() + + // long-press then move the Enqueue item down + composeRule.onNodeWithText(Enqueue.label) + .longPressThenMove(dy = { 3 * height }) + + // assert that only Enqueue was disabled + assertActionEnabledStatus(label = R.string.long_press_menu_header, expectedEnabled = false) + assertActionEnabledStatus(label = Enqueue.label, expectedEnabled = false) + assertActionEnabledStatus(label = PlayWithKodi.label, expectedEnabled = true) + + // assert that the Kodi item moved horizontally but not vertically + val kodiFinalPos = composeRule.onNodeWithText(PlayWithKodi.label).fetchPosOnScreen() + assertEquals(kodiOriginalPos.y, kodiFinalPos.y) + assertNotEquals(kodiOriginalPos.x, kodiFinalPos.x) + + // make sure the new setting is saved + closeEditorAndAssertNewSettings(isHeaderEnabled = false, actionArrangement = listOf(PlayWithKodi)) + } + + @Test + fun testDraggingHeaderToDisable() { + setEditor(isHeaderEnabled = true, actionArrangement = listOf(Enqueue, PlayWithKodi)) + assertActionEnabledStatus(label = R.string.long_press_menu_header, expectedEnabled = true) + assertActionEnabledStatus(label = Enqueue.label, expectedEnabled = true) + assertActionEnabledStatus(label = PlayWithKodi.label, expectedEnabled = true) + + // long-press then move the header down + composeRule.onNodeWithText(R.string.long_press_menu_header) + .longPressThenMove(dy = { 3 * height }) + + // assert that only the header was disabled + assertActionEnabledStatus(label = R.string.long_press_menu_header, expectedEnabled = false) + assertActionEnabledStatus(label = Enqueue.label, expectedEnabled = true) + assertActionEnabledStatus(label = PlayWithKodi.label, expectedEnabled = true) + + // make sure the new setting is saved + closeEditorAndAssertNewSettings(isHeaderEnabled = false, actionArrangement = listOf(Enqueue, PlayWithKodi)) + } + + @Test + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.N) // setDisplaySize not available on API < 24 + fun testDraggingItemWithoutLongPressOnlyScrolls() { + onDevice().setDisplaySize(WidthSizeClass.COMPACT, HeightSizeClass.COMPACT) + setEditor(isHeaderEnabled = false, actionArrangement = listOf(Enqueue, PlayWithKodi)) + + // scroll up by 3 pixels + val (originalY, finalY) = composeRule.onNodeWithTag("LongPressMenuEditorGrid") + .scrollVerticallyAndGetOriginalAndFinalY( + itemInsideScrollingContainer = composeRule.onNodeWithText(Enqueue.label), + startY = { bottom }, + endY = { bottom - 30 } + ) + assertInRange(originalY - 40, originalY - 20, finalY) + + // scroll back down by dragging on an item (without long pressing, longPressDurationMs = 0!) + assertActionEnabledStatus(label = Enqueue.label, expectedEnabled = true) + composeRule.onNodeWithText(Enqueue.label) + .longPressThenMove(dy = { 3 * height }, longPressDurationMs = 0) + assertActionEnabledStatus(label = Enqueue.label, expectedEnabled = true) + + // make sure that we are back to the original scroll state + val posAfterScrollingBack = composeRule.onNodeWithText(Enqueue.label).fetchPosOnScreen() + assertEquals(originalY, posAfterScrollingBack.y) + + // make sure that the item was not moved + closeEditorAndAssertNewSettings(isHeaderEnabled = false, actionArrangement = listOf(Enqueue, PlayWithKodi)) + } + + @Test + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.N) // setDisplaySize not available on API < 24 + fun testDraggingItemToBottomScrollsDown() { + onDevice().setDisplaySize(WidthSizeClass.COMPACT, HeightSizeClass.COMPACT) + setEditor(isHeaderEnabled = false, actionArrangement = listOf(Enqueue, PlayWithKodi)) + composeRule.onNodeWithText(PlayWithKodi.label) + .assertExists() + + // drag the Enqueue item to the bottom of the screen + val rootBottom = composeRule.onNodeWithTag("LongPressMenuEditorGrid") + .fetchSemanticsNode() + .boundsInWindow + .bottom + composeRule.onNodeWithText(Enqueue.label) + .longPressThenMove(dy = { (rootBottom - center.y).toInt() }) + + // the Kodi button does not exist anymore because the screen scrolled past it + composeRule.onNodeWithText(PlayWithKodi.label) + .assertDoesNotExist() + composeRule.onNodeWithText(Enqueue.label) + .assertExists() + + // make sure that Enqueue is now disabled + closeEditorAndAssertNewSettings(isHeaderEnabled = false, actionArrangement = listOf(PlayWithKodi)) + } + + @Test + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.N) // setDisplaySize not available on API < 24 + fun testDraggingItemToTopScrollsUp() { + onDevice().setDisplaySize(WidthSizeClass.COMPACT, HeightSizeClass.COMPACT) + setEditor(isHeaderEnabled = true, actionArrangement = listOf()) + composeRule.onNodeWithText(R.string.long_press_menu_enabled_actions_description) + .assertExists() + + // scroll grid to the bottom (hacky way to achieve so is to swipe up 10 times) + composeRule.onNodeWithTag("LongPressMenuEditorGrid") + .performTouchInput { swipeUp() } + // the enabled description does not exist anymore because the screen scrolled past it + composeRule.onNodeWithText(R.string.long_press_menu_enabled_actions_description) + .assertDoesNotExist() + + // find any action that is now visible on screen + val actionToDrag = LongPressAction.Type.entries + .first { composeRule.onNodeWithText(it.label).runCatching { isDisplayed() }.isSuccess } + + // drag it to the top of the screen (using a large dy since going out of the screen bounds + // does not invalidate the touch gesture) + composeRule.onNodeWithText(actionToDrag.label) + .longPressThenMove(dy = { -2000 }) + + // the enabled description now should exist again because the view scrolled back up + composeRule.onNodeWithText(R.string.long_press_menu_enabled_actions_description) + .assertExists() + composeRule.onNodeWithText(actionToDrag.label) + .assertExists() + + // make sure the actionToDrag is now enabled + closeEditorAndAssertNewSettings(isHeaderEnabled = true, actionArrangement = listOf(actionToDrag)) + } + + @Test + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.N) // setDisplaySize not available on API < 24 + fun testDpadScrolling() { + onDevice().setDisplaySize(WidthSizeClass.COMPACT, HeightSizeClass.COMPACT) + setEditor(isHeaderEnabled = false, actionArrangement = listOf(Enqueue, PlayWithKodi)) + + composeRule.onNodeWithText(Enqueue.label).assertExists() + repeat(20) { inst.sendKeyDownUpSync(KeyEvent.KEYCODE_DPAD_DOWN) } + composeRule.onNodeWithText(Enqueue.label).assertDoesNotExist() // scrolled down! + repeat(20) { inst.sendKeyDownUpSync(KeyEvent.KEYCODE_DPAD_UP) } + composeRule.onNodeWithText(Enqueue.label).assertExists() // scrolled back up! + } + + @Test + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.N) // setDisplaySize not available on API < 24 + fun testDpadScrollingWhileDraggingHeader() { + onDevice().setDisplaySize(WidthSizeClass.COMPACT, HeightSizeClass.COMPACT) + setEditor(isHeaderEnabled = true, actionArrangement = listOf(Enqueue, PlayWithKodi)) + composeRule.onNodeWithText(R.string.detail_drag_description) + .assertDoesNotExist() + + // grab the header which is always in top left + inst.sendKeyDownUpSync(KeyEvent.KEYCODE_DPAD_DOWN) + inst.sendKeyDownUpSync(KeyEvent.KEYCODE_DPAD_DOWN) + inst.sendKeyDownUpSync(KeyEvent.KEYCODE_DPAD_DOWN) + inst.sendKeyDownUpSync(KeyEvent.KEYCODE_DPAD_CENTER) + + // ensure that the header was picked up by checking the presence of the placeholder + composeRule.onNodeWithText(R.string.detail_drag_description) + .assertExists() + + // same checks as in testDpadScrolling + composeRule.onNodeWithText(Enqueue.label).assertExists() + repeat(20) { inst.sendKeyDownUpSync(KeyEvent.KEYCODE_DPAD_DOWN) } + composeRule.onNodeWithText(Enqueue.label).assertDoesNotExist() // scrolled down! + repeat(20) { inst.sendKeyDownUpSync(KeyEvent.KEYCODE_DPAD_UP) } + composeRule.onNodeWithText(Enqueue.label).assertExists() // scrolled back up! + } + + @Test + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.N) // setDisplaySize not available on API < 24 + fun testDpadDraggingHeader() { + onDevice().setDisplaySize(WidthSizeClass.COMPACT, HeightSizeClass.MEDIUM) + setEditor(isHeaderEnabled = true, actionArrangement = listOf(Enqueue, PlayWithKodi)) + val originalHeaderPos = composeRule.onNodeWithText(R.string.long_press_menu_header).fetchPosOnScreen() + composeRule.onNodeWithText(R.string.detail_drag_description) + .assertDoesNotExist() + + // grab the header which is always in top left + inst.sendKeyDownUpSync(KeyEvent.KEYCODE_DPAD_DOWN) + inst.sendKeyDownUpSync(KeyEvent.KEYCODE_DPAD_DOWN) + inst.sendKeyDownUpSync(KeyEvent.KEYCODE_DPAD_DOWN) + inst.sendKeyDownUpSync(KeyEvent.KEYCODE_DPAD_CENTER) + + // the header was grabbed and is thus in an offset position + val dragHeaderPos = composeRule.onNodeWithText(R.string.long_press_menu_header).fetchPosOnScreen() + assertNotEquals(originalHeaderPos.x, dragHeaderPos.x) + assertNotEquals(originalHeaderPos.y, dragHeaderPos.y) + + // ensure that the header was picked up by checking the presence of the placeholder + composeRule.onNodeWithText(R.string.detail_drag_description) + .assertExists() + + // move down a few times and release + inst.sendKeyDownUpSync(KeyEvent.KEYCODE_DPAD_DOWN) + inst.sendKeyDownUpSync(KeyEvent.KEYCODE_DPAD_DOWN) + inst.sendKeyDownUpSync(KeyEvent.KEYCODE_DPAD_DOWN) + inst.sendKeyDownUpSync(KeyEvent.KEYCODE_DPAD_CENTER) + + // the header was released in yet another position + val endHeaderPos = composeRule.onNodeWithText(R.string.long_press_menu_header).fetchPosOnScreen() + assertEquals(originalHeaderPos.x, endHeaderPos.x) // always first item + assertNotEquals(originalHeaderPos.y, endHeaderPos.y) + assertNotEquals(dragHeaderPos.x, endHeaderPos.x) + assertNotEquals(dragHeaderPos.y, endHeaderPos.y) + + // make sure the header is now disabled + closeEditorAndAssertNewSettings(isHeaderEnabled = false, actionArrangement = listOf(Enqueue, PlayWithKodi)) + } + + @Test + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.N) // setDisplaySize not available on API < 24 + fun testDpadDraggingItem() { + onDevice().setDisplaySize(WidthSizeClass.COMPACT, HeightSizeClass.MEDIUM) + setEditor(isHeaderEnabled = true, actionArrangement = listOf(Enqueue, PlayWithKodi)) + val originalHeaderPos = composeRule.onNodeWithText(R.string.long_press_menu_header).fetchPosOnScreen() + composeRule.onNodeWithText(R.string.detail_drag_description) + .assertDoesNotExist() + + // grab the Enqueue item which is just right of the header which is always in top left + inst.sendKeyDownUpSync(KeyEvent.KEYCODE_DPAD_DOWN) + inst.sendKeyDownUpSync(KeyEvent.KEYCODE_DPAD_DOWN) + inst.sendKeyDownUpSync(KeyEvent.KEYCODE_DPAD_DOWN) + inst.sendKeyDownUpSync(KeyEvent.KEYCODE_DPAD_RIGHT) + inst.sendKeyDownUpSync(KeyEvent.KEYCODE_DPAD_CENTER) + + // ensure the header was not picked up by checking that it is still in the same position + // (though the y might have changed because of scrolling) + val dragHeaderPos = composeRule.onNodeWithText(R.string.long_press_menu_header).fetchPosOnScreen() + assertEquals(originalHeaderPos.x, dragHeaderPos.x) + + // ensure that the Enqueue item was picked up by checking the presence of the placeholder + composeRule.onNodeWithText(R.string.detail_drag_description) + .assertExists() + + // move down a few times and release + inst.sendKeyDownUpSync(KeyEvent.KEYCODE_DPAD_DOWN) + inst.sendKeyDownUpSync(KeyEvent.KEYCODE_DPAD_DOWN) + inst.sendKeyDownUpSync(KeyEvent.KEYCODE_DPAD_DOWN) + inst.sendKeyDownUpSync(KeyEvent.KEYCODE_DPAD_CENTER) + + // make sure the Enqueue item is now disabled + closeEditorAndAssertNewSettings(isHeaderEnabled = true, actionArrangement = listOf(PlayWithKodi)) + } + + @Test + fun testNoneMarkerIsShownIfNoItemsEnabled() { + setEditor(isHeaderEnabled = false, actionArrangement = listOf()) + assertActionEnabledStatus(R.string.none, true) + } + + @Test + fun testNoneMarkerIsShownIfNoItemsDisabled() { + setEditor(isHeaderEnabled = true, actionArrangement = LongPressAction.Type.entries) + // scroll grid to the bottom (hacky way to achieve so is to swipe up 10 times) + composeRule.onNodeWithTag("LongPressMenuEditorGrid") + .performTouchInput { repeat(10) { swipeUp() } } + assertActionEnabledStatus(R.string.none, false) + } + + @Test + fun testDpadReordering() { + setEditor(isHeaderEnabled = true, actionArrangement = listOf(Enqueue, PlayWithKodi)) + + // grab the Enqueue item which is just right of the header which is always in top left + inst.sendKeyDownUpSync(KeyEvent.KEYCODE_DPAD_DOWN) + inst.sendKeyDownUpSync(KeyEvent.KEYCODE_DPAD_DOWN) + inst.sendKeyDownUpSync(KeyEvent.KEYCODE_DPAD_DOWN) + inst.sendKeyDownUpSync(KeyEvent.KEYCODE_DPAD_RIGHT) + inst.sendKeyDownUpSync(KeyEvent.KEYCODE_DPAD_CENTER) + + // move the item right (past PlayWithKodi) and release it + inst.sendKeyDownUpSync(KeyEvent.KEYCODE_DPAD_RIGHT) + inst.sendKeyDownUpSync(KeyEvent.KEYCODE_DPAD_CENTER) + + // the items now should have swapped + closeEditorAndAssertNewSettings(isHeaderEnabled = true, actionArrangement = listOf(PlayWithKodi, Enqueue)) + } + + @Test + fun testDpadHeaderIsAlwaysInFirstPosition() { + setEditor(isHeaderEnabled = true, actionArrangement = LongPressAction.Type.entries) + val originalHeaderPos = composeRule.onNodeWithText(R.string.long_press_menu_header).fetchPosOnScreen() + + // grab the header which is always in top left + inst.sendKeyDownUpSync(KeyEvent.KEYCODE_DPAD_DOWN) + inst.sendKeyDownUpSync(KeyEvent.KEYCODE_DPAD_DOWN) + inst.sendKeyDownUpSync(KeyEvent.KEYCODE_DPAD_DOWN) + inst.sendKeyDownUpSync(KeyEvent.KEYCODE_DPAD_CENTER) + + // the header was grabbed and is thus in an offset position + val dragHeaderPos = composeRule.onNodeWithText(R.string.long_press_menu_header).fetchPosOnScreen() + assertNotEquals(originalHeaderPos.x, dragHeaderPos.x) + assertNotEquals(originalHeaderPos.y, dragHeaderPos.y) + + // even after moving the header around through the enabled actions ... + inst.sendKeyDownUpSync(KeyEvent.KEYCODE_DPAD_DOWN) + inst.sendKeyDownUpSync(KeyEvent.KEYCODE_DPAD_DOWN) + inst.sendKeyDownUpSync(KeyEvent.KEYCODE_DPAD_RIGHT) + inst.sendKeyDownUpSync(KeyEvent.KEYCODE_DPAD_CENTER) + + // ... after releasing it its position will still be the original + val endHeaderPos = composeRule.onNodeWithText(R.string.long_press_menu_header).fetchPosOnScreen() + assertEquals(originalHeaderPos.x, endHeaderPos.x) + assertEquals(originalHeaderPos.y, endHeaderPos.y) + + // nothing should have changed + closeEditorAndAssertNewSettings(isHeaderEnabled = true, actionArrangement = LongPressAction.Type.entries) + } + + @Test + fun testTouchReordering() { + setEditor(isHeaderEnabled = false, actionArrangement = listOf(Enqueue, PlayWithKodi)) + + // move the Enqueue item to the right + composeRule.onNodeWithText(Enqueue.label) + .longPressThenMove(dx = { 200.dp.value.toInt() }) + + // the items now should have swapped + closeEditorAndAssertNewSettings(isHeaderEnabled = false, actionArrangement = listOf(PlayWithKodi, Enqueue)) + } + + @Test + fun testTouchHeaderIsAlwaysInFirstPosition() { + setEditor(isHeaderEnabled = true, actionArrangement = LongPressAction.Type.entries) + val originalHeaderPos = composeRule.onNodeWithText(R.string.long_press_menu_header).fetchPosOnScreen() + + // grab the header and move it around through the enabled actions + composeRule.onNodeWithText(R.string.long_press_menu_header) + .longPressThenMove(dx = { 2 * width }, dy = { 2 * height }) + + // after releasing it its position will still be the original + val endHeaderPos = composeRule.onNodeWithText(R.string.long_press_menu_header).fetchPosOnScreen() + assertEquals(originalHeaderPos.x, endHeaderPos.x) + assertEquals(originalHeaderPos.y, endHeaderPos.y) + + // nothing should have changed + closeEditorAndAssertNewSettings(isHeaderEnabled = true, actionArrangement = LongPressAction.Type.entries) + } +} diff --git a/app/src/androidTest/java/org/schabi/newpipe/ui/components/menu/LongPressMenuTest.kt b/app/src/androidTest/java/org/schabi/newpipe/ui/components/menu/LongPressMenuTest.kt index e497adffd..b0153cdcc 100644 --- a/app/src/androidTest/java/org/schabi/newpipe/ui/components/menu/LongPressMenuTest.kt +++ b/app/src/androidTest/java/org/schabi/newpipe/ui/components/menu/LongPressMenuTest.kt @@ -21,11 +21,10 @@ import androidx.compose.ui.test.isNotDisplayed import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.onAllNodesWithTag import androidx.compose.ui.test.onFirst +import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick -import androidx.compose.ui.test.performTouchInput -import androidx.compose.ui.test.swipeUp import androidx.compose.ui.unit.dp import androidx.test.espresso.Espresso import androidx.test.espresso.Espresso.onView @@ -44,19 +43,23 @@ import java.time.OffsetDateTime import java.time.temporal.ChronoUnit import kotlinx.coroutines.delay import org.junit.Assert.assertEquals -import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test import org.junit.rules.RuleChain import org.junit.rules.TestRule import org.junit.runner.RunWith import org.schabi.newpipe.R +import org.schabi.newpipe.assertDidNotMove import org.schabi.newpipe.assertInRange +import org.schabi.newpipe.assertMoved import org.schabi.newpipe.assertNotInRange import org.schabi.newpipe.ctx import org.schabi.newpipe.extractor.stream.StreamType import org.schabi.newpipe.onNodeWithContentDescription import org.schabi.newpipe.onNodeWithText +import org.schabi.newpipe.scrollVerticallyAndGetOriginalAndFinalY +import org.schabi.newpipe.tapAtAbsoluteXY import org.schabi.newpipe.ui.components.menu.LongPressAction.Type.BackgroundShuffled import org.schabi.newpipe.ui.components.menu.LongPressAction.Type.Enqueue import org.schabi.newpipe.ui.components.menu.LongPressAction.Type.PlayWithKodi @@ -135,7 +138,8 @@ class LongPressMenuTest { .isDisplayed() } - Espresso.pressBack() + composeRule.onNodeWithContentDescription(R.string.back) + .performClick() composeRule.waitUntil { composeRule.onNodeWithText(R.string.long_press_menu_enabled_actions_description) .isNotDisplayed() @@ -458,14 +462,16 @@ class LongPressMenuTest { fun assertOnlyAndAllArrangedActionsDisplayed( availableActions: List, actionArrangement: List, - expectedShownActions: List + expectedShownActions: List, + onDismissRequest: () -> Unit = {} ) { setLongPressMenu( longPressActions = availableActions.map { LongPressAction(it) {} }, // whether the header is enabled or not shouldn't influence the result, so enable it // at random (but still deterministically) isHeaderEnabled = ((expectedShownActions + availableActions).sumOf { it.id } % 2) == 0, - actionArrangement = actionArrangement + actionArrangement = actionArrangement, + onDismissRequest = onDismissRequest ) for (type in LongPressAction.Type.entries) { composeRule.onNodeWithText(type.label) @@ -563,54 +569,58 @@ class LongPressMenuTest { @Test fun testFewActionsOnNormalScreenAreNotScrollable() { + var dismissedCount = 0 assertOnlyAndAllArrangedActionsDisplayed( availableActions = listOf(ShowDetails, ShowChannelDetails), actionArrangement = listOf(ShowDetails, ShowChannelDetails), - expectedShownActions = listOf(ShowDetails, ShowChannelDetails) + expectedShownActions = listOf(ShowDetails, ShowChannelDetails), + onDismissRequest = { dismissedCount += 1 } ) // try to scroll and confirm that items don't move because the menu is not overflowing the // screen height composeRule.onNodeWithTag("LongPressMenuGrid") .assert(hasScrollAction()) - val originalPosition = composeRule.onNodeWithText(ShowDetails.label) - .fetchSemanticsNode() - .positionOnScreen - composeRule.onNodeWithTag("LongPressMenuGrid") - .performTouchInput { swipeUp() } - val finalPosition = composeRule.onNodeWithText(ShowDetails.label) - .fetchSemanticsNode() - .positionOnScreen - assertEquals(originalPosition, finalPosition) + .scrollVerticallyAndGetOriginalAndFinalY(composeRule.onNodeWithText(ShowDetails.label)) + .assertDidNotMove() + + // also test that clicking on the top of the screen does not close the dialog because it + // spans all of the screen + tapAtAbsoluteXY(100f, 100f) + composeRule.waitUntil { dismissedCount == 1 } } @Test @SdkSuppress(minSdkVersion = Build.VERSION_CODES.N) // setDisplaySize not available on API < 24 fun testAllActionsOnSmallScreenAreScrollable() { onDevice().setDisplaySize(WidthSizeClass.COMPACT, HeightSizeClass.COMPACT) + var dismissedCount = 0 assertOnlyAndAllArrangedActionsDisplayed( availableActions = LongPressAction.Type.entries, actionArrangement = LongPressAction.Type.entries, - expectedShownActions = LongPressAction.Type.entries + expectedShownActions = LongPressAction.Type.entries, + onDismissRequest = { dismissedCount += 1 } ) val anItemIsNotVisible = LongPressAction.Type.entries.any { composeRule.onNodeWithText(it.label).isNotDisplayed() } - assertEquals(true, anItemIsNotVisible) + assertTrue(anItemIsNotVisible) // try to scroll and confirm that items move composeRule.onNodeWithTag("LongPressMenuGrid") .assert(hasScrollAction()) - val originalPosition = composeRule.onNodeWithText(Enqueue.label) + .scrollVerticallyAndGetOriginalAndFinalY(composeRule.onNodeWithText(Enqueue.label)) + .assertMoved() + + // also test that clicking on the top of the screen does not close the dialog because it + // spans all of the screen (tap just above the grid bounds on the drag handle, to avoid + // clicking on an action that would close the dialog) + val gridBounds = composeRule.onNodeWithTag("LongPressMenuGrid") .fetchSemanticsNode() - .positionOnScreen - composeRule.onNodeWithTag("LongPressMenuGrid") - .performTouchInput { swipeUp() } - val finalPosition = composeRule.onNodeWithText(Enqueue.label) - .fetchSemanticsNode() - .positionOnScreen - assertNotEquals(originalPosition, finalPosition) + .boundsInWindow + tapAtAbsoluteXY(gridBounds.center.x, gridBounds.top - 1) + assertTrue(composeRule.runCatching { waitUntil { dismissedCount == 1 } }.isFailure) } @Test 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 6d755984e..a0c898185 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 @@ -63,6 +63,7 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.tooling.preview.Preview @@ -132,7 +133,8 @@ fun LongPressMenuEditorPage(onBackClick: () -> Unit) { ) // `.focusTarget().onKeyEvent()` handles DPAD on Android TVs .focusTarget() - .onKeyEvent { event -> state.onKeyEvent(event, columns) }, + .onKeyEvent { event -> state.onKeyEvent(event, columns) } + .testTag("LongPressMenuEditorGrid"), // same width as the LongPressMenu columns = GridCells.Adaptive(MinButtonWidth), userScrollEnabled = false, @@ -202,7 +204,7 @@ private fun ResetToDefaultsButton(onClick: () -> Unit) { TooltipIconButton( onClick = { showDialog = true }, icon = Icons.Default.RestartAlt, - contentDescription = stringResource(R.string.playback_reset) + contentDescription = stringResource(R.string.reset_to_defaults) ) } From 34d4eae47b56a6e9711b4c4b1c6f75f5b750c993 Mon Sep 17 00:00:00 2001 From: Stypox Date: Wed, 11 Feb 2026 01:39:10 +0100 Subject: [PATCH 85/87] Finish adding documentation to all files --- app/build.gradle.kts | 2 + .../menu/LongPressMenuEditorTest.kt | 2 +- .../menu/LongPressMenuSettingsTest.kt | 8 +- .../ui/components/menu/LongPressMenuTest.kt | 10 +- .../org/schabi/newpipe/ui/GestureModifiers.kt | 8 +- .../ui/components/menu/LongPressMenu.kt | 6 +- .../ui/components/menu/LongPressMenuEditor.kt | 102 +++++--- .../menu/LongPressMenuEditorState.kt | 246 +++++++++++++----- .../components/menu/LongPressMenuSettings.kt | 23 +- .../ui/components/menu/SparseItemUtil.kt | 7 +- .../schabi/newpipe/ui/theme/CustomColors.kt | 14 +- .../java/org/schabi/newpipe/util/Either.kt | 24 +- .../newpipe/util/text/FadedMarqueeModifier.kt | 16 +- app/src/main/res/values/strings.xml | 6 +- .../org/schabi/newpipe/util/EitherTest.kt | 43 +++ 15 files changed, 375 insertions(+), 142 deletions(-) create mode 100644 app/src/test/java/org/schabi/newpipe/util/EitherTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 3a7aaecdb..36a8a0c4a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -55,7 +55,9 @@ configure { System.getProperty("versionNameSuffix")?.let { versionNameSuffix = it } testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + // https://blog.grobox.de/2019/disable-google-android-instrumentation-test-tracking/ testInstrumentationRunnerArguments["disableAnalytics"] = "true" + // https://developer.android.com/studio/test/espresso-api#set_up_your_project_for_the_espresso_device_api testOptions { emulatorControl { enable = true diff --git a/app/src/androidTest/java/org/schabi/newpipe/ui/components/menu/LongPressMenuEditorTest.kt b/app/src/androidTest/java/org/schabi/newpipe/ui/components/menu/LongPressMenuEditorTest.kt index 190bc6861..adebf3cdc 100644 --- a/app/src/androidTest/java/org/schabi/newpipe/ui/components/menu/LongPressMenuEditorTest.kt +++ b/app/src/androidTest/java/org/schabi/newpipe/ui/components/menu/LongPressMenuEditorTest.kt @@ -261,7 +261,7 @@ class LongPressMenuEditorTest { assertActionEnabledStatus(label = Enqueue.label, expectedEnabled = true) assertActionEnabledStatus(label = EnqueueNext.label, expectedEnabled = true) assertActionEnabledStatus(label = R.string.long_press_menu_header, expectedEnabled = true) - closeEditorAndAssertNewSettings(isHeaderEnabled = true, actionArrangement = getDefaultEnabledLongPressActions(ctx)) + closeEditorAndAssertNewSettings(isHeaderEnabled = true, actionArrangement = getDefaultLongPressActionArrangement(ctx)) } @Test diff --git a/app/src/androidTest/java/org/schabi/newpipe/ui/components/menu/LongPressMenuSettingsTest.kt b/app/src/androidTest/java/org/schabi/newpipe/ui/components/menu/LongPressMenuSettingsTest.kt index 8ebb78993..15706de71 100644 --- a/app/src/androidTest/java/org/schabi/newpipe/ui/components/menu/LongPressMenuSettingsTest.kt +++ b/app/src/androidTest/java/org/schabi/newpipe/ui/components/menu/LongPressMenuSettingsTest.kt @@ -44,13 +44,13 @@ class LongPressMenuSettingsTest { @Test fun testLoadingActionArrangementUnset() { clearPrefs() - assertEquals(getDefaultEnabledLongPressActions(ctx), loadLongPressActionArrangementFromSettings(ctx)) + assertEquals(getDefaultLongPressActionArrangement(ctx), loadLongPressActionArrangementFromSettings(ctx)) } @Test fun testLoadingActionArrangementInvalid() { putStringInPrefs(R.string.long_press_menu_action_arrangement_key, "0,1,whatever,3") - assertEquals(getDefaultEnabledLongPressActions(ctx), loadLongPressActionArrangementFromSettings(ctx)) + assertEquals(getDefaultLongPressActionArrangement(ctx), loadLongPressActionArrangementFromSettings(ctx)) } @Test @@ -73,7 +73,7 @@ class LongPressMenuSettingsTest { fun testDefaultActionsIncludeKodiIffShowKodiEnabled() { for (enabled in arrayOf(false, true)) { putBooleanInPrefs(R.string.show_play_with_kodi_key, enabled) - val actions = getDefaultEnabledLongPressActions(ctx) + val actions = getDefaultLongPressActionArrangement(ctx) assertEquals(enabled, actions.contains(PlayWithKodi)) } } @@ -85,7 +85,7 @@ class LongPressMenuSettingsTest { for (actions in listOf(listOf(Enqueue), listOf(Enqueue, PlayWithKodi))) { storeLongPressActionArrangementToSettings(ctx, actions) addOrRemoveKodiLongPressAction(ctx) - val newActions = getDefaultEnabledLongPressActions(ctx) + val newActions = getDefaultLongPressActionArrangement(ctx) assertEquals(enabled, newActions.contains(PlayWithKodi)) } } diff --git a/app/src/androidTest/java/org/schabi/newpipe/ui/components/menu/LongPressMenuTest.kt b/app/src/androidTest/java/org/schabi/newpipe/ui/components/menu/LongPressMenuTest.kt index b0153cdcc..fbf2c491e 100644 --- a/app/src/androidTest/java/org/schabi/newpipe/ui/components/menu/LongPressMenuTest.kt +++ b/app/src/androidTest/java/org/schabi/newpipe/ui/components/menu/LongPressMenuTest.kt @@ -21,12 +21,10 @@ import androidx.compose.ui.test.isNotDisplayed import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.onAllNodesWithTag import androidx.compose.ui.test.onFirst -import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.unit.dp -import androidx.test.espresso.Espresso import androidx.test.espresso.Espresso.onView import androidx.test.espresso.assertion.ViewAssertions.doesNotExist import androidx.test.espresso.assertion.ViewAssertions.matches @@ -508,8 +506,8 @@ class LongPressMenuTest { fun testOnlyAndAllArrangedActionsDisplayed3() { assertOnlyAndAllArrangedActionsDisplayed( availableActions = LongPressAction.Type.entries, - actionArrangement = getDefaultEnabledLongPressActions(ctx), - expectedShownActions = getDefaultEnabledLongPressActions(ctx) + actionArrangement = getDefaultLongPressActionArrangement(ctx), + expectedShownActions = getDefaultLongPressActionArrangement(ctx) ) } @@ -534,9 +532,9 @@ class LongPressMenuTest { @Test fun testOnlyAndAllAvailableActionsDisplayed3() { assertOnlyAndAllArrangedActionsDisplayed( - availableActions = getDefaultEnabledLongPressActions(ctx), + availableActions = getDefaultLongPressActionArrangement(ctx), actionArrangement = LongPressAction.Type.entries, - expectedShownActions = getDefaultEnabledLongPressActions(ctx) + expectedShownActions = getDefaultLongPressActionArrangement(ctx) ) } diff --git a/app/src/main/java/org/schabi/newpipe/ui/GestureModifiers.kt b/app/src/main/java/org/schabi/newpipe/ui/GestureModifiers.kt index 2fe995823..d5bfd3634 100644 --- a/app/src/main/java/org/schabi/newpipe/ui/GestureModifiers.kt +++ b/app/src/main/java/org/schabi/newpipe/ui/GestureModifiers.kt @@ -67,15 +67,17 @@ fun Modifier.detectDragGestures( return@withTimeout false } } catch (_: PointerEventTimeoutCancellationException) { - true + true // the timeout fired, so the "press" is indeed "long" } val pointerId = down.id + // importantly, tell `beginDragGesture` whether the drag begun with a long press beginDragGesture(down.position.toIntOffset(), wasLongPressed) while (true) { + // go through all events of this gesture and feed them to `handleDragGestureChange` val change = awaitPointerEvent().changes.find { it.id == pointerId } if (change == null || !change.pressed) { - break + break // the gesture finished } handleDragGestureChange( change.position.toIntOffset(), @@ -97,7 +99,7 @@ private fun Offset.toIntOffset() = IntOffset(this.x.toInt(), this.y.toInt()) fun Modifier.discardAllTouchesIf(doDiscard: Boolean) = if (doDiscard) { pointerInput(Unit) { awaitPointerEventScope { - // we should wait for all new pointer events + // we should wait for all new pointer events and ignore them all while (true) { awaitPointerEvent(pass = PointerEventPass.Initial) .changes 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 d760e52af..5ee0ad0b1 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 @@ -832,11 +832,7 @@ private fun LongPressMenuPreview( onDispose {} } - // 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) { + AppTheme { Surface(color = MaterialTheme.colorScheme.surfaceContainerLow) { // longPressable is null when running the preview in an emulator for some reason... @Suppress("USELESS_ELVIS") 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 a0c898185..ac348d02c 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 @@ -1,17 +1,7 @@ /* - * 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. + * SPDX-FileCopyrightText: 2022-2025 The FlorisBoard Contributors + * SPDX-FileCopyrightText: 2026 NewPipe e.V. + * SPDX-License-Identifier: Apache-2.0 AND GPL-3.0-or-later */ @file:OptIn(ExperimentalMaterial3Api::class) @@ -82,6 +72,10 @@ import org.schabi.newpipe.ui.theme.AppTheme import org.schabi.newpipe.util.text.FixedHeightCenteredText /** + * An editor for the actions shown in the [LongPressMenu], that also allows enabling or disabling + * the header. It allows the user to arrange the actions in any way, and to disable them by dragging + * them to a disabled section. + * * When making changes to this composable and to [LongPressMenuEditorState], make sure to test the * following use cases, and check that they still work: * - both the actions and the header can be dragged around @@ -94,7 +88,8 @@ import org.schabi.newpipe.util.text.FixedHeightCenteredText * offset to ensure the user can see the thing being dragged under their finger) * - when the view does not fit the page, it is possible to scroll without moving any item, and * dragging an item towards the top/bottom of the page scrolls up/down - * @author This composable was originally copied from FlorisBoard. + * + * @author This composable was originally copied from FlorisBoard, but was modified significantly. */ @Composable fun LongPressMenuEditorPage(onBackClick: () -> Unit) { @@ -107,6 +102,7 @@ fun LongPressMenuEditorPage(onBackClick: () -> Unit) { DisposableEffect(Unit) { onDispose { + // saves to settings the action arrangement and whether the header is enabled state.onDispose(context) } } @@ -118,7 +114,8 @@ fun LongPressMenuEditorPage(onBackClick: () -> Unit) { ResetToDefaultsButton { state.resetToDefaults(context) } } ) { paddingValues -> - // test scrolling on Android TV by adding `.padding(horizontal = 350.dp)` here + // if you want to forcefully "make the screen smaller" to test scrolling on Android TVs with + // DPAD, add `.padding(horizontal = 350.dp)` here BoxWithConstraints(Modifier.padding(paddingValues)) { // 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()) @@ -126,10 +123,11 @@ fun LongPressMenuEditorPage(onBackClick: () -> Unit) { LazyVerticalGrid( modifier = Modifier .safeDrawingPadding() + // `.detectDragGestures()` handles touch gestures on phones/tablets .detectDragGestures( - beginDragGesture = state::beginDragGesture, - handleDragGestureChange = state::handleDragGestureChange, - endDragGesture = state::completeDragGestureAndCleanUp + beginDragGesture = state::beginDragTouch, + handleDragGestureChange = state::handleDragChangeTouch, + endDragGesture = state::completeDragAndCleanUp ) // `.focusTarget().onKeyEvent()` handles DPAD on Android TVs .focusTarget() @@ -137,6 +135,9 @@ fun LongPressMenuEditorPage(onBackClick: () -> Unit) { .testTag("LongPressMenuEditorGrid"), // same width as the LongPressMenu columns = GridCells.Adaptive(MinButtonWidth), + // Scrolling is handled manually through `.detectDragGestures` above: if the user + // long-presses an item and then moves the finger, the item itself moves; otherwise, + // if the click is too short or the user didn't click on an item, the view scrolls. userScrollEnabled = false, state = gridState ) { @@ -147,25 +148,26 @@ fun LongPressMenuEditorPage(onBackClick: () -> Unit) { ) { i, item -> ItemInListUi( item = item, - selected = state.currentlyFocusedItem == i, + focused = state.currentlyFocusedItem == i, // We only want placement animations: fade in/out animations interfere with // items being replaced by a drag marker while being dragged around, and a // fade in/out animation there does not make sense as the item was just - // "picked up". Furthermore there are strange moving animation artifacts + // "picked up". Furthermore there were strange moving animation artifacts // when moving and releasing items quickly before their fade-out animation - // finishes. + // finishes, so it looks much more polished without fade in/out animations. modifier = Modifier.animateItem(fadeInSpec = null, fadeOutSpec = null) ) } } state.activeDragItem?.let { activeDragItem -> - // draw it the same size as the selected item, + // draw it the same size as the selected item, so it properly appears that the user + // picked up the item and is controlling it with their finger val size = with(LocalDensity.current) { remember(state.activeDragSize) { state.activeDragSize.toSize().toDpSize() } } ItemInListUi( item = activeDragItem, - selected = true, + focused = true, modifier = Modifier .size(size) .offset { state.activeDragPosition } @@ -177,8 +179,12 @@ fun LongPressMenuEditorPage(onBackClick: () -> Unit) { } } +/** + * A button that when clicked opens a confirmation dialog, and then calls [doReset] to reset the + * actions arrangement and whether the header is enabled to their default values. + */ @Composable -private fun ResetToDefaultsButton(onClick: () -> Unit) { +private fun ResetToDefaultsButton(doReset: () -> Unit) { var showDialog by rememberSaveable { mutableStateOf(false) } if (showDialog) { @@ -187,7 +193,7 @@ private fun ResetToDefaultsButton(onClick: () -> Unit) { text = { Text(stringResource(R.string.long_press_menu_reset_to_defaults_confirm)) }, confirmButton = { TextButton(onClick = { - onClick() + doReset() showDialog = false }) { Text(stringResource(R.string.ok)) @@ -208,9 +214,13 @@ private fun ResetToDefaultsButton(onClick: () -> Unit) { ) } +/** + * Renders either [ItemInList.EnabledCaption] or [ItemInList.HiddenCaption], i.e. the full-width + * captions separating enabled and hidden items in the list. + */ @Composable -private fun Subheader( - selected: Boolean, +private fun Caption( + focused: Boolean, @StringRes title: Int, @StringRes description: Int, modifier: Modifier = Modifier @@ -219,7 +229,7 @@ private fun Subheader( modifier = modifier .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 12.dp) - .letIf(selected) { border(2.dp, LocalContentColor.current) } + .letIf(focused) { border(2.dp, LocalContentColor.current) } ) { Text( text = stringResource(title), @@ -233,9 +243,13 @@ private fun Subheader( } } +/** + * Renders all [ItemInList] except captions, that is, all items using a slot of the grid (or two + * horizontal slots in case of the header). + */ @Composable private fun ActionOrHeaderBox( - selected: Boolean, + focused: Boolean, icon: ImageVector, @StringRes text: Int, contentColor: Color, @@ -247,7 +261,7 @@ private fun ActionOrHeaderBox( color = backgroundColor, contentColor = contentColor, shape = MaterialTheme.shapes.large, - border = BorderStroke(2.dp, contentColor.copy(alpha = 1f)).takeIf { selected }, + border = BorderStroke(2.dp, contentColor.copy(alpha = 1f)).takeIf { focused }, modifier = modifier.padding( horizontal = horizontalPadding, vertical = 5.dp @@ -268,26 +282,32 @@ private fun ActionOrHeaderBox( } } +/** + * @param item the [ItemInList] to render using either [Caption] or [ActionOrHeaderBox] with + * different parameters + * @param focused if `true`, a box will be drawn around the item to indicate that it is focused + * (this will only ever be `true` when the user is navigating with DPAD, e.g. on Android TVs) + */ @Composable private fun ItemInListUi( item: ItemInList, - selected: Boolean, + focused: Boolean, modifier: Modifier = Modifier ) { when (item) { ItemInList.EnabledCaption -> { - Subheader( + Caption( modifier = modifier, - selected = selected, + focused = focused, title = R.string.long_press_menu_enabled_actions, description = R.string.long_press_menu_enabled_actions_description ) } ItemInList.HiddenCaption -> { - Subheader( + Caption( modifier = modifier, - selected = selected, + focused = focused, title = R.string.long_press_menu_hidden_actions, description = R.string.long_press_menu_hidden_actions_description ) @@ -296,7 +316,7 @@ private fun ItemInListUi( is ItemInList.Action -> { ActionOrHeaderBox( modifier = modifier, - selected = selected, + focused = focused, icon = item.type.icon, text = item.type.label, contentColor = MaterialTheme.colorScheme.onSurface @@ -306,7 +326,7 @@ private fun ItemInListUi( ItemInList.HeaderBox -> { ActionOrHeaderBox( modifier = modifier, - selected = selected, + focused = focused, icon = Icons.Default.ArtTrack, text = R.string.long_press_menu_header, contentColor = MaterialTheme.colorScheme.onSurfaceVariant, @@ -318,7 +338,7 @@ private fun ItemInListUi( ItemInList.NoneMarker -> { ActionOrHeaderBox( modifier = modifier, - selected = selected, + focused = focused, icon = Icons.Default.Close, text = R.string.none, // 0.38f is the same alpha that the Material3 library applies for disabled buttons @@ -329,9 +349,11 @@ private fun ItemInListUi( is ItemInList.DragMarker -> { ActionOrHeaderBox( modifier = modifier, - selected = selected, + focused = focused, icon = Icons.Default.DragHandle, text = R.string.detail_drag_description, + // this should be just barely visible, we could even decide to hide it completely + // at some point, since it doesn't provide much of a useful hint contentColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f) ) } @@ -361,7 +383,7 @@ private fun QuickActionButtonPreview( Surface { ItemInListUi( item = itemInList, - selected = itemInList.stableUniqueKey() % 2 == 0, + focused = itemInList.stableUniqueKey() % 2 == 0, modifier = Modifier.width(MinButtonWidth * (itemInList.columnSpan ?: 4)) ) } diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressMenuEditorState.kt b/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressMenuEditorState.kt index c1b9abbc0..498cef5fb 100644 --- a/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressMenuEditorState.kt +++ b/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressMenuEditorState.kt @@ -33,12 +33,18 @@ import kotlinx.coroutines.launch private const val TAG = "LongPressMenuEditorStat" /** - * This class is very tied to [LongPressMenuEditor] and interacts with the UI layer through + * Holds a list of items (from a fixed set of items, see [ItemInList]) to show in a `LazyGrid`, and + * allows performing drag operations on this list, both via touch and via DPAD (e.g. Android TVs). + * Loads the list state (composed of whether the header is enabled and of the action arrangement) + * from settings upon initialization, and only persists changes back to settings when [onDispose] is + * called. + * + * This class is very tied to [LongPressMenuEditorPage] and interacts with the UI layer through * [gridState]. Therefore it's not a view model but rather a state holder class, see * https://developer.android.com/topic/architecture/ui-layer/stateholders#ui-logic. * - * See the javadoc of [LongPressMenuEditor] to understand which behaviors you should test for when - * changing this class. + * See the javadoc of [LongPressMenuEditorPage] to understand which behaviors you should test for + * when changing this class. */ @Stable class LongPressMenuEditorState( @@ -53,14 +59,54 @@ class LongPressMenuEditorState( return@run buildItemsInList(isHeaderEnabled, actionArrangement).toMutableStateList() } - // variables for handling drag, focus, and autoscrolling when finger is at top/bottom - var activeDragItem by mutableStateOf(null) - var activeDragPosition by mutableStateOf(IntOffset.Zero) - var activeDragSize by mutableStateOf(IntSize.Zero) - var currentlyFocusedItem by mutableIntStateOf(-1) - var autoScrollJob by mutableStateOf(null) - var autoScrollSpeed by mutableFloatStateOf(0f) + // variables for handling drag, DPAD focus, and autoscrolling when finger is at top/bottom + /** If not null, the [ItemInList] that the user picked up and is dragging around. */ + var activeDragItem by mutableStateOf(null) + private set + + /** If [activeDragItem]`!=null`, contains the user's finger position. */ + var activeDragPosition by mutableStateOf(IntOffset.Zero) + private set + + /** If [activeDragItem]`!=null`, the size it had in the list before being picked up. */ + var activeDragSize by mutableStateOf(IntSize.Zero) + private set + + /** If `>=0`, the index of the list item currently focused via DPAD (e.g. on Android TVs). */ + var currentlyFocusedItem by mutableIntStateOf(-1) + private set + + /** + * It is `!=null` only when the user is dragging something via touch, and is used to scroll + * up/down if the user's finger is close to the top/bottom of the list. + */ + private var autoScrollJob by mutableStateOf(null) + + /** + * A value in range `[0, maxSpeed]`, computed with [autoScrollSpeedFromTouchPos], and used by + * [autoScrollJob] to scroll faster or slower depending on how close the finger is to the + * top/bottom of the list. + */ + private var autoScrollSpeed by mutableFloatStateOf(0f) + + /** + * Build the initial list of [ItemInList] given the [isHeaderEnabled] and [actionArrangement] + * loaded from settings. A "hidden actions" caption will separate the enabled actions (at the + * beginning of the list) from the disabled ones (at the end). + * + * @param isHeaderEnabled whether the header should come before or after the "hidden actions" + * caption in the list + * @param actionArrangement a list of **distinct** [LongPressAction.Type]s to show before the + * "hidden actions"; items must be distinct because it wouldn't make sense to enable an action + * twice, but also because the [LongPressAction.Type]`.ordinal`s are used as `LazyGrid` IDs in + * the UI (see [ItemInList.stableUniqueKey]), which requires them to be unique, so any duplicate + * items will be removed + * @return a list with [ItemInList.Action]s of all [LongPressAction.Type]s, with a header, and + * with two textual captions in between to distinguish between enabled and disabled items, for a + * total of `#(`[LongPressAction.Type]`) + 3` items (`+ 1` if a [ItemInList.NoneMarker] is also + * needed to indicate that no items are enabled or disabled) + */ private fun buildItemsInList( isHeaderEnabled: Boolean, actionArrangement: List @@ -72,6 +118,7 @@ class LongPressMenuEditorState( } yieldAll( actionArrangement + .distinct() // see in the javadoc why this is important .map { ItemInList.Action(it) } .ifEmpty { if (isHeaderEnabled) listOf() else listOf(ItemInList.NoneMarker) } ) @@ -80,6 +127,7 @@ class LongPressMenuEditorState( yield(ItemInList.HeaderBox) } yieldAll( + // these are trivially all distinct, so no need for distinct() here LongPressAction.Type.entries .filter { !actionArrangement.contains(it) } .map { ItemInList.Action(it) } @@ -88,11 +136,21 @@ class LongPressMenuEditorState( }.toList() } + /** + * Rebuilds the list state given the default action arrangement and header enabled status. Note + * that this does not save anything to settings, but only changes the list shown in the UI, as + * per the class javadoc. + */ fun resetToDefaults(context: Context) { items.clear() - items.addAll(buildItemsInList(true, getDefaultEnabledLongPressActions(context))) + items.addAll(buildItemsInList(true, getDefaultLongPressActionArrangement(context))) } + /** + * @return the [ItemInList] at the position [offset] (relative to the start of the lazy grid), + * or the closest item along the row of the grid intersecting with [offset], or `null` if no + * such item exists + */ private fun findItemForOffsetOrClosestInRow(offset: IntOffset): LazyGridItemInfo? { var closestItemInRow: LazyGridItemInfo? = null // Using manual for loop with indices instead of firstOrNull() because this method gets @@ -109,6 +167,12 @@ class LongPressMenuEditorState( return closestItemInRow } + /** + * @return a number between 0 and [maxSpeed] indicating how fast the view should auto-scroll + * up/down while dragging an item, depending on how close the finger is to the top/bottom; uses + * this piecewise linear function, where `x=`[touchPos]`.y/height`: + * `f(x) = maxSpeed * max((x-1)/borderPercent + 1, min(x/borderPercent - 1, 0))` + */ private fun autoScrollSpeedFromTouchPos( touchPos: IntOffset, maxSpeed: Float = 20f, @@ -130,47 +194,72 @@ class LongPressMenuEditorState( } /** - * Called not just for drag gestures initiated by moving the finger, but also with DPAD's Enter. + * Prepares the list state because user wants to pick up an item, by putting the selected item + * in [activeDragItem] and replacing it in the view with a [ItemInList.DragMarker]. Called not + * just for drag gestures initiated by moving the finger, but also with DPAD's Enter. + * @param pos the touch position (for touch dragging), or the focus position (for DPAD moving) + * @param rawItem the `LazyGrid` item the user selected (it's a parameter because it's + * determined differently for touch and for DPAD) + * @return `true` if the dragging could be initiated correctly, `false` otherwise (e.g. if the + * item is not supposed to be draggable) */ - private 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) - activeDragItem = item - activeDragPosition = pos - activeDragSize = rawItem.size - } + private fun beginDrag(pos: IntOffset, rawItem: LazyGridItemInfo): Boolean { + if (activeDragItem != null) return false + val item = items.getOrNull(rawItem.index) ?: return false + if (!item.isDraggable) return false + + items[rawItem.index] = ItemInList.DragMarker(item.columnSpan) + activeDragItem = item + activeDragPosition = pos + activeDragSize = rawItem.size + return true } /** - * This beginDragGesture() overload is only called when moving the finger (not on DPAD's Enter). + * Finds the item under the user's touch, and then just delegates to [beginDrag], and if that's + * successful starts [autoScrollJob]. Only called on touch input, and not on DPAD input. Will + * not do anything if [wasLongPressed] is `false`, because only long-press-then-move should be + * used for moving items, note that however the touch events will still be forwarded to + * [handleDragChangeTouch] to handle scrolling. */ - fun beginDragGesture(pos: IntOffset, wasLongPressed: Boolean) { + fun beginDragTouch(pos: IntOffset, wasLongPressed: Boolean) { if (!wasLongPressed) { // items can be dragged around only if they are long-pressed; // use the drag as scroll otherwise return } val rawItem = findItemForOffsetOrClosestInRow(pos) ?: return - beginDragGesture(pos, rawItem) - autoScrollSpeed = 0f - autoScrollJob = coroutineScope.launch { - while (isActive) { - if (autoScrollSpeed != 0f) { - gridState.scrollBy(autoScrollSpeed) + if (beginDrag(pos, rawItem)) { + // only start the job if `beginDragGesture` was successful + autoScrollSpeed = 0f + autoScrollJob?.cancel() // just in case + autoScrollJob = coroutineScope.launch { + while (isActive) { + if (autoScrollSpeed != 0f) { + gridState.scrollBy(autoScrollSpeed) + } + delay(16L) // roughly 60 FPS } - delay(16L) // roughly 60 FPS } } } /** - * Called not just for drag gestures by moving the finger, but also with DPAD's events. + * Called when the user's finger, or the DPAD focus, moves over a new item while a drag is + * active (i.e. [activeDragItem]`!=null`). Moves the [ItemInList.DragMarker] in the list to be + * at the current position of [rawItem]/[dragItem], and adds/removes [ItemInList.NoneMarker] if + * needed. + * @param dragItem the same as [activeDragItem], but `!= null` + * @param rawItem the raw `LazyGrid` state of the [ItemInList] that the user is currently + * passing over with touch or focus */ - private fun handleDragGestureChange(dragItem: ItemInList, rawItem: LazyGridItemInfo) { + private fun handleDragChange(dragItem: ItemInList, rawItem: LazyGridItemInfo) { val prevDragMarkerIndex = items.indexOfFirst { it is ItemInList.DragMarker } - .takeIf { it >= 0 } ?: return // impossible situation, DragMarker is always in the list + .takeIf { it >= 0 } + if (prevDragMarkerIndex == null) { + Log.w(TAG, "DragMarker not being in the list should be impossible") + 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) @@ -212,10 +301,12 @@ class LongPressMenuEditorState( } /** - * This handleDragGestureChange() overload is only called when moving the finger - * (not on DPAD's events). + * Handles touch gesture movements, and scrolls the `LazyGrid` if no item is being actively + * dragged, or otherwise delegates to [handleDragChange]. Also updates [activeDragPosition] (so + * the dragged item can be shown at that offset in the UI) and [autoScrollSpeed]. This is only + * called on touch input, and not on DPAD input. */ - fun handleDragGestureChange(pos: IntOffset, posChangeForScrolling: Offset) { + fun handleDragChangeTouch(pos: IntOffset, posChangeForScrolling: Offset) { val dragItem = activeDragItem if (dragItem == null) { // when the user clicks outside of any draggable item, or if the user did not long-press @@ -226,17 +317,22 @@ class LongPressMenuEditorState( autoScrollSpeed = autoScrollSpeedFromTouchPos(pos) activeDragPosition = pos val rawItem = findItemForOffsetOrClosestInRow(pos) ?: return - handleDragGestureChange(dragItem, rawItem) + handleDragChange(dragItem, rawItem) } /** - * Called in multiple places, e.g. when the finger stops touching, or with DPAD events. + * Concludes the touch/DPAD drag, stops the [autoScrollJob] if any, and most importantly + * "releases" the [activeDragItem] by putting it back in the list, replacing the + * [ItemInList.DragMarker]. This function is called in multiple places, e.g. when the finger + * stops touching, or with DPAD events. */ - fun completeDragGestureAndCleanUp() { + fun completeDragAndCleanUp() { autoScrollJob?.cancel() autoScrollJob = null autoScrollSpeed = 0f + // activeDragItem could be null if the user did not long-press any item but is just + // scrolling the view, see `beginDragTouch()` and `handleDragChangeTouch()` activeDragItem?.let { dragItem -> val dragMarkerIndex = items.indexOfFirst { it is ItemInList.DragMarker } if (dragMarkerIndex >= 0) { @@ -249,27 +345,43 @@ class LongPressMenuEditorState( } /** - * Handles DPAD events on Android TVs. + * Handles DPAD events on Android TVs (right, left, up, down, center). Items can be focused by + * navigating with arrows and can be selected (thus initiating a drag) with center. Once + * selected, arrow button presses will move the item around in the list, and pressing center + * will release the item at the new position. When focusing or moving an item outside of the + * screen, the `LazyGrid` will scroll to it. + * + * @param event the event to process + * @param columns the number of columns in the `LazyGrid`, needed to correctly go one line + * up/down when receiving the up/down events + * @return `true` if the event was handled, `false` if it wasn't (if this function returns + * `false`, the event is supposed to be handled by the focus mechanism of some external view, + * e.g. to give focus back to views other than the `LazyGrid`) */ fun onKeyEvent(event: KeyEvent, columns: Int): Boolean { - if (event.type != KeyEventType.KeyDown) { - if (event.type == KeyEventType.KeyUp && - event.key == Key.DirectionDown && + // generally we only care about [KeyEventType.KeyDown] events, as is common on Android TVs, + // but in the special case where the user has an external view in focus (i.e. a button in + // the toolbar) and then presses the down-arrow to enter the `LazyGrid`, we will only + // receive [KeyEventType.KeyUp] here, and we need to handle it + if (event.type != KeyEventType.KeyDown) { // KeyDown means that the button was pressed + if (event.type == KeyEventType.KeyUp && // KeyDown means that the button was released + event.key == Key.DirectionDown && // DirectionDown indicates the down-arrow button currentlyFocusedItem < 0 ) { currentlyFocusedItem = 0 } return false } - var focusedItem = currentlyFocusedItem + + var focusedItem = currentlyFocusedItem // do operations on a local variable when (event.key) { Key.DirectionUp -> { if (focusedItem < 0) { - return false + return false // already at the beginning, } else if (items[focusedItem].columnSpan == null) { - focusedItem -= 1 + focusedItem -= 1 // this item uses the whole line, just go to the previous item } else { - // go to the previous line + // go to the item in the same column on the previous line var remaining = columns while (true) { focusedItem -= 1 @@ -286,11 +398,11 @@ class LongPressMenuEditorState( Key.DirectionDown -> { if (focusedItem >= items.size - 1) { - return false + return false // already at the end } else if (focusedItem < 0 || items[focusedItem].columnSpan == null) { - focusedItem += 1 + focusedItem += 1 // this item uses the whole line, just go to the next item } else { - // go to the next line + // go to the item in the same column on the next line var remaining = columns while (true) { focusedItem += 1 @@ -307,7 +419,7 @@ class LongPressMenuEditorState( Key.DirectionLeft -> { if (focusedItem < 0) { - return false + return false // already at the beginning } else { focusedItem -= 1 } @@ -315,39 +427,41 @@ class LongPressMenuEditorState( Key.DirectionRight -> { if (focusedItem >= items.size - 1) { - return false + return false // already at the end } else { focusedItem += 1 } } + // when pressing enter/center, either start a drag or complete the current one Key.Enter, Key.NumPadEnter, Key.DirectionCenter -> if (activeDragItem == null) { val rawItem = gridState.layoutInfo.visibleItemsInfo .firstOrNull { it.index == focusedItem } ?: return false - beginDragGesture(rawItem.offset, rawItem) + beginDrag(rawItem.offset, rawItem) return true } else { - completeDragGestureAndCleanUp() + completeDragAndCleanUp() return true } - else -> return false + else -> return false // we don't need this event } currentlyFocusedItem = focusedItem if (focusedItem < 0) { - // not checking for focusedItem>=items.size because it's impossible for it + // there is no `if (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() + completeDragAndCleanUp() return false } else if (focusedItem >= items.size) { Log.w(TAG, "Invalid focusedItem $focusedItem: >= items size ${items.size}") } + // find the item with the closest index to handle `focusedItem < 0` or `>= items.size` cases val rawItem = gridState.layoutInfo.visibleItemsInfo .minByOrNull { abs(it.index - focusedItem) } ?: return false // no item is visible at all, impossible case @@ -356,7 +470,7 @@ class LongPressMenuEditorState( // scroll to it. Note that this will cause the "drag item" to appear misplaced, // since the drag item's position is set to the position of the focused item // before scrolling. However, it's not worth overcomplicating the logic just for - // correcting the position of a drag hint on Android TVs. + // correcting the UI position of a drag hint on Android TVs. val h = rawItem.size.height if (rawItem.index != focusedItem || rawItem.offset.y <= gridState.layoutInfo.viewportStartOffset + 0.8 * h || @@ -367,21 +481,24 @@ class LongPressMenuEditorState( } } - val dragItem = activeDragItem - if (dragItem != null) { + activeDragItem?.let { dragItem -> // This will mostly bring the drag item to the right position, but will // misplace it if the view just scrolled (see above), or if the DragMarker's // position is moved past HiddenCaption by handleDragGestureChange() below. // However, it's not worth overcomplicating the logic just for correcting - // the position of a drag hint on Android TVs. + // the UI position of a drag hint on Android TVs. activeDragPosition = rawItem.offset - handleDragGestureChange(dragItem, rawItem) + handleDragChange(dragItem, rawItem) } return true } + /** + * Stops any currently active drag, and saves to settings the action arrangement and whether the + * header is enabled. + */ fun onDispose(context: Context) { - completeDragGestureAndCleanUp() + completeDragAndCleanUp() var isHeaderEnabled = false val actionArrangement = ArrayList() @@ -418,6 +535,9 @@ sealed class ItemInList( object NoneMarker : ItemInList() data class DragMarker(override val columnSpan: Int?) : ItemInList() + /** + * @return a unique key for each [ItemInList], which can be used as a key for `Lazy` containers + */ fun stableUniqueKey(): Int { return when (this) { is Action -> this.type.ordinal diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressMenuSettings.kt b/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressMenuSettings.kt index a32606e4d..c681dea05 100644 --- a/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressMenuSettings.kt +++ b/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressMenuSettings.kt @@ -54,7 +54,12 @@ private fun getShowPlayWithKodi(context: Context): Boolean { .getBoolean(context.getString(R.string.show_play_with_kodi_key), false) } -fun getDefaultEnabledLongPressActions(context: Context): List { +/** + * Returns the default arrangement of actions in the long press menu. Includes [PlayWithKodi] only + * if the user enabled Kodi in settings. Note however that this does not prevent the user from + * adding/removing [PlayWithKodi] anyway, via the long press menu editor. + */ +fun getDefaultLongPressActionArrangement(context: Context): List { return if (getShowPlayWithKodi(context)) { // only include Kodi in the default actions if it is enabled in settings DefaultEnabledActions + listOf(PlayWithKodi) @@ -63,12 +68,16 @@ fun getDefaultEnabledLongPressActions(context: Context): List { val key = context.getString(R.string.long_press_menu_action_arrangement_key) val ids = PreferenceManager.getDefaultSharedPreferences(context) .getString(key, null) if (ids == null) { - return getDefaultEnabledLongPressActions(context) + return getDefaultLongPressActionArrangement(context) } else if (ids.isEmpty()) { return emptyList() // apparently the user has disabled all buttons } @@ -90,10 +99,14 @@ fun loadLongPressActionArrangementFromSettings(context: Context): List) { val items = actions.joinToString(separator = ",") { it.id.toString() } val key = context.getString(R.string.long_press_menu_action_arrangement_key) @@ -102,6 +115,10 @@ fun storeLongPressActionArrangementToSettings(context: Context, actions: List( +data class Either( val value: Any, - val classA: KClass, - val classB: KClass + val classA: KClass, + val classB: KClass ) { + /** + * Calls either [ifLeft] or [ifRight] by casting the [value] this [Either] was built with to + * either [A] or [B] (first tries [A], and if that fails uses [B] and asserts that the cast + * succeeds). See [Either] for a possible pitfall of this function. + */ inline fun match(ifLeft: (A) -> R, ifRight: (B) -> R): R { return classA.safeCast(value)?.let { ifLeft(it) } ?: ifRight(classB.cast(value)) } companion object { + /** + * Builds an [Either] populated with a value of the left variant type [A]. + */ inline fun left(a: A): Either = Either(a, A::class, B::class) + + /** + * Builds an [Either] populated with a value of the right variant type [B]. + */ inline fun right(b: B): Either = Either(b, A::class, B::class) } } 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 index e9d78c92c..8d88cd799 100644 --- a/app/src/main/java/org/schabi/newpipe/util/text/FadedMarqueeModifier.kt +++ b/app/src/main/java/org/schabi/newpipe/util/text/FadedMarqueeModifier.kt @@ -16,21 +16,25 @@ import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.unit.Dp /** + * A Modifier to be applied to [androidx.compose.material3.Text]. If the text is too large, this + * fades out the left and right edges of the text, and makes the text scroll horizontally, so the + * user can read it all. + * * 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) { + fun ContentDrawScope.drawFadedEdge(leftOrRightEdge: Boolean) { // left = true, right = false val edgeWidthPx = edgeWidth.toPx() drawRect( - topLeft = Offset(if (leftEdge) 0f else size.width - edgeWidthPx, 0f), + topLeft = Offset(if (leftOrRightEdge) 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 + startX = if (leftOrRightEdge) 0f else size.width, + endX = if (leftOrRightEdge) edgeWidthPx else size.width - edgeWidthPx ), blendMode = BlendMode.DstIn ) @@ -40,8 +44,8 @@ fun Modifier.fadedMarquee(edgeWidth: Dp): Modifier { .graphicsLayer { compositingStrategy = CompositingStrategy.Offscreen } .drawWithContent { drawContent() - drawFadedEdge(leftEdge = true) - drawFadedEdge(leftEdge = false) + drawFadedEdge(leftOrRightEdge = true) + drawFadedEdge(leftOrRightEdge = false) } .basicMarquee( repeatDelayMillis = 2000, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5c6a394fb..eddc51a6a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -903,6 +903,9 @@ Background\nfrom here Popup\nfrom here Play\nfrom here + Background\nshuffled + Popup\nshuffled + Play\nshuffled Enabled actions: Reorder the actions by long pressing them and then dragging them around Hidden actions: @@ -912,9 +915,6 @@ Reset to defaults Are you sure you want to reset to the default actions? Reorder and hide actions - Background\nshuffled - Popup\nshuffled - Play\nshuffled %d items in playlist Stopped loading after %1$d pages and %2$d items to avoid rate limits diff --git a/app/src/test/java/org/schabi/newpipe/util/EitherTest.kt b/app/src/test/java/org/schabi/newpipe/util/EitherTest.kt new file mode 100644 index 000000000..6be8d8361 --- /dev/null +++ b/app/src/test/java/org/schabi/newpipe/util/EitherTest.kt @@ -0,0 +1,43 @@ +package org.schabi.newpipe.util + +import org.junit.Assert.assertEquals +import org.junit.Assert.fail +import org.junit.Test + +class EitherTest { + @Test + fun testMatchLeft() { + var leftCalledTimes = 0 + Either.left("A").match( + ifLeft = { e -> + assertEquals("A", e) + leftCalledTimes += 1 + }, + ifRight = { fail() } + ) + assert(leftCalledTimes == 1) + } + + @Test + fun testMatchRight() { + var rightCalledTimes = 0 + Either.right(5).match( + ifLeft = { fail() }, + ifRight = { e -> + assertEquals(5, e) + rightCalledTimes += 1 + } + ) + assert(rightCalledTimes == 1) + } + + @Test + fun testCovariance() { + // since values can only be read from an Either, you can e.g. assign Either + // to Either because String is a subclass of Object + val e1: Either = Either.left("Hello") + assertEquals("Hello", e1.value) + val e2: Either = Either.right(5) + assertEquals(5, e2.value) + } +} From 90d6c7c052de9888ca3e9f8c8c95da17bcfe07a5 Mon Sep 17 00:00:00 2001 From: Stypox Date: Wed, 11 Feb 2026 04:13:42 +0100 Subject: [PATCH 86/87] Improve style of item being dragged in LongPressMenuEditor --- .../ui/components/menu/LongPressMenuEditor.kt | 17 +++++++++++++---- .../components/menu/LongPressMenuEditorState.kt | 8 ++++++-- 2 files changed, 19 insertions(+), 6 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 ac348d02c..e4ddb6dff 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 @@ -149,6 +149,7 @@ fun LongPressMenuEditorPage(onBackClick: () -> Unit) { ItemInListUi( item = item, focused = state.currentlyFocusedItem == i, + beingDragged = false, // We only want placement animations: fade in/out animations interfere with // items being replaced by a drag marker while being dragged around, and a // fade in/out animation there does not make sense as the item was just @@ -167,7 +168,8 @@ fun LongPressMenuEditorPage(onBackClick: () -> Unit) { } ItemInListUi( item = activeDragItem, - focused = true, + focused = false, + beingDragged = true, modifier = Modifier .size(size) .offset { state.activeDragPosition } @@ -287,12 +289,15 @@ private fun ActionOrHeaderBox( * different parameters * @param focused if `true`, a box will be drawn around the item to indicate that it is focused * (this will only ever be `true` when the user is navigating with DPAD, e.g. on Android TVs) + * @param beingDragged if `true`, draw a semi-transparent background to show that the item is being + * dragged */ @Composable private fun ItemInListUi( item: ItemInList, focused: Boolean, - modifier: Modifier = Modifier + beingDragged: Boolean, + modifier: Modifier ) { when (item) { ItemInList.EnabledCaption -> { @@ -319,7 +324,9 @@ private fun ItemInListUi( focused = focused, icon = item.type.icon, text = item.type.label, - contentColor = MaterialTheme.colorScheme.onSurface + contentColor = MaterialTheme.colorScheme.onSurface, + backgroundColor = MaterialTheme.colorScheme.surface + .letIf(beingDragged) { copy(alpha = 0.7f) } ) } @@ -330,7 +337,8 @@ private fun ItemInListUi( icon = Icons.Default.ArtTrack, text = R.string.long_press_menu_header, contentColor = MaterialTheme.colorScheme.onSurfaceVariant, - backgroundColor = MaterialTheme.colorScheme.surfaceContainer, + backgroundColor = MaterialTheme.colorScheme.surfaceContainer + .letIf(beingDragged) { copy(alpha = 0.85f) }, horizontalPadding = 12.dp ) } @@ -384,6 +392,7 @@ private fun QuickActionButtonPreview( ItemInListUi( item = itemInList, focused = itemInList.stableUniqueKey() % 2 == 0, + beingDragged = false, modifier = Modifier.width(MinButtonWidth * (itemInList.columnSpan ?: 4)) ) } diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressMenuEditorState.kt b/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressMenuEditorState.kt index 498cef5fb..9252e4bda 100644 --- a/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressMenuEditorState.kt +++ b/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressMenuEditorState.kt @@ -438,7 +438,7 @@ class LongPressMenuEditorState( val rawItem = gridState.layoutInfo.visibleItemsInfo .firstOrNull { it.index == focusedItem } ?: return false - beginDrag(rawItem.offset, rawItem) + beginDrag(rawItem.center(), rawItem) return true } else { completeDragAndCleanUp() @@ -487,7 +487,7 @@ class LongPressMenuEditorState( // position is moved past HiddenCaption by handleDragGestureChange() below. // However, it's not worth overcomplicating the logic just for correcting // the UI position of a drag hint on Android TVs. - activeDragPosition = rawItem.offset + activeDragPosition = rawItem.center() handleDragChange(dragItem, rawItem) } return true @@ -549,3 +549,7 @@ sealed class ItemInList( } } } + +fun LazyGridItemInfo.center(): IntOffset { + return offset + IntOffset(size.width / 2, size.height / 2) +} From a09cf90b362c65cf05064b0d2f2f019d2b7b8ae7 Mon Sep 17 00:00:00 2001 From: Stypox Date: Wed, 11 Feb 2026 04:23:45 +0100 Subject: [PATCH 87/87] Remove previous versions of custom PlayShuffled/FromHere icons The Work In Progress versions may still be considered useful in case of future changes though! --- .../menu/icons/BackgroundFromHere.kt | 93 +----------------- .../menu/icons/BackgroundShuffled.kt | 31 +----- .../ui/components/menu/icons/PlayFromHere.kt | 78 +-------------- .../ui/components/menu/icons/PlayShuffled.kt | 4 +- .../ui/components/menu/icons/PopupFromHere.kt | 97 +------------------ .../ui/components/menu/icons/PopupShuffled.kt | 4 +- 6 files changed, 15 insertions(+), 292 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 9c3e2ce91..124103b23 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,24 +13,11 @@ 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 [androidx.compose.material.icons.filled.Headset] * and the tiny arrow in [androidx.compose.material.icons.filled.ContentPasteGo]. + * Some iterations were made before obtaining this icon, if you want to see them, search through git + * history the commit "Remove previous versions of custom PlayShuffled/FromHere icons". */ val Icons.Filled.BackgroundFromHere: ImageVector by lazy { materialIcon(name = "Filled.BackgroundFromHere") { @@ -61,82 +48,6 @@ val Icons.Filled.BackgroundFromHere: ImageVector by lazy { 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) - lineToRelative(0.916f, 0.916f) - lineToRelative(3.900f, -3.900f) - close() - } - materialPath { - 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/BackgroundShuffled.kt b/app/src/main/java/org/schabi/newpipe/ui/components/menu/icons/BackgroundShuffled.kt index a359f326d..ba13c127a 100644 --- a/app/src/main/java/org/schabi/newpipe/ui/components/menu/icons/BackgroundShuffled.kt +++ b/app/src/main/java/org/schabi/newpipe/ui/components/menu/icons/BackgroundShuffled.kt @@ -15,7 +15,9 @@ import androidx.compose.ui.unit.dp /** * Obtained by combining [androidx.compose.material.icons.filled.Headset] - * and the tiny arrow in [androidx.compose.material.icons.filled.ContentPasteGo]. + * and [androidx.compose.material.icons.filled.Shuffle]. + * Some iterations were made before obtaining this icon, if you want to see them, search through git + * history the commit "Remove previous versions of custom PlayShuffled/FromHere icons". */ val Icons.Filled.BackgroundShuffled: ImageVector by lazy { materialIcon(name = "Filled.BackgroundShuffled") { @@ -58,33 +60,6 @@ val Icons.Filled.BackgroundShuffled: ImageVector by lazy { lineToRelative(-1.170f, 1.170f) close() } - /* - val thickness = 0.15f - materialPath { - moveTo(13f, 12f) - moveToRelative(3.295f - thickness, 2.585f - 3 * thickness) - lineToRelative(-2.590f + 3 * thickness, -2.585f + 3 * thickness) - lineToRelative(-0.705f - 2 * thickness, 0.705f + 2 * thickness) - lineToRelative(2.585f - 3 * thickness, 2.585f - 3 * thickness) - close() - moveToRelative(1.955f - 3 * thickness, -2.585f + 3 * thickness) - lineToRelative(1.020f + thickness, 1.020f + thickness) - lineToRelative(-6.270f + 3 * thickness, 6.265f - 3 * thickness) - lineToRelative(0.705f + 2 * thickness, 0.705f + 2 * thickness) - lineToRelative(6.275f - 3 * thickness, -6.270f + 3 * thickness) - lineToRelative(1.020f + thickness, 1.020f + thickness) - lineToRelative(0f, -2.74f - 4 * thickness) - close() - moveToRelative(0.165f + 7 * thickness, 4.705f + thickness) - lineToRelative(-0.705f - 2 * thickness, 0.705f + 2 * thickness) - lineToRelative(1.565f - 4 * thickness, 1.565f - 4 * thickness) - lineToRelative(-1.025f - thickness, 1.025f + thickness) - lineToRelative(2.750f + 4 * thickness, 0f) - lineToRelative(0f, -2.750f - 4 * thickness) - lineToRelative(-1.020f - thickness, 1.020f + thickness) - 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 f736b40dc..a6166bc81 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 @@ -16,6 +16,8 @@ import androidx.compose.ui.unit.dp /** * Obtained by combining [androidx.compose.material.icons.filled.PlayArrow] * and the tiny arrow in [androidx.compose.material.icons.filled.ContentPasteGo]. + * Some iterations were made before obtaining this icon, if you want to see them, search through git + * history the commit "Remove previous versions of custom PlayShuffled/FromHere icons". */ val Icons.Filled.PlayFromHere: ImageVector by lazy { materialIcon(name = "Filled.PlayFromHere") { @@ -37,82 +39,6 @@ val Icons.Filled.PlayFromHere: ImageVector by lazy { 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) - 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() - }*/ } } diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/menu/icons/PlayShuffled.kt b/app/src/main/java/org/schabi/newpipe/ui/components/menu/icons/PlayShuffled.kt index 584c0be08..879c65330 100644 --- a/app/src/main/java/org/schabi/newpipe/ui/components/menu/icons/PlayShuffled.kt +++ b/app/src/main/java/org/schabi/newpipe/ui/components/menu/icons/PlayShuffled.kt @@ -15,7 +15,9 @@ import androidx.compose.ui.unit.dp /** * Obtained by combining [androidx.compose.material.icons.filled.PlayArrow] - * and the tiny arrow in [androidx.compose.material.icons.filled.ContentPasteGo]. + * and [androidx.compose.material.icons.filled.Shuffle]. + * Some iterations were made before obtaining this icon, if you want to see them, search through git + * history the commit "Remove previous versions of custom PlayShuffled/FromHere icons". */ val Icons.Filled.PlayShuffled: ImageVector by lazy { materialIcon(name = "Filled.PlayShuffled") { 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 f456b48d0..ef550bc40 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 @@ -16,6 +16,8 @@ import androidx.compose.ui.unit.dp /** * Obtained by combining [androidx.compose.material.icons.filled.PictureInPicture] * and the tiny arrow in [androidx.compose.material.icons.filled.ContentPasteGo]. + * Some iterations were made before obtaining this icon, if you want to see them, search through git + * history the commit "Remove previous versions of custom PlayShuffled/FromHere icons". */ val Icons.Filled.PopupFromHere: ImageVector by lazy { materialIcon(name = "Filled.PopupFromHere") { @@ -41,12 +43,6 @@ val Icons.Filled.PopupFromHere: ImageVector by lazy { verticalLineToRelative(-7.0f) curveToRelative(0.0f, -1.1f, -0.9f, -2.0f, -2.0f, -2.0f) close() - /*moveTo(21.0f, 17.01f) - horizontalLineToRelative(-18.0f) - verticalLineToRelative(-14.03f) - horizontalLineToRelative(18.0f) - verticalLineToRelative(14.03f) - close()*/ } materialPath { moveTo(19f, 11.5f) @@ -60,95 +56,6 @@ val Icons.Filled.PopupFromHere: ImageVector by lazy { 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) - 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() - }*/ } } diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/menu/icons/PopupShuffled.kt b/app/src/main/java/org/schabi/newpipe/ui/components/menu/icons/PopupShuffled.kt index 456d8ffd5..e896bb00e 100644 --- a/app/src/main/java/org/schabi/newpipe/ui/components/menu/icons/PopupShuffled.kt +++ b/app/src/main/java/org/schabi/newpipe/ui/components/menu/icons/PopupShuffled.kt @@ -15,7 +15,9 @@ import androidx.compose.ui.unit.dp /** * Obtained by combining [androidx.compose.material.icons.filled.PictureInPicture] - * and the tiny arrow in [androidx.compose.material.icons.filled.ContentPasteGo]. + * and [androidx.compose.material.icons.filled.Shuffle]. + * Some iterations were made before obtaining this icon, if you want to see them, search through git + * history the commit "Remove previous versions of custom PlayShuffled/FromHere icons". */ val Icons.Filled.PopupShuffled: ImageVector by lazy { materialIcon(name = "Filled.PopupShuffled") {