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 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index bc06dfc41..36a8a0c4a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -55,6 +55,14 @@ 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 + } + } } buildTypes { @@ -363,7 +371,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/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..761de0a22 --- /dev/null +++ b/app/src/androidTest/java/org/schabi/newpipe/InstrumentedTestUtil.kt @@ -0,0 +1,143 @@ +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. + */ +val ctx: Context + get() = InstrumentationRegistry.getInstrumentation().targetContext + +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() +} + +/** + * 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]. + */ +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) +} + +/** + * 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) +} + +/** + * Shorthand for `.fetchSemanticsNode().positionOnScreen`. + */ +fun SemanticsNodeInteraction.fetchPosOnScreen() = fetchSemanticsNode().positionOnScreen + +/** + * 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]") + } + if (value !in l..r) { + fail("Expected $value to be in range [$l, $r]") + } +} + +/** + * 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]") + } + if (value in l..r) { + 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/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/LongPressMenuEditorTest.kt b/app/src/androidTest/java/org/schabi/newpipe/ui/components/menu/LongPressMenuEditorTest.kt new file mode 100644 index 000000000..adebf3cdc --- /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 = getDefaultLongPressActionArrangement(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/LongPressMenuSettingsTest.kt b/app/src/androidTest/java/org/schabi/newpipe/ui/components/menu/LongPressMenuSettingsTest.kt new file mode 100644 index 000000000..15706de71 --- /dev/null +++ b/app/src/androidTest/java/org/schabi/newpipe/ui/components/menu/LongPressMenuSettingsTest.kt @@ -0,0 +1,93 @@ +package org.schabi.newpipe.ui.components.menu + +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 +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 { + + @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(getDefaultLongPressActionArrangement(ctx), loadLongPressActionArrangementFromSettings(ctx)) + } + + @Test + fun testLoadingActionArrangementInvalid() { + putStringInPrefs(R.string.long_press_menu_action_arrangement_key, "0,1,whatever,3") + assertEquals(getDefaultLongPressActionArrangement(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 = getDefaultLongPressActionArrangement(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 = 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 new file mode 100644 index 000000000..fbf2c491e --- /dev/null +++ b/app/src/androidTest/java/org/schabi/newpipe/ui/components/menu/LongPressMenuTest.kt @@ -0,0 +1,698 @@ +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 +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 +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.unit.dp +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 androidx.test.filters.SdkSuppress +import java.time.OffsetDateTime +import java.time.temporal.ChronoUnit +import kotlinx.coroutines.delay +import org.junit.Assert.assertEquals +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 +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: TestRule = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + DisplaySizeRule() + } else { + 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", + 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, 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 { LongPressAction(it) { } }, + 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.long_press_menu_actions_editor) + .performClick() + composeRule.waitUntil { + composeRule.onNodeWithText(R.string.long_press_menu_enabled_actions_description) + .isDisplayed() + } + + composeRule.onNodeWithContentDescription(R.string.back) + .performClick() + composeRule.waitUntil { + composeRule.onNodeWithText(R.string.long_press_menu_enabled_actions_description) + .isNotDisplayed() + } + composeRule.onNodeWithContentDescription(R.string.long_press_menu_actions_editor) + .assertIsDisplayed() + } + + @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 = "UpLoAdEr"), + longPressActions = listOf(LongPressAction(ShowChannelDetails) { 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("UpLoAdEr", 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), + longPressActions = listOf(LongPressAction(ShowChannelDetails) { 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") + .performClick() + composeRule.waitUntil { dismissedCount == 1 } + assertEquals(1, pressedCount) + } + + @Test + fun testShowChannelDetails3() { + setLongPressMenu( + 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 testShowChannelDetails4() { + setLongPressMenu( + longPressable = getLongPressable(uploader = "UpLoAdEr"), + longPressActions = listOf(LongPressAction(ShowChannelDetails) {}), + 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() + } + + @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() { + // 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() + } + + @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) + 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() + } + + private fun getFirstRowAndHeaderBounds(): Pair { + val row = composeRule + .onAllNodesWithTag("LongPressMenuGridRow") + .onFirst() + .fetchSemanticsNode() + .boundsInRoot + 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) + } + + @Test + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.N) // setDisplaySize not available on API < 24 + fun testHeaderIsNotFullWidthIfLargeScreen() { + onDevice().setDisplaySize(WidthSizeClass.EXPANDED, HeightSizeClass.MEDIUM) + setLongPressMenu() + + // 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. + */ + fun assertOnlyAndAllArrangedActionsDisplayed( + availableActions: List, + actionArrangement: 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, + onDismissRequest = onDismissRequest + ) + 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 = getDefaultLongPressActionArrangement(ctx), + expectedShownActions = getDefaultLongPressActionArrangement(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 = getDefaultLongPressActionArrangement(ctx), + actionArrangement = LongPressAction.Type.entries, + expectedShownActions = getDefaultLongPressActionArrangement(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 testFewActionsOnNormalScreenAreNotScrollable() { + var dismissedCount = 0 + assertOnlyAndAllArrangedActionsDisplayed( + availableActions = listOf(ShowDetails, ShowChannelDetails), + actionArrangement = 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()) + .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, + onDismissRequest = { dismissedCount += 1 } + ) + + val anItemIsNotVisible = LongPressAction.Type.entries.any { + composeRule.onNodeWithText(it.label).isNotDisplayed() + } + assertTrue(anItemIsNotVisible) + + // try to scroll and confirm that items move + composeRule.onNodeWithTag("LongPressMenuGrid") + .assert(hasScrollAction()) + .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() + .boundsInWindow + tapAtAbsoluteXY(gridBounds.center.x, gridBounds.top - 1) + assertTrue(composeRule.runCatching { waitUntil { dismissedCount == 1 } }.isFailure) + } + + @Test + fun testEnabledDisabledActions() { + setLongPressMenu( + longPressActions = listOf( + LongPressAction(ShowDetails, enabled = { true }) {}, + LongPressAction(Enqueue, 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(LongPressAction(PlayWithKodi) { 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(LongPressAction(BackgroundShuffled) { 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) + .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( + LongPressAction(BackgroundShuffled) { throw Throwable("Whatever") } + ) + ) + + // 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) + .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/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/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/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/error/UserAction.kt b/app/src/main/java/org/schabi/newpipe/error/UserAction.kt index b3f14e2da..27fff3d99 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 ae5800ab3..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 @@ -439,7 +439,7 @@ class VideoDetailFragment : PlaylistDialog.createCorrespondingDialog( requireContext(), listOf(StreamEntity(info)) - ) { dialog -> dialog.show(getParentFragmentManager(), TAG) } + ).subscribe { dialog -> dialog.show(getParentFragmentManager(), TAG) } ) } } @@ -1424,14 +1424,12 @@ 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/fragments/list/BaseListFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListFragment.java index 8a117a47a..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 @@ -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; @@ -22,12 +23,17 @@ 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; 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.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; import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.OnClickGesture; import org.schabi.newpipe.util.StateSaver; @@ -38,6 +44,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 { @@ -256,32 +264,71 @@ 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 - public void held(final StreamInfoItem selectedItem) { - showInfoItemDialog(selectedItem); + public void held(final StreamInfoItem item) { + openLongPressMenuInActivity( + requireActivity(), + LongPressable.fromStreamInfoItem(item), + LongPressAction.fromStreamInfoItem(item, getPlayQueueStartingAt(item)) + ); } }); - 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 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()); + + openLongPressMenuInActivity( + requireActivity(), + LongPressable.fromChannelInfoItem(item), + LongPressAction.fromChannelInfoItem(item, isSubscribed) + ); } }); - 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) + ); } }); @@ -291,6 +338,17 @@ public abstract class BaseListFragment extends BaseStateFragment useNormalItemListScrollListener(); } + /** + * @param item an item in the list, from which the built queue should start + * @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) { + return null; // disable "play from here" options by default (e.g. in search, kiosks) + } + /** * Removes all listeners and adds the normal scroll listener to the {@link #itemsList}. */ @@ -373,27 +431,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) { - try { - new InfoItemDialog.Builder(getActivity(), getContext(), this, item).create().show(); - } catch (final IllegalArgumentException e) { - InfoItemDialog.Builder.reportErrorDuringInitialization(e, item); - } - } - /*////////////////////////////////////////////////////////////////////////// // Menu //////////////////////////////////////////////////////////////////////////*/ 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/fragments/list/channel/ChannelTabFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelTabFragment.java index feb23b6ac..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 @@ -32,10 +32,12 @@ 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; import io.reactivex.rxjava3.core.Single; +import kotlin.jvm.functions.Function0; public class ChannelTabFragment extends BaseListInfoFragment implements PlaylistControlViewHolder { @@ -164,14 +166,24 @@ public class ChannelTabFragment extends BaseListInfoFragment, 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); + } + + @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 be4f076dd..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 @@ -5,7 +5,6 @@ import static org.schabi.newpipe.ktx.ViewUtils.animate; import static org.schabi.newpipe.ktx.ViewUtils.animateHideRecyclerViewAllowingScrolling; import static org.schabi.newpipe.util.ServiceHelper.getServiceById; -import android.content.Context; import android.os.Bundle; import android.text.TextUtils; import android.util.Log; @@ -42,8 +41,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; @@ -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,29 +142,6 @@ public class PlaylistFragment extends BaseListInfoFragment NavigationHelper.playOnBackgroundPlayer( - context, getPlayQueueStartingAt(infoItem), true)) - .create() - .show(); - } catch (final IllegalArgumentException e) { - InfoItemDialog.Builder.reportErrorDuringInitialization(e, item); - } - } - @Override public void onCreateOptionsMenu(@NonNull final Menu menu, @NonNull final MenuInflater inflater) { @@ -247,15 +222,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: @@ -371,10 +346,6 @@ public class PlaylistFragment extends BaseListInfoFragment infoItems = new ArrayList<>(); for (final InfoItem i : infoListAdapter.getItemsList()) { @@ -391,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/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); - } -} 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/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/ktx/Scope.kt b/app/src/main/java/org/schabi/newpipe/ktx/Scope.kt new file mode 100644 index 000000000..fd6f3069e --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/ktx/Scope.kt @@ -0,0 +1,11 @@ +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/local/bookmark/BookmarkFragment.java b/app/src/main/java/org/schabi/newpipe/local/bookmark/BookmarkFragment.java index 3b1d0c573..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 @@ -2,12 +2,10 @@ 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; -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; @@ -40,6 +37,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; @@ -53,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 { @@ -163,7 +161,7 @@ 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; @@ -493,59 +472,33 @@ 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(); - } - - 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(); + openLongPressMenuInActivity( + requireActivity(), + LongPressable.fromPlaylistMetadataEntry(selectedItem), + LongPressAction.fromPlaylistMetadataEntry( + selectedItem, + // Note: this does a blocking I/O call to the database. Use coroutines when + // migrating to Kotlin/Compose instead. + 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 showDeleteDialog(final String name, final PlaylistLocalItem item) { 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..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 @@ -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,19 @@ 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}; the + * function inside the subscribe() will be called on the main thread */ - 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 +172,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/local/feed/FeedFragment.kt b/app/src/main/java/org/schabi/newpipe/local/feed/FeedFragment.kt index 560850294..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 @@ -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,19 @@ 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.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 import org.schabi.newpipe.ui.emptystate.setEmptyStateComposable import org.schabi.newpipe.util.DeviceUtils import org.schabi.newpipe.util.Localization @@ -381,18 +382,10 @@ 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) { - val stream = item.streamWithState.stream + val stream = item.stream NavigationHelper.openVideoDetailFragment( requireContext(), fm, @@ -407,7 +400,34 @@ 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.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 } return false @@ -578,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 258a67a4c..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 @@ -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 @@ -31,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 /** @@ -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/history/StatisticsPlaylistFragment.java b/app/src/main/java/org/schabi/newpipe/local/history/StatisticsPlaylistFragment.java index 43b7f1c0d..b30bc940d 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, () -> getPlayQueueStartingAt(item)) + ); } @Override @@ -377,8 +336,8 @@ public class StatisticsPlaylistFragment final List 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/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/local/playlist/LocalPlaylistFragment.java b/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistFragment.java index cb38d9bae..f068aec0e 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; @@ -450,7 +451,9 @@ public class LocalPlaylistFragment extends BaseLocalListFragment successToast.show(), throwable -> showError(new ErrorInfo(throwable, UserAction.REQUESTED_BOOKMARK, @@ -619,7 +627,7 @@ 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()), + LongPressAction.fromPlaylistStreamEntry( + item, + () -> getPlayQueueStartingAt(item), + 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) + ) + ); } private void setInitialData(final long pid, final String title) { @@ -860,8 +847,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 045148844..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 @@ -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,43 +333,10 @@ 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) - ) - - 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) { - disposables.add( - subscriptionManager.deleteSubscription(selectedItem.serviceId, selectedItem.url).subscribe { - Toast.makeText(requireContext(), getString(R.string.channel_unsubscribed), Toast.LENGTH_SHORT).show() - } + openLongPressMenuInActivity( + requireActivity(), + LongPressable.fromChannelInfoItem(selectedItem), + LongPressAction.fromChannelInfoItem(selectedItem, true) ) } 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..7f974b010 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,15 @@ class SubscriptionManager(context: Context) { subscriptionTable.delete(subscriptionEntity) } + /** + * Checks if the user is subscribed to a channel, in a blocking manner. Since the data being + * loaded from the database is very little, this should be fine. However once the migration to + * Kotlin coroutines will be finished, the blocking computation should be removed. + */ + 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/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/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(); 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/ChannelTabPlayQueue.java b/app/src/main/java/org/schabi/newpipe/player/playqueue/ChannelTabPlayQueue.java index a9eb2a19c..3c58e95aa 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,15 +19,20 @@ import io.reactivex.rxjava3.schedulers.Schedulers; public final class ChannelTabPlayQueue extends AbstractInfoPlayQueue { - final ListLinkHandler linkHandler; + /** + * 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 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, @@ -31,6 +40,18 @@ public final class ChannelTabPlayQueue extends AbstractInfoPlayQueue { + tabHandler = channelInfo.getTabs() + .stream() + .filter(ChannelTabHelper::isStreamsTab) + .findFirst() + .orElseThrow(() -> new ExtractionException( + "No playable channel tab found")); + + return ExtractorHelper + .getChannelTab(this.serviceId, this.tabHandler, false); + }) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(getHeadListObserver()); + + } else { + // fetch the initial page of the channel tab + ExtractorHelper.getChannelTab(this.serviceId, this.tabHandler, false) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(getHeadListObserver()); + } } else { - ExtractorHelper.getMoreChannelTabItems(this.serviceId, this.linkHandler, this.nextPage) + // fetch the successive page of the channel tab + ExtractorHelper.getMoreChannelTabItems(this.serviceId, this.tabHandler, this.nextPage) .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe(getNextPageObserver()); 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..566f036b1 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/playqueue/LocalPlaylistPlayQueue.kt @@ -0,0 +1,52 @@ +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 + +/** + * A play queue that fetches all of the items in a local playlist. + */ +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.streamEntity.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/player/playqueue/PlayQueue.kt b/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueue.kt index 1daf311a7..afe4c9069 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 @@ -1,5 +1,9 @@ package org.schabi.newpipe.player.playqueue +import android.os.Handler +import android.os.Looper +import android.util.Log +import android.widget.Toast import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.core.BackpressureStrategy import io.reactivex.rxjava3.core.Flowable @@ -7,6 +11,10 @@ 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.App +import org.schabi.newpipe.BuildConfig +import org.schabi.newpipe.R import org.schabi.newpipe.player.playqueue.PlayQueueEvent.AppendEvent import org.schabi.newpipe.player.playqueue.PlayQueueEvent.ErrorEvent import org.schabi.newpipe.player.playqueue.PlayQueueEvent.InitEvent @@ -15,7 +23,6 @@ import org.schabi.newpipe.player.playqueue.PlayQueueEvent.RecoveryEvent import org.schabi.newpipe.player.playqueue.PlayQueueEvent.RemoveEvent import org.schabi.newpipe.player.playqueue.PlayQueueEvent.ReorderEvent import org.schabi.newpipe.player.playqueue.PlayQueueEvent.SelectEvent -import org.schabi.newpipe.player.playqueue.PlayQueueItem /** * PlayQueue is responsible for keeping track of a list of streams and the index of @@ -434,6 +441,78 @@ 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. + Log.w( + TAG, + "Stopped after $MAX_FETCHES_BEFORE_SHUFFLING calls to fetch() " + + "(for a total of ${streams.size} streams) to avoid rate limits" + ) + Handler(Looper.getMainLooper()).post { + Toast.makeText( + App.instance, + App.instance.getString( + R.string.queue_fetching_stopped_early, + MAX_FETCHES_BEFORE_SHUFFLING, + streams.size + ), + Toast.LENGTH_SHORT + ).show() + } + break + } + fetchCount += 1 + + if (BuildConfig.DEBUG) { + Log.d(TAG, "fetchAllAndShuffle(): fetching a page, current stream count ${streams.size}") + } + 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. * @@ -508,4 +587,9 @@ abstract class PlayQueue internal constructor( private fun broadcast(event: PlayQueueEvent) { eventBroadcast?.onNext(event) } + + companion object { + val TAG: String = PlayQueue::class.java.simpleName + const val MAX_FETCHES_BEFORE_SHUFFLING = 10 + } } 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/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/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/settings/VideoAudioSettingsFragment.java b/app/src/main/java/org/schabi/newpipe/settings/VideoAudioSettingsFragment.java index a4d52592f..f9c5bc91b 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,10 @@ 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)) { + // Adds or removes the kodi action from the long press menu (but the user may still + // remove/add it again independently of the show_play_with_kodi_key setting). + addOrRemoveKodiLongPressAction(requireContext()); } }; } diff --git a/app/src/main/java/org/schabi/newpipe/ui/GestureModifiers.kt b/app/src/main/java/org/schabi/newpipe/ui/GestureModifiers.kt new file mode 100644 index 000000000..d5bfd3634 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/ui/GestureModifiers.kt @@ -0,0 +1,112 @@ +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.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 +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. 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 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, 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 // 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 // the gesture finished + } + handleDragGestureChange( + change.position.toIntOffset(), + change.positionChange() + ) + change.consume() + } + endDragGesture() + } + } +} + +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 and ignore them all + while (true) { + awaitPointerEvent(pass = PointerEventPass.Initial) + .changes + .forEach(PointerInputChange::consume) + } + } + } +} else { + this +} 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..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 @@ -38,7 +39,7 @@ fun TextAction(text: String, modifier: Modifier = Modifier) { fun NavigationIcon() { Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Back", + contentDescription = stringResource(R.string.back), modifier = Modifier.padding(horizontal = SizeTokens.SpacingExtraSmall) ) } @@ -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 4780e78a3..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,15 +6,15 @@ 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 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 @@ -37,12 +37,11 @@ fun ScaffoldWithToolbar( actionIconContentColor = MaterialTheme.colorScheme.onPrimaryContainer ), navigationIcon = { - IconButton(onClick = onBackClick) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = null - ) - } + 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/items/ItemList.kt b/app/src/main/java/org/schabi/newpipe/ui/components/items/ItemList.kt index ba45c503d..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 @@ -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 @@ -21,27 +18,33 @@ 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 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 = {} ) { 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, @@ -50,7 +53,7 @@ fun ItemList( ) } else if (item is PlaylistInfoItem) { NavigationHelper.openPlaylistFragment( - fragmentManager, + context.findFragmentManager(), item.serviceId, item.url, item.name @@ -59,20 +62,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 +78,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, 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 84fff3e74..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 @@ -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,33 @@ 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 -@OptIn(ExperimentalFoundationApi::class) +/** + * @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, - isSelected: Boolean, - onClick: (StreamInfoItem) -> Unit = {}, - onLongClick: (StreamInfoItem) -> Unit = {}, - onDismissPopup: () -> Unit = {} + getPlayQueueStartingAt: ((item: StreamInfoItem) -> PlayQueue)? = null, + onClick: (StreamInfoItem) -> 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 +83,16 @@ fun StreamListItem( } } - StreamMenu(stream, isSelected, onDismissPopup) + if (showLongPressMenu) { + LongPressMenu( + longPressable = LongPressable.fromStreamInfoItem(stream), + longPressActions = LongPressAction.fromStreamInfoItem( + stream, + getPlayQueueStartingAt?.let { { it(stream) } } + ), + onDismissRequest = { showLongPressMenu = false } + ) + } } } @@ -79,7 +104,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/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/LongPressAction.kt b/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressAction.kt new file mode 100644 index 000000000..45574c041 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressAction.kt @@ -0,0 +1,624 @@ +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.AddCircle +import androidx.compose.material.icons.filled.AddToQueue +import androidx.compose.material.icons.filled.Cast +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Done +import androidx.compose.material.icons.filled.Download +import androidx.compose.material.icons.filled.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 +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 +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 +import org.schabi.newpipe.NewPipeDatabase +import org.schabi.newpipe.R +import org.schabi.newpipe.database.playlist.PlaylistMetadataEntry +import org.schabi.newpipe.database.playlist.PlaylistStreamEntry +import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity +import org.schabi.newpipe.database.stream.StreamStatisticsEntry +import org.schabi.newpipe.database.stream.model.StreamEntity +import org.schabi.newpipe.database.subscription.SubscriptionEntity +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 +import org.schabi.newpipe.extractor.playlist.PlaylistInfoItem +import org.schabi.newpipe.extractor.stream.StreamInfoItem +import org.schabi.newpipe.ktx.findFragmentActivity +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 +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 +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 + +typealias ActionList = MutableList + +/** + * 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 +) { + /** + * 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 + * @param icon an icon to show in the action's button + */ + enum class Type( + val id: Int, + @StringRes val label: Int, + val icon: ImageVector + ) { + 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), + 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) + // READ THE Type ENUM JAVADOC BEFORE ADDING OR CHANGING ACTIONS! + } + + companion object { + + /** + * Builds and adds a [LongPressAction] to the list. + */ + private fun ActionList.addAction( + type: Type, + enabled: () -> Boolean = { true }, + action: suspend (context: Context) -> Unit + ): ActionList { + this.add(LongPressAction(type, enabled, action)) + 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, + enabled: () -> Boolean = { true }, + action: suspend (context: Context) -> Unit + ): ActionList { + if (condition) { + addAction(type, enabled, action) + } + 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 + // 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 + } + + /** + * 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)? + ): 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 + } + + /** + * 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 { + val shuffledQueue: suspend (Context) -> PlayQueue = { context -> + val q = queue(context) + withContext(Dispatchers.IO) { + q.fetchAllAndShuffle() + } + q + } + 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 + } + + /** + * 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) + } + addAction(Type.OpenInBrowser) { context -> + ShareUtils.openUrlInBrowser(context, item.url) + } + 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, + thumbnailUrl: String? + ): ActionList { + addAction(Type.Share) { context -> + ShareUtils.shareText(context, name, url, thumbnailUrl) + } + addAction(Type.OpenInBrowser) { context -> + ShareUtils.openUrlInBrowser(context, url) + } + 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) + val downloadDialog = DownloadDialog(context, info) + downloadDialog.show(context.findFragmentManager(), "downloadDialog") + } + addAction(Type.AddToPlaylist) { context -> + LocalPlaylistManager(NewPipeDatabase.getInstance(context)) + .hasPlaylists() + val dialog = withContext(Dispatchers.IO) { + PlaylistDialog.createCorrespondingDialog(context, listOf(StreamEntity(item))) + .awaitSingle() + } + dialog.show(context.findFragmentManager(), "addToPlaylistDialog") + } + 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)) { + // offer the option to play with Kodi only if Kore supports the item service + addAction( + Type.PlayWithKodi, + enabled = { KoreUtils.isServiceSupportedByKore(item.serviceId) } + ) { context -> KoreUtils.playWithKore(context, item.url.toUri()) } + } + return this + } + + /** + * *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( + item: StreamInfoItem, + queueFromHere: (() -> PlayQueue)? + ): ActionList { + return ArrayList() + .addPlayerActions { context -> fetchItemInfoIfSparse(context, item) } + .addPlayerFromHereActions(queueFromHere) + .addShareActions(item) + .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 { + return fromStreamInfoItem(item.toStreamInfoItem(), queueFromHere) + } + + /** + * *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, + queueFromHere: (() -> PlayQueue)? + ): 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() + } + } + + /** + * *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( + item: PlaylistStreamEntry, + queueFromHere: (() -> PlayQueue)?, + playlistId: Long, + onDelete: Runnable + ): 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() } + } + + /** + * *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( + item: PlaylistMetadataEntry, + isThumbnailPermanent: Boolean, + onDelete: Runnable + ): 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) { + LocalPlaylistManager(NewPipeDatabase.getInstance(context)) + .renamePlaylist(item.uid, newName) + .awaitSingle() + } + } + .addAction( + Type.UnsetPlaylistThumbnail, + 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() + } + } + .addAction(Type.Delete) { onDelete.run() } + } + + /** + * @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( + item: PlaylistRemoteEntity, + onDelete: Runnable + ): 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() } + } + + /** + * @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, + isSubscribed: Boolean + ): 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() + } + 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() + } + } + } +} 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..5ee0ad0b1 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressMenu.kt @@ -0,0 +1,849 @@ +@file:OptIn(ExperimentalMaterial3Api::class) + +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 +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 +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.fillMaxSize +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.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 +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.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +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 +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.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 +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.AnnotatedString +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.withStyle +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.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.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 +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 +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 + +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( + composeView, + LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) + ) +} + +/** + * 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() + var showEditor by rememberSaveable { mutableStateOf(false) } + 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 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 } + } + } +} + +/** + * 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?, + onUploaderClick: (() -> Unit)?, + actions: List, + runActionAndDismiss: (LongPressAction) -> Unit +) { + BoxWithConstraints( + modifier = Modifier + .fillMaxWidth() + .padding(start = 6.dp, end = 6.dp, bottom = 16.dp) + ) { + // 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() + val scrollState = rememberScrollState() + + 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() + .testTag("LongPressMenuGridRow") + ) { + 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 = { runActionAndDismiss(action) }, + enabled = action.enabled(), + modifier = Modifier + .height(buttonHeight) + .weight(1F) + .testTag("LongPressMenuButton") + ) + rowIndex += 1 + } 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 = header!!, // surely not null since actionIndex < 0 + 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 + .fillMaxWidth() + .weight(maxHeaderWidthInButtonsFullSpan.toFloat()) + .testTag("LongPressMenuHeader") + ) + 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), 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 = header!!, // surely not null since actionIndex < 0 + onUploaderClick = onUploaderClick, + modifier = Modifier + .padding(start = 8.dp, top = 11.dp, bottom = 11.dp) + .heightIn(min = ThumbnailHeight) + .fillMaxWidth() + .weight(headerWidthInButtonsReducedSpan.toFloat()) + .testTag("LongPressMenuHeader") + ) + rowIndex += headerWidthInButtonsReducedSpan + } + actionIndex += 1 + } + } + } + } + } +} + +/** + * A custom [BottomSheetDefaults.DragHandle] that also shows a small button on the right, that opens + * the long press menu settings editor. + */ +@Composable +private 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) + ) + + // 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, + 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) + ) + } +} + +@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 {} + } + } +} + +/** + * 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 +private fun LongPressMenuHeader( + item: LongPressable, + onUploaderClick: (() -> Unit)?, + modifier: Modifier = Modifier +) { + val ctx = LocalContext.current + + Surface( + color = MaterialTheme.colorScheme.surfaceContainer, + contentColor = MaterialTheme.colorScheme.onSurfaceVariant, + shape = MaterialTheme.shapes.large, + modifier = modifier + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + // thumbnail and decorations + Box { + 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 + .height(ThumbnailHeight) + .widthIn(max = ThumbnailHeight * 16 / 9) // 16:9 thumbnail at most + .clip(MaterialTheme.shapes.large) + .testTag("LongPressMenuHeaderThumbnail") + ) + } + + when (val decoration = item.decoration) { + is LongPressable.Decoration.Duration -> { + // only show duration if there is a thumbnail + if (item.thumbnailUrl != null) { + Surface( + color = Color.Black.copy(alpha = 0.5f), + contentColor = Color.White, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(4.dp) + .clip(MaterialTheme.shapes.medium) + ) { + Text( + text = Localization.getDurationString(decoration.duration), + 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) { + 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(), + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(vertical = 2.dp, horizontal = 4.dp) + ) + } + } + } + + is LongPressable.Decoration.Playlist -> { + Surface( + color = Color.Black.copy(alpha = 0.4f), + contentColor = Color.White, + modifier = Modifier + .align(Alignment.TopEnd) + .size(width = 40.dp, height = ThumbnailHeight) + .clip(MaterialTheme.shapes.large) + ) { + Column( + 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, + contentDescription = null + ) + Text( + text = Localization.localizeStreamCountMini( + ctx, + decoration.itemCount + ), + style = MaterialTheme.typography.labelMedium, + maxLines = 1 + ) + } + } + } + + null -> {} + } + } + + // title, channel and other textual information + Column( + modifier = Modifier.padding(vertical = 8.dp) + ) { + // title + Text( + text = item.title, + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + modifier = Modifier + .fillMaxWidth() + .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, + linkColor = MaterialTheme.customColors.onSurfaceVariantLink, + ctx = ctx + ) + if (subtitle.isNotBlank()) { + Spacer(Modifier.height(1.dp)) + + 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 { + stringResource(R.string.show_channel_details) + } + SimpleTooltipBox( + text = label + ) { + LongPressMenuHeaderSubtitle( + subtitle, + Modifier.clickable(onClick = onUploaderClick, onClickLabel = label) + ) + } + } + } + } + } + } +} + +/** + * 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( + text = subtitle, + style = MaterialTheme.typography.bodyMedium, + inlineContent = getSubtitleInlineContent(), + modifier = modifier + .fillMaxWidth() + .fadedMarquee(edgeWidth = 12.dp) + .testTag("ShowChannelDetails") + ) +} + +/** + * @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()) { + append(ctx.getString(R.string.show_channel_details)) + } else { + append(item.uploader) + } + append(" ") + // see getSubtitleInlineContent() + appendInlineContent("open_in_new", "↗") + } + shouldAddSeparator = true + } else if (!item.uploader.isNullOrBlank()) { + append(item.uploader) + shouldAddSeparator = true + } + + // localized upload date + val uploadDate = item.uploadDate?.match( + { it }, + { Localization.relativeTime(it) } + ) + if (!uploadDate.isNullOrBlank()) { + if (shouldAddSeparator) { + append(Localization.DOT_SEPARATOR) + } + shouldAddSeparator = true + append(uploadDate) + } + + // localized view count + val viewCount = item.viewCount?.let { + Localization.localizeViewCount(ctx, true, item.streamType, it) + } + if (!viewCount.isNullOrBlank()) { + if (shouldAddSeparator) { + append(Localization.DOT_SEPARATOR) + } + append(viewCount) + } +} + +/** + * [getSubtitleAnnotatedString] returns a string that might make use of the OpenInNew icon, and we + * provide it to [Text] through its `inlineContent` parameter. + */ +@Composable +private 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 + ) + } +) + +/** + * 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 +private fun LongPressMenuButton( + icon: ImageVector, + text: String, + onClick: () -> Unit, + enabled: Boolean, + modifier: Modifier = Modifier +) { + SimpleTooltipBox( + text = text, + modifier = modifier + ) { + 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 + ) + } + } + } +} + +@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 LongPressMenuButtonPreviews() { + AppTheme { + Surface(color = MaterialTheme.colorScheme.surfaceContainerLow) { + FlowRow { + for (entry in LongPressAction.Type.entries) { + LongPressMenuButton( + icon = entry.icon, + text = stringResource(entry.label), + onClick = { }, + enabled = true, + modifier = Modifier.size(86.dp) + ) + } + } + } + } +} + +private class LongPressablePreviews : CollectionPreviewParameterProvider( + listOf( + 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", + viewCount = 8765432, + streamType = null, + 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", + viewCount = 8765432, + streamType = StreamType.VIDEO_STREAM, + 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, + viewCount = null, + streamType = 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, + viewCount = null, + streamType = StreamType.AUDIO_STREAM, + uploadDate = null, + decoration = LongPressable.Decoration.Duration(500) + ), + 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, + viewCount = null, + streamType = StreamType.LIVE_STREAM, + uploadDate = null, + decoration = LongPressable.Decoration.Live + ), + LongPressable( + title = LoremIpsum().values.first(), + url = "https://www.youtube.com/watch?v=YE7VzlLtp-4", + thumbnailUrl = null, + uploader = null, + viewCount = null, + streamType = StreamType.AUDIO_LIVE_STREAM, + uploadDate = Either.right(OffsetDateTime.now().minusSeconds(12)), + decoration = LongPressable.Decoration.Playlist(1500) + ) + ) +) + +@Preview +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES or Configuration.UI_MODE_TYPE_NORMAL) +@Preview(device = "spec:width=1280dp,height=800dp,dpi=240") +@Composable +private fun LongPressMenuPreview( + @PreviewParameter(LongPressablePreviews::class) longPressable: LongPressable +) { + DisposableEffect(null) { + Localization.initPrettyTime(Localization.resolvePrettyTime()) + onDispose {} + } + + AppTheme { + Surface(color = MaterialTheme.colorScheme.surfaceContainerLow) { + // longPressable is null when running the preview in an emulator for some reason... + @Suppress("USELESS_ELVIS") + LongPressMenuContent( + header = longPressable ?: LongPressablePreviews().values.first(), + onUploaderClick = {}, + actions = LongPressAction.Type.entries + // disable Enqueue actions just to show it off + .map { t -> LongPressAction(t, { t != EnqueueNext }) {} }, + 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 new file mode 100644 index 000000000..e4ddb6dff --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressMenuEditor.kt @@ -0,0 +1,400 @@ +/* + * 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) + +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 +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.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.material.icons.filled.RestartAlt +import androidx.compose.material3.AlertDialog +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.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 +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.platform.testTag +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.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.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 + +/** + * 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 + * - 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, but was modified significantly. + */ +@Composable +fun LongPressMenuEditorPage(onBackClick: () -> Unit) { + val context = LocalContext.current + val gridState = rememberLazyGridState() + val coroutineScope = rememberCoroutineScope() + val state = remember(gridState, coroutineScope) { + LongPressMenuEditorState(context, gridState, coroutineScope) + } + + DisposableEffect(Unit) { + onDispose { + // saves to settings the action arrangement and whether the header is enabled + state.onDispose(context) + } + } + + ScaffoldWithToolbar( + title = stringResource(R.string.long_press_menu_actions_editor), + onBackClick = onBackClick, + actions = { + ResetToDefaultsButton { state.resetToDefaults(context) } + } + ) { paddingValues -> + // 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()) + + LazyVerticalGrid( + modifier = Modifier + .safeDrawingPadding() + // `.detectDragGestures()` handles touch gestures on phones/tablets + .detectDragGestures( + beginDragGesture = state::beginDragTouch, + handleDragGestureChange = state::handleDragChangeTouch, + endDragGesture = state::completeDragAndCleanUp + ) + // `.focusTarget().onKeyEvent()` handles DPAD on Android TVs + .focusTarget() + .onKeyEvent { event -> state.onKeyEvent(event, columns) } + .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 + ) { + itemsIndexed( + state.items, + key = { _, item -> item.stableUniqueKey() }, + span = { _, item -> GridItemSpan(item.columnSpan ?: maxLineSpan) } + ) { i, item -> + 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 + // "picked up". Furthermore there were strange moving animation artifacts + // when moving and releasing items quickly before their fade-out animation + // 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, 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, + focused = false, + beingDragged = true, + modifier = Modifier + .size(size) + .offset { state.activeDragPosition } + .offset(-size.width / 2, -size.height / 2) + .offset((-24).dp, (-24).dp) + ) + } + } + } +} + +/** + * 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(doReset: () -> 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 = { + doReset() + showDialog = false + }) { + Text(stringResource(R.string.ok)) + } + }, + dismissButton = { + TextButton(onClick = { showDialog = false }) { + Text(stringResource(R.string.cancel)) + } + } + ) + } + + TooltipIconButton( + onClick = { showDialog = true }, + icon = Icons.Default.RestartAlt, + contentDescription = stringResource(R.string.reset_to_defaults) + ) +} + +/** + * Renders either [ItemInList.EnabledCaption] or [ItemInList.HiddenCaption], i.e. the full-width + * captions separating enabled and hidden items in the list. + */ +@Composable +private fun Caption( + focused: Boolean, + @StringRes title: Int, + @StringRes description: Int, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp) + .letIf(focused) { border(2.dp, LocalContentColor.current) } + ) { + Text( + text = stringResource(title), + style = MaterialTheme.typography.titleMedium + ) + Text( + text = stringResource(description), + fontStyle = FontStyle.Italic, + style = MaterialTheme.typography.bodyMedium + ) + } +} + +/** + * 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( + focused: Boolean, + icon: ImageVector, + @StringRes text: Int, + contentColor: Color, + modifier: Modifier = Modifier, + backgroundColor: Color = Color.Transparent, + horizontalPadding: Dp = 3.dp +) { + Surface( + color = backgroundColor, + contentColor = contentColor, + shape = MaterialTheme.shapes.large, + border = BorderStroke(2.dp, contentColor.copy(alpha = 1f)).takeIf { focused }, + modifier = modifier.padding( + horizontal = horizontalPadding, + vertical = 5.dp + ) + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(32.dp) + ) + FixedHeightCenteredText( + text = stringResource(text), + lines = 2, + style = MaterialTheme.typography.bodySmall + ) + } + } +} + +/** + * @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) + * @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, + beingDragged: Boolean, + modifier: Modifier +) { + when (item) { + ItemInList.EnabledCaption -> { + Caption( + modifier = modifier, + focused = focused, + title = R.string.long_press_menu_enabled_actions, + description = R.string.long_press_menu_enabled_actions_description + ) + } + + ItemInList.HiddenCaption -> { + Caption( + modifier = modifier, + focused = focused, + title = R.string.long_press_menu_hidden_actions, + description = R.string.long_press_menu_hidden_actions_description + ) + } + + is ItemInList.Action -> { + ActionOrHeaderBox( + modifier = modifier, + focused = focused, + icon = item.type.icon, + text = item.type.label, + contentColor = MaterialTheme.colorScheme.onSurface, + backgroundColor = MaterialTheme.colorScheme.surface + .letIf(beingDragged) { copy(alpha = 0.7f) } + ) + } + + ItemInList.HeaderBox -> { + ActionOrHeaderBox( + modifier = modifier, + focused = focused, + icon = Icons.Default.ArtTrack, + text = R.string.long_press_menu_header, + contentColor = MaterialTheme.colorScheme.onSurfaceVariant, + backgroundColor = MaterialTheme.colorScheme.surfaceContainer + .letIf(beingDragged) { copy(alpha = 0.85f) }, + horizontalPadding = 12.dp + ) + } + + ItemInList.NoneMarker -> { + ActionOrHeaderBox( + modifier = modifier, + focused = focused, + 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, + 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) + ) + } + } +} + +@Preview +@Preview(device = "spec:width=1080px,height=1000px,dpi=440") +@Composable +private fun LongPressMenuEditorPagePreview() { + AppTheme { + LongPressMenuEditorPage { } + } +} + +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(ItemInListPreviewProvider::class) itemInList: ItemInList +) { + AppTheme { + Surface { + 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 new file mode 100644 index 000000000..9252e4bda --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressMenuEditorState.kt @@ -0,0 +1,555 @@ +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 +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 kotlin.collections.ifEmpty +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 + +private const val TAG = "LongPressMenuEditorStat" + +/** + * 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 [LongPressMenuEditorPage] to understand which behaviors you should test for + * when changing this class. + */ +@Stable +class LongPressMenuEditorState( + context: Context, + val gridState: LazyGridState, + val coroutineScope: CoroutineScope +) { + val items = run { + // We get the current arrangement once and do not observe on purpose. + val isHeaderEnabled = loadIsHeaderEnabledFromSettings(context) + val actionArrangement = loadLongPressActionArrangementFromSettings(context) + return@run buildItemsInList(isHeaderEnabled, actionArrangement).toMutableStateList() + } + + // 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 + ): List { + return sequence { + yield(ItemInList.EnabledCaption) + if (isHeaderEnabled) { + yield(ItemInList.HeaderBox) + } + yieldAll( + actionArrangement + .distinct() // see in the javadoc why this is important + .map { ItemInList.Action(it) } + .ifEmpty { if (isHeaderEnabled) listOf() else listOf(ItemInList.NoneMarker) } + ) + yield(ItemInList.HiddenCaption) + if (!isHeaderEnabled) { + 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) } + .ifEmpty { if (isHeaderEnabled) listOf(ItemInList.NoneMarker) else listOf() } + ) + }.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, 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 + // 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 + } + + /** + * @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, + 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 + ) + ) + } + + /** + * 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 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 + } + + /** + * 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 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 + 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 + } + } + } + } + + /** + * 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 handleDragChange(dragItem: ItemInList, rawItem: LazyGridItemInfo) { + val prevDragMarkerIndex = items.indexOfFirst { it is ItemInList.DragMarker } + .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) + 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 (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 { + // 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 // do operations on a local variable + when (event.key) { + Key.DirectionUp -> { + if (focusedItem < 0) { + return false // already at the beginning, + } else if (items[focusedItem].columnSpan == null) { + focusedItem -= 1 // this item uses the whole line, just go to the previous item + } else { + // go to the item in the same column on 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 // already at the end + } else if (focusedItem < 0 || items[focusedItem].columnSpan == null) { + focusedItem += 1 // this item uses the whole line, just go to the next item + } else { + // go to the item in the same column on 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 // already at the beginning + } else { + focusedItem -= 1 + } + } + + Key.DirectionRight -> { + if (focusedItem >= items.size - 1) { + 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 + beginDrag(rawItem.center(), rawItem) + return true + } else { + completeDragAndCleanUp() + return true + } + + else -> return false // we don't need this event + } + + currentlyFocusedItem = focusedItem + if (focusedItem < 0) { + // 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. + 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 + + // 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 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 || + rawItem.offset.y + 1.8 * h >= gridState.layoutInfo.viewportEndOffset + ) { + coroutineScope.launch { + gridState.scrollToItem(focusedItem, -(0.8 * h).toInt()) + } + } + + 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 UI position of a drag hint on Android TVs. + activeDragPosition = rawItem.center() + 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) { + completeDragAndCleanUp() + + 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) + } +} + +sealed class ItemInList( + val isDraggable: Boolean = false, + val isCaption: Boolean = false, + // 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. 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) + data class Action(val type: LongPressAction.Type) : ItemInList(isDraggable = true) + + // markers + 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 + 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) + } + } +} + +fun LazyGridItemInfo.center(): IntOffset { + return offset + IntOffset(size.width / 2, size.height / 2) +} 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..c681dea05 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressMenuSettings.kt @@ -0,0 +1,132 @@ +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 +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.Subscribe +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" + +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) + } +} + +// 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, Subscribe, Unsubscribe, Delete, Remove +) + +private fun getShowPlayWithKodi(context: Context): Boolean { + return PreferenceManager.getDefaultSharedPreferences(context) + .getBoolean(context.getString(R.string.show_play_with_kodi_key), false) +} + +/** + * 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) + } else { + DefaultEnabledActions + } +} + +/** + * Loads the arrangement of actions in the long press menu from settings, and handles corner cases + * by returning [getDefaultLongPressActionArrangement]`()`. The returned list is distinct. + */ +fun loadLongPressActionArrangementFromSettings(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 getDefaultLongPressActionArrangement(context) + } else if (ids.isEmpty()) { + return emptyList() // apparently the user has disabled all buttons + } + + try { + val actions = ids.split(',') + .map { id -> + LongPressAction.Type.entries.first { entry -> + entry.id.toString() == id + } + } + + // 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 getDefaultLongPressActionArrangement(context) + } +} + +/** + * Stores the arrangement of actions in the long press menu to settings, as a comma-separated string + * of [LongPressAction.Type.id]s. + */ +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) + } +} + +/** + * Adds or removes the kodi action from the long press menu. Note however that this does not prevent + * the user from adding/removing [PlayWithKodi] anyway, via the long press menu editor. + */ +fun addOrRemoveKodiLongPressAction(context: Context) { + val actions = loadLongPressActionArrangementFromSettings(context).toMutableList() + if (getShowPlayWithKodi(context)) { + if (!actions.contains(PlayWithKodi)) { + actions.add(PlayWithKodi) + } + } else { + actions.remove(PlayWithKodi) + } + storeLongPressActionArrangementToSettings(context, actions) +} 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..8b5977231 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressMenuViewModel.kt @@ -0,0 +1,60 @@ +package org.schabi.newpipe.ui.components.menu + +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 + +/** + * 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) + 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) + } +} 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..d9fe56bd8 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressable.kt @@ -0,0 +1,134 @@ +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 +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 +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 + +@Stable +data class LongPressable( + val title: String, + val url: String?, + val thumbnailUrl: String?, + val uploader: String?, + val viewCount: Long?, + val streamType: StreamType?, // only used to format the view count properly + 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 + + 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 fromStreamInfoItem(item: StreamInfoItem) = LongPressable( + title = item.name, + url = item.url?.takeIf { it.isNotBlank() }, + thumbnailUrl = ImageStrategy.choosePreferredImage(item.thumbnails), + uploader = item.uploaderName?.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) + ) + + @JvmStatic + fun fromStreamEntity(item: StreamEntity) = LongPressable( + title = item.title, + url = item.url.takeIf { it.isNotBlank() }, + thumbnailUrl = item.thumbnailUrl, + uploader = item.uploader.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) + ) + + @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() }, + 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 + title = item.orderingName ?: "", + url = null, + thumbnailUrl = item.thumbnailUrl, + uploader = null, + viewCount = null, + streamType = 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, + viewCount = null, + streamType = null, + uploadDate = null, + decoration = Decoration.Playlist( + 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, + viewCount = null, + streamType = 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() }, + viewCount = null, + streamType = null, + uploadDate = null, + 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 new file mode 100644 index 000000000..ce62d0f2e --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/ui/components/menu/SparseItemUtil.kt @@ -0,0 +1,107 @@ +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 a 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). A toast is shown if + * loading details is required, so this needs to be called on the main thread. + * + * @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, so this needs to be called on the main thread. + * + * @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/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..124103b23 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/ui/components/menu/icons/BackgroundFromHere.kt @@ -0,0 +1,62 @@ +@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]. + * 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") { + 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(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() + } + } +} + +@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/BackgroundShuffled.kt b/app/src/main/java/org/schabi/newpipe/ui/components/menu/icons/BackgroundShuffled.kt new file mode 100644 index 000000000..ba13c127a --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/ui/components/menu/icons/BackgroundShuffled.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 [androidx.compose.material.icons.filled.Headset] + * 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") { + 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() + } + } +} + +@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/PlayFromHere.kt b/app/src/main/java/org/schabi/newpipe/ui/components/menu/icons/PlayFromHere.kt new file mode 100644 index 000000000..a6166bc81 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/ui/components/menu/icons/PlayFromHere.kt @@ -0,0 +1,53 @@ +@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]. + * 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") { + materialPath { + 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() + } + } +} + +@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/PlayShuffled.kt b/app/src/main/java/org/schabi/newpipe/ui/components/menu/icons/PlayShuffled.kt new file mode 100644 index 000000000..879c65330 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/ui/components/menu/icons/PlayShuffled.kt @@ -0,0 +1,65 @@ +@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 [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") { + 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/PopupFromHere.kt b/app/src/main/java/org/schabi/newpipe/ui/components/menu/icons/PopupFromHere.kt new file mode 100644 index 000000000..ef550bc40 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/ui/components/menu/icons/PopupFromHere.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 [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") { + 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(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() + } + 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() + } + } +} + +@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/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..e896bb00e --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/ui/components/menu/icons/PopupShuffled.kt @@ -0,0 +1,82 @@ +@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 [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") { + 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/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 { 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..3621ab9f0 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/ui/theme/CustomColors.kt @@ -0,0 +1,42 @@ +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 + +/** + * A list of custom colors to use throughout the app, in addition to the color scheme defined in + * [MaterialTheme.colorScheme]. Always try to use a color in [MaterialTheme.colorScheme] first + * before adding a new color here, so it's easier to keep consistency. + */ +@Immutable +data class CustomColors( + val onSurfaceVariantLink: Color = Color.Unspecified +) + +private val onSurfaceVariantLinkLight = Color(0xFF5060B0) + +private val onSurfaceVariantLinkDark = Color(0xFFC0D0FF) + +val lightCustomColors = CustomColors( + onSurfaceVariantLink = onSurfaceVariantLinkLight +) + +val darkCustomColors = CustomColors( + onSurfaceVariantLink = onSurfaceVariantLinkDark +) + +/** + * A `CompositionLocal` that keeps track of the currently set [CustomColors]. This needs to be setup + * in every place where [MaterialTheme] is also setup, i.e. in the theme composable. + */ +val LocalCustomColors = staticCompositionLocalOf { CustomColors() } + +@Suppress("UnusedReceiverParameter") // we do `MaterialTheme.` just for consistency +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 + ) + } } 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..689a20e68 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/Either.kt @@ -0,0 +1,41 @@ +package org.schabi.newpipe.util + +import androidx.compose.runtime.Stable +import kotlin.reflect.KClass +import kotlin.reflect.cast +import kotlin.reflect.safeCast + +/** + * Contains either an item of type [A] or an item of type [B]. If [A] is a subclass of [B] or + * vice versa, [match] may not call the same left/right branch that the [Either] was constructed + * with. This is because the point of this class is not to represent two possible options of an + * enum, but to enforce type safety when an object can be of two known types. + */ +@Stable +data class Either( + val value: Any, + 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/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, 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); 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) - )); - } -} 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..8d88cd799 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/text/FadedMarqueeModifier.kt @@ -0,0 +1,58 @@ +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 + +/** + * 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(leftOrRightEdge: Boolean) { // left = true, right = false + val edgeWidthPx = edgeWidth.toPx() + drawRect( + 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 (leftOrRightEdge) 0f else size.width, + endX = if (leftOrRightEdge) edgeWidthPx else size.width - edgeWidthPx + ), + blendMode = BlendMode.DstIn + ) + } + + return this + .graphicsLayer { compositingStrategy = CompositingStrategy.Offscreen } + .drawWithContent { + drawContent() + drawFadedEdge(leftOrRightEdge = true) + drawFadedEdge(leftOrRightEdge = 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) +} 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..18ca425e6 --- /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) + ) + } +} 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/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d9520a056..eddc51a6a 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 @@ -899,4 +900,21 @@ 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 + Background\nshuffled + Popup\nshuffled + Play\nshuffled + Enabled actions: + 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 thumbnail, title, clickable channel + Back + Reset to defaults + Are you sure you want to reset to the default actions? + Reorder and hide actions + %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/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) + } +} 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) + } +} 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" }