From b3b6cf30dc6c4264398563f5d9ff6cd85591d704 Mon Sep 17 00:00:00 2001 From: Stypox Date: Sat, 7 Feb 2026 11:42:01 +0100 Subject: [PATCH] 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