Merge a09cf90b362c65cf05064b0d2f2f019d2b7b8ae7 into c2b698491b3e522e600324a2931c86b1a8fadeb0

This commit is contained in:
Stypox 2026-02-11 04:24:42 +01:00 committed by GitHub
commit 75d1380623
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
81 changed files with 5965 additions and 1467 deletions

View File

@ -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

View File

@ -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))

View File

@ -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)
}

View File

@ -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()
}
}

View File

@ -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))
}
}
}
}

View File

@ -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();
}
}

View File

@ -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",

View File

@ -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)
}
}
}

View File

@ -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"

View File

@ -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
)
}
}
}

View File

@ -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")
}

View File

@ -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

View File

@ -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
//////////////////////////////////////////////////////////////////////////*/

View File

@ -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));

View File

@ -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));
}
}

View File

@ -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
//////////////////////////////////////////////////////////////////////////*/

View File

@ -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()));
}
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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(),

View File

@ -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
}

View 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

View File

@ -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) {

View File

@ -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"));
}
}

View File

@ -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

View File

@ -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)
}
}

View File

@ -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);

View File

@ -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()));

View File

@ -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);

View File

@ -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,

View File

@ -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)
)
}

View File

@ -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

View File

@ -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)
);
}
}

View File

@ -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();

View File

@ -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> {

View File

@ -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());

View File

@ -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()
}
}

View File

@ -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
}
}

View File

@ -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)

View File

@ -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());

View File

@ -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)
);
}
}

View File

@ -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());
}
};
}

View 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
}

View File

@ -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
)
}
}
)

View File

@ -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
)

View File

@ -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
)
}
}
}

View File

@ -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)
}

View File

@ -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)
}
}
}

View File

@ -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)
}
}
)
}
}

View File

@ -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

View File

@ -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))
)
}
}
}

View File

@ -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)
}

View File

@ -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)
}
}

View File

@ -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)
)
}
}

View File

@ -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
}
}

View File

@ -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)
)
}

View File

@ -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)
)
}

View File

@ -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)
)
}

View File

@ -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)
)
}

View File

@ -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)
)
}

View File

@ -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)
)
}

View File

@ -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 {

View File

@ -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

View File

@ -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
)
}
}

View 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)
}
}

View File

@ -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,

View File

@ -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);

View File

@ -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)
));
}
}

View File

@ -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)
}

View File

@ -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)
)
}
}

View File

@ -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>

View File

@ -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>

View File

@ -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)
}
}

View 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)
}
}

View File

@ -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

View File

@ -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" }