Merge a09cf90b362c65cf05064b0d2f2f019d2b7b8ae7 into c2b698491b3e522e600324a2931c86b1a8fadeb0
This commit is contained in:
commit
75d1380623
3
.github/workflows/ci.yml
vendored
3
.github/workflows/ci.yml
vendored
@ -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
|
||||
|
||||
@ -55,6 +55,14 @@ configure<ApplicationExtension> {
|
||||
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))
|
||||
|
||||
@ -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 <T : Comparable<T>> 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 <T : Comparable<T>> 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<Float, Float> {
|
||||
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<Float, Float>.assertMoved() {
|
||||
val (originalY, finalY) = this
|
||||
assertNotEquals(originalY, finalY)
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple assert on results from [scrollVerticallyAndGetOriginalAndFinalY].
|
||||
*/
|
||||
fun Pair<Float, Float>.assertDidNotMove() {
|
||||
val (originalY, finalY) = this
|
||||
assertEquals(originalY, finalY)
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -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<LongPressAction.Type>(), 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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",
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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")
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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<I, N> extends BaseStateFragment<I>
|
||||
implements ListViewContract<I, N>, StateSaver.WriteRead,
|
||||
SharedPreferences.OnSharedPreferenceChangeListener {
|
||||
@ -256,32 +264,71 @@ public abstract class BaseListFragment<I, N> extends BaseStateFragment<I>
|
||||
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<I, N> extends BaseStateFragment<I>
|
||||
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<PlayQueue> 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<I, N> extends BaseStateFragment<I>
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
@ -361,13 +361,7 @@ public class ChannelFragment extends BaseStateFragment<ChannelInfo>
|
||||
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));
|
||||
|
||||
@ -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<InfoItem, ChannelTabInfo>
|
||||
implements PlaylistControlViewHolder {
|
||||
@ -164,14 +166,24 @@ public class ChannelTabFragment extends BaseListInfoFragment<InfoItem, ChannelTa
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public PlayQueue getPlayQueue() {
|
||||
public PlayQueue getPlayQueue(final Function<List<StreamInfoItem>, Integer> index) {
|
||||
final List<StreamInfoItem> 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<PlayQueue> getPlayQueueStartingAt(@NonNull final StreamInfoItem item) {
|
||||
return () -> getPlayQueue(streamItems -> Math.max(streamItems.indexOf(item), 0));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<StreamInfoItem, PlaylistInfo>
|
||||
implements PlaylistControlViewHolder {
|
||||
@ -144,29 +142,6 @@ public class PlaylistFragment extends BaseListInfoFragment<StreamInfoItem, Playl
|
||||
infoListAdapter.setUseMiniVariant(true);
|
||||
}
|
||||
|
||||
private PlayQueue getPlayQueueStartingAt(final StreamInfoItem infoItem) {
|
||||
return getPlayQueue(Math.max(infoListAdapter.getItemsList().indexOf(infoItem), 0));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void showInfoItemDialog(final StreamInfoItem item) {
|
||||
final Context context = getContext();
|
||||
try {
|
||||
final InfoItemDialog.Builder dialogBuilder =
|
||||
new InfoItemDialog.Builder(getActivity(), context, this, item);
|
||||
|
||||
dialogBuilder
|
||||
.setAction(
|
||||
StreamDialogDefaultEntry.START_HERE_ON_BACKGROUND,
|
||||
(f, infoItem) -> NavigationHelper.playOnBackgroundPlayer(
|
||||
context, getPlayQueueStartingAt(infoItem), true))
|
||||
.create()
|
||||
.show();
|
||||
} catch (final IllegalArgumentException e) {
|
||||
InfoItemDialog.Builder.reportErrorDuringInitialization(e, item);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCreateOptionsMenu(@NonNull final Menu menu,
|
||||
@NonNull final MenuInflater inflater) {
|
||||
@ -247,15 +222,15 @@ public class PlaylistFragment extends BaseListInfoFragment<StreamInfoItem, Playl
|
||||
break;
|
||||
case R.id.menu_item_append_playlist:
|
||||
if (currentInfo != null) {
|
||||
disposables.add(PlaylistDialog.createCorrespondingDialog(
|
||||
getContext(),
|
||||
getPlayQueue()
|
||||
.getStreams()
|
||||
.stream()
|
||||
.map(StreamEntity::new)
|
||||
.collect(Collectors.toList()),
|
||||
dialog -> 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<StreamInfoItem, Playl
|
||||
PlayButtonHelper.initPlaylistControlClickListener(activity, playlistControlBinding, this);
|
||||
}
|
||||
|
||||
public PlayQueue getPlayQueue() {
|
||||
return getPlayQueue(0);
|
||||
}
|
||||
|
||||
private PlayQueue getPlayQueue(final int index) {
|
||||
final List<StreamInfoItem> infoItems = new ArrayList<>();
|
||||
for (final InfoItem i : infoListAdapter.getItemsList()) {
|
||||
@ -391,6 +362,17 @@ public class PlaylistFragment extends BaseListInfoFragment<StreamInfoItem, Playl
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public PlayQueue getPlayQueue() {
|
||||
return getPlayQueue(0);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
@Override
|
||||
protected Function0<PlayQueue> getPlayQueueStartingAt(@NonNull final StreamInfoItem item) {
|
||||
return () -> getPlayQueue(Math.max(infoListAdapter.getItemsList().indexOf(item), 0));
|
||||
}
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// Utils
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
@ -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<StreamDialogEntry> 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();
|
||||
}
|
||||
|
||||
/**
|
||||
* <p>Builder to generate a {@link InfoItemDialog} for a {@link StreamInfoItem}.</p>
|
||||
* Use {@link #addEntry(StreamDialogDefaultEntry)}
|
||||
* and {@link #addAllEntries(StreamDialogDefaultEntry...)} to add options to the dialog.
|
||||
* <br>
|
||||
* 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<StreamDialogEntry> entries = new ArrayList<>();
|
||||
private final boolean addDefaultEntriesAutomatically;
|
||||
|
||||
/**
|
||||
* <p>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.</p>
|
||||
* The dialog has the following structure:
|
||||
* <pre>
|
||||
* + - - - - - - - - - - - - - - - - - - - - - -+
|
||||
* | 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 |
|
||||
* + - - - - - - - - - - - - - - - - - - - - - -+
|
||||
* </pre>
|
||||
* 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 <code>activity, context</code>
|
||||
* or resources is <code>null</code>
|
||||
*/
|
||||
public Builder(final Activity activity,
|
||||
final Context context,
|
||||
@NonNull final Fragment fragment,
|
||||
@NonNull final StreamInfoItem infoItem) {
|
||||
this(activity, context, fragment, infoItem, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* <p>Create an instance of this {@link Builder} for a {@link StreamInfoItem}.</p>
|
||||
* <p>If {@code addDefaultEntriesAutomatically} is set to {@code true},
|
||||
* some default entries are added to the top and bottom of the dialog.</p>
|
||||
* The dialog has the following structure:
|
||||
* <pre>
|
||||
* + - - - - - - - - - - - - - - - - - - - - - -+
|
||||
* | 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 |
|
||||
* + - - - - - - - - - - - - - - - - - - - - - -+
|
||||
* </pre>
|
||||
* 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}.
|
||||
* <br/>
|
||||
* Entries added with {@link #addEntry(StreamDialogDefaultEntry)} and
|
||||
* {@link #addAllEntries(StreamDialogDefaultEntry...)} are added in between.
|
||||
* @throws IllegalArgumentException if <code>activity, context</code>
|
||||
* or resources is <code>null</code>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* <p>Change an entries' action that is called when the entry is selected.</p>
|
||||
* <p><strong>Warning:</strong> 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.</p>
|
||||
* @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.
|
||||
* <br/>
|
||||
* 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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
/**
|
||||
* <p>
|
||||
* This enum provides entries that are accepted
|
||||
* by the {@link InfoItemDialog.Builder}.
|
||||
* </p>
|
||||
* <p>
|
||||
* 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 <code>onClick()</code>).
|
||||
* <br/>
|
||||
* They action can be overridden by using the Builder's
|
||||
* {@link InfoItemDialog.Builder#setAction(
|
||||
* StreamDialogDefaultEntry, StreamDialogEntry.StreamDialogEntryAction)}
|
||||
* method.
|
||||
* </p>
|
||||
*/
|
||||
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);
|
||||
}
|
||||
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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(),
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
11
app/src/main/java/org/schabi/newpipe/ktx/Scope.kt
Normal file
11
app/src/main/java/org/schabi/newpipe/ktx/Scope.kt
Normal file
@ -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> T.letIf(condition: Boolean, block: T.() -> T): T = if (condition) block(this) else this
|
||||
@ -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<List<PlaylistLocalItem>, Void>
|
||||
implements DebounceSavable {
|
||||
@ -163,7 +161,7 @@ public final class BookmarkFragment extends BaseLocalListFragment<List<PlaylistL
|
||||
if (selectedItem instanceof PlaylistMetadataEntry) {
|
||||
showLocalDialog((PlaylistMetadataEntry) selectedItem);
|
||||
} else if (selectedItem instanceof PlaylistRemoteEntity) {
|
||||
showRemoteDeleteDialog((PlaylistRemoteEntity) selectedItem);
|
||||
showRemoteDialog((PlaylistRemoteEntity) selectedItem);
|
||||
}
|
||||
}
|
||||
|
||||
@ -321,25 +319,6 @@ public final class BookmarkFragment extends BaseLocalListFragment<List<PlaylistL
|
||||
// Playlist Metadata Manipulation
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
private void changeLocalPlaylistName(final long id, final String name) {
|
||||
if (localPlaylistManager == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "Updating playlist id=[" + id + "] "
|
||||
+ "with new name=[" + name + "] items");
|
||||
}
|
||||
|
||||
final Disposable disposable = localPlaylistManager.renamePlaylist(id, name)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(longs -> { /*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<List<PlaylistL
|
||||
// Utils
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
|
||||
private void showRemoteDeleteDialog(final PlaylistRemoteEntity item) {
|
||||
showDeleteDialog(item.getOrderingName(), item);
|
||||
private void showRemoteDialog(final PlaylistRemoteEntity item) {
|
||||
openLongPressMenuInActivity(
|
||||
requireActivity(),
|
||||
LongPressable.fromPlaylistRemoteEntity(item),
|
||||
LongPressAction.fromPlaylistRemoteEntity(
|
||||
item,
|
||||
// TODO passing this parameter is bad and should be fixed when migrating the
|
||||
// bookmark fragment to Compose, for more info see method javadoc
|
||||
() -> 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<String> 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) {
|
||||
|
||||
@ -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<PlaylistDialog> createCorrespondingDialog(
|
||||
final Context context,
|
||||
final List<StreamEntity> streamEntities,
|
||||
final Consumer<PlaylistDialog> onExec) {
|
||||
final List<StreamEntity> 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"));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<FeedState>() {
|
||||
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<FeedState>() {
|
||||
|
||||
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<FeedState>() {
|
||||
}
|
||||
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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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<List<StreamStatisticsEntry>, 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<LocalItem> infoItems = itemListAdapter.getItemsList();
|
||||
final List<StreamInfoItem> 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);
|
||||
|
||||
@ -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()));
|
||||
|
||||
@ -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<List<PlaylistSt
|
||||
final var streamStateEntity = streamStates.get(i);
|
||||
final int indexInHistory = Collections.binarySearch(historyStreamIds,
|
||||
playlistItem.getStreamId());
|
||||
final long duration = playlistItem.toStreamInfoItem().getDuration();
|
||||
final long duration = playlistItem.getStreamEntity()
|
||||
.toStreamInfoItem()
|
||||
.getDuration();
|
||||
|
||||
if (indexInHistory < 0 // stream is not in history
|
||||
// stream is in history but the streamStateEntity is null
|
||||
@ -581,9 +584,14 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
|
||||
disposables.add(disposable);
|
||||
}
|
||||
|
||||
private void changeThumbnailStreamId(final long thumbnailStreamId, final boolean isPermanent) {
|
||||
if (playlistManager == null || (!isPermanent && playlistManager
|
||||
.getIsPlaylistThumbnailPermanent(playlistId))) {
|
||||
/**
|
||||
* Changes the playlist's non-permanent thumbnail to the one of the stream entity with the
|
||||
* provided ID, but only does so if the user did not set a permanent thumbnail themselves.
|
||||
* @param thumbnailStreamId the ID of the stream entity whose thumbnail will be displayed
|
||||
*/
|
||||
private void changeThumbnailStreamIdIfNotPermanent(final long thumbnailStreamId) {
|
||||
if (playlistManager == null
|
||||
|| playlistManager.getIsPlaylistThumbnailPermanent(playlistId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -597,7 +605,7 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
|
||||
}
|
||||
|
||||
final Disposable disposable = playlistManager
|
||||
.changePlaylistThumbnail(playlistId, thumbnailStreamId, isPermanent)
|
||||
.changePlaylistThumbnail(playlistId, thumbnailStreamId, false)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(ignore -> successToast.show(), throwable ->
|
||||
showError(new ErrorInfo(throwable, UserAction.REQUESTED_BOOKMARK,
|
||||
@ -619,7 +627,7 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
|
||||
thumbnailStreamId = PlaylistEntity.DEFAULT_THUMBNAIL_ID;
|
||||
}
|
||||
|
||||
changeThumbnailStreamId(thumbnailStreamId, false);
|
||||
changeThumbnailStreamIdIfNotPermanent(thumbnailStreamId);
|
||||
}
|
||||
|
||||
private void openRemoveDuplicatesDialog() {
|
||||
@ -789,39 +797,18 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
|
||||
}
|
||||
|
||||
protected void showInfoItemDialog(final PlaylistStreamEntry item) {
|
||||
final StreamInfoItem infoItem = item.toStreamInfoItem();
|
||||
|
||||
try {
|
||||
final Context context = getContext();
|
||||
final InfoItemDialog.Builder dialogBuilder =
|
||||
new InfoItemDialog.Builder(getActivity(), context, this, infoItem);
|
||||
|
||||
// add entries in the middle
|
||||
dialogBuilder.addAllEntries(
|
||||
StreamDialogDefaultEntry.SET_AS_PLAYLIST_THUMBNAIL,
|
||||
StreamDialogDefaultEntry.DELETE
|
||||
);
|
||||
|
||||
// set custom actions
|
||||
// all entries modified below have already been added within the builder
|
||||
dialogBuilder
|
||||
.setAction(
|
||||
StreamDialogDefaultEntry.START_HERE_ON_BACKGROUND,
|
||||
(f, i) -> NavigationHelper.playOnBackgroundPlayer(
|
||||
context, getPlayQueueStartingAt(item), true))
|
||||
.setAction(
|
||||
StreamDialogDefaultEntry.SET_AS_PLAYLIST_THUMBNAIL,
|
||||
(f, i) ->
|
||||
changeThumbnailStreamId(item.getStreamEntity().getUid(),
|
||||
true))
|
||||
.setAction(
|
||||
StreamDialogDefaultEntry.DELETE,
|
||||
(f, i) -> deleteItem(item))
|
||||
.create()
|
||||
.show();
|
||||
} catch (final IllegalArgumentException e) {
|
||||
InfoItemDialog.Builder.reportErrorDuringInitialization(e, infoItem);
|
||||
}
|
||||
openLongPressMenuInActivity(
|
||||
requireActivity(),
|
||||
LongPressable.fromStreamEntity(item.getStreamEntity()),
|
||||
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<List<PlaylistSt
|
||||
final List<LocalItem> infoItems = itemListAdapter.getItemsList();
|
||||
final List<StreamInfoItem> 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);
|
||||
|
||||
@ -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<Long> getAutomaticPlaylistThumbnailStreamId(final long playlistId) {
|
||||
return playlistStreamTable.getAutomaticThumbnailStreamId(playlistId)
|
||||
.map(streamId -> (streamId >= 0 ? streamId : PlaylistEntity.DEFAULT_THUMBNAIL_ID));
|
||||
}
|
||||
|
||||
private Maybe<Integer> modifyPlaylist(final long playlistId,
|
||||
|
||||
@ -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<SubscriptionState>() {
|
||||
private var _binding: FragmentSubscriptionBinding? = null
|
||||
@ -334,43 +333,10 @@ class SubscriptionFragment : BaseStateFragment<SubscriptionState>() {
|
||||
}
|
||||
|
||||
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)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -127,7 +127,9 @@ class MediaBrowserPlaybackPreparer(
|
||||
//region Building play queues from playlists and history
|
||||
private fun extractLocalPlayQueue(playlistId: Long, index: Int): Single<PlayQueue> {
|
||||
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<PlayQueue> {
|
||||
|
||||
@ -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<ChannelTabInfo> {
|
||||
|
||||
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<StreamInfoItem> 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<ChannelTabI
|
||||
this(serviceId, linkHandler, null, Collections.emptyList(), 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Plays the streams in the channel tab where {@link ChannelTabHelper#isStreamsTab} returns
|
||||
* true, choosing the first such tab among the ones returned by {@code ChannelInfo.getTabs()}.
|
||||
* @param serviceId the service ID of the channel
|
||||
* @param channelUrl the channel URL
|
||||
*/
|
||||
public ChannelTabPlayQueue(final int serviceId,
|
||||
final String channelUrl) {
|
||||
super(serviceId, channelUrl, null, Collections.emptyList(), 0);
|
||||
tabHandler = null;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String getTag() {
|
||||
return "ChannelTabPlayQueue@" + Integer.toHexString(hashCode());
|
||||
@ -39,12 +60,34 @@ public final class ChannelTabPlayQueue extends AbstractInfoPlayQueue<ChannelTabI
|
||||
@Override
|
||||
public void fetch() {
|
||||
if (isInitial) {
|
||||
ExtractorHelper.getChannelTab(this.serviceId, this.linkHandler, false)
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(getHeadListObserver());
|
||||
if (tabHandler == null) {
|
||||
// we still have not chosen a tab, so we need to fetch the channel
|
||||
ExtractorHelper.getChannelInfo(this.serviceId, this.baseUrl, false)
|
||||
.flatMap(channelInfo -> {
|
||||
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());
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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<PlaylistInfo>
|
||||
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());
|
||||
|
||||
@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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());
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
112
app/src/main/java/org/schabi/newpipe/ui/GestureModifiers.kt
Normal file
112
app/src/main/java/org/schabi/newpipe/ui/GestureModifiers.kt
Normal file
@ -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
|
||||
}
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@ -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
|
||||
)
|
||||
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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<InfoItem>,
|
||||
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<StreamInfoItem?>(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)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<StreamViewModel>()
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,400 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2022-2025 The FlorisBoard Contributors <https://florisboard.org>
|
||||
* SPDX-FileCopyrightText: 2026 NewPipe e.V. <https://newpipe-ev.de>
|
||||
* 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<ItemInList>(
|
||||
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))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -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<LongPressAction.Type> = 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<LongPressAction.Type> {
|
||||
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<LongPressAction.Type> {
|
||||
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<LongPressAction.Type>) {
|
||||
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)
|
||||
}
|
||||
@ -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<Boolean> = _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<List<LongPressAction.Type>> = _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)
|
||||
}
|
||||
}
|
||||
@ -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<String, OffsetDateTime>?,
|
||||
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)
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
)
|
||||
}
|
||||
@ -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)
|
||||
)
|
||||
}
|
||||
@ -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)
|
||||
)
|
||||
}
|
||||
@ -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)
|
||||
)
|
||||
}
|
||||
@ -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)
|
||||
)
|
||||
}
|
||||
@ -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)
|
||||
)
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
41
app/src/main/java/org/schabi/newpipe/util/Either.kt
Normal file
41
app/src/main/java/org/schabi/newpipe/util/Either.kt
Normal file
@ -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<out A : Any, out B : Any>(
|
||||
val value: Any,
|
||||
val classA: KClass<out A>,
|
||||
val classB: KClass<out B>
|
||||
) {
|
||||
/**
|
||||
* 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 <R> 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 <reified A : Any, reified B : Any> left(a: A): Either<A, B> = Either(a, A::class, B::class)
|
||||
|
||||
/**
|
||||
* Builds an [Either] populated with a value of the right variant type [B].
|
||||
*/
|
||||
inline fun <reified A : Any, reified B : Any> right(b: B): Either<A, B> = Either(b, A::class, B::class)
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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<SinglePlayQueue> 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<String> 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<StreamInfo> 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)
|
||||
));
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -366,6 +366,9 @@
|
||||
|
||||
<string name="show_thumbnail_key">show_thumbnail_key</string>
|
||||
|
||||
<string name="long_press_menu_action_arrangement_key">long_press_menu_action_arrangement</string>
|
||||
<string name="long_press_menu_is_header_enabled_key">long_press_menu_is_header_enabled</string>
|
||||
|
||||
<!-- Values will be localized in runtime -->
|
||||
<string-array name="feed_update_threshold_options">
|
||||
<item>@string/feed_update_threshold_option_always_update</item>
|
||||
|
||||
@ -436,6 +436,7 @@
|
||||
<string name="audio_track">Audio track</string>
|
||||
<string name="hold_to_append">Hold to enqueue</string>
|
||||
<string name="show_channel_details">Show channel details</string>
|
||||
<string name="show_channel_details_for">Show channel details for %s</string>
|
||||
<string name="enqueue_stream">Enqueue</string>
|
||||
<string name="enqueued">Enqueued</string>
|
||||
<string name="enqueue_next_stream">Enqueue next</string>
|
||||
@ -899,4 +900,21 @@
|
||||
<string name="youtube_player_http_403">HTTP error 403 received from server while playing, likely caused by an IP ban or streaming URL deobfuscation issues</string>
|
||||
<string name="sign_in_confirm_not_bot_error">%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).</string>
|
||||
<string name="unsupported_content_in_country">This content is not available for the currently selected content country.\n\nChange your selection from \"Settings > Content > Default content country\".</string>
|
||||
<string name="background_from_here">Background\nfrom here</string>
|
||||
<string name="popup_from_here">Popup\nfrom here</string>
|
||||
<string name="play_from_here">Play\nfrom here</string>
|
||||
<string name="background_shuffled">Background\nshuffled</string>
|
||||
<string name="popup_shuffled">Popup\nshuffled</string>
|
||||
<string name="play_shuffled">Play\nshuffled</string>
|
||||
<string name="long_press_menu_enabled_actions">Enabled actions:</string>
|
||||
<string name="long_press_menu_enabled_actions_description">Reorder the actions by long pressing them and then dragging them around</string>
|
||||
<string name="long_press_menu_hidden_actions">Hidden actions:</string>
|
||||
<string name="long_press_menu_hidden_actions_description">Drag the header or the actions to this section to hide them</string>
|
||||
<string name="long_press_menu_header">Header with thumbnail, title, clickable channel</string>
|
||||
<string name="back">Back</string>
|
||||
<string name="reset_to_defaults">Reset to defaults</string>
|
||||
<string name="long_press_menu_reset_to_defaults_confirm">Are you sure you want to reset to the default actions?</string>
|
||||
<string name="long_press_menu_actions_editor">Reorder and hide actions</string>
|
||||
<string name="items_in_playlist">%d items in playlist</string>
|
||||
<string name="queue_fetching_stopped_early">Stopped loading after %1$d pages and %2$d items to avoid rate limits</string>
|
||||
</resources>
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
43
app/src/test/java/org/schabi/newpipe/util/EitherTest.kt
Normal file
43
app/src/test/java/org/schabi/newpipe/util/EitherTest.kt
Normal file
@ -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<String, Int>("A").match(
|
||||
ifLeft = { e ->
|
||||
assertEquals("A", e)
|
||||
leftCalledTimes += 1
|
||||
},
|
||||
ifRight = { fail() }
|
||||
)
|
||||
assert(leftCalledTimes == 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testMatchRight() {
|
||||
var rightCalledTimes = 0
|
||||
Either.right<String, Int>(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<String, Int>
|
||||
// to Either<CharSequence, Number> because String is a subclass of Object
|
||||
val e1: Either<CharSequence, Number> = Either.left<String, Int>("Hello")
|
||||
assertEquals("Hello", e1.value)
|
||||
val e2: Either<CharSequence, Number> = Either.right<String, Int>(5)
|
||||
assertEquals(5, e2.value)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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" }
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user