diff --git a/app/build.gradle b/app/build.gradle index 4652cf6e5ba..3804a721727 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -9,6 +9,7 @@ plugins { id "kotlin-parcelize" id "checkstyle" id "org.sonarqube" version "4.0.0.2929" + id "org.jetbrains.kotlin.plugin.compose" version "${kotlin_version}" } android { @@ -104,10 +105,6 @@ android { 'META-INF/COPYRIGHT'] } } - - composeOptions { - kotlinCompilerExtensionVersion = "1.5.3" - } } ext { @@ -203,7 +200,7 @@ dependencies { // name and the commit hash with the commit hash of the (pushed) commit you want to test // This works thanks to JitPack: https://jitpack.io/ implementation 'com.github.TeamNewPipe:nanojson:1d9e1aea9049fc9f85e68b43ba39fe7be1c1f751' - implementation 'com.github.TeamNewPipe:NewPipeExtractor:v0.24.0' + implementation 'com.github.TeamNewPipe:NewPipeExtractor:v0.24.2' implementation 'com.github.TeamNewPipe:NoNonsense-FilePicker:5.0.0' /** Checkstyle **/ @@ -267,8 +264,7 @@ dependencies { implementation "com.github.lisawray.groupie:groupie-viewbinding:${groupieVersion}" // Image loading - //noinspection GradleDependency --> 2.8 is the last version, not 2.71828! - implementation "com.squareup.picasso:picasso:2.8" + implementation 'io.coil-kt:coil-compose:2.7.0' // Markdown library for Android implementation "io.noties.markwon:core:${markwonVersion}" @@ -290,10 +286,15 @@ dependencies { implementation "org.ocpsoft.prettytime:prettytime:5.0.8.Final" // Jetpack Compose - implementation(platform('androidx.compose:compose-bom:2024.02.01')) - implementation 'androidx.compose.material3:material3' + implementation(platform('androidx.compose:compose-bom:2024.06.00')) + implementation 'androidx.compose.material3:material3:1.3.0-beta05' + implementation 'androidx.compose.material3.adaptive:adaptive:1.0.0-beta04' implementation 'androidx.activity:activity-compose' implementation 'androidx.compose.ui:ui-tooling-preview' + implementation 'androidx.compose.ui:ui-text:1.7.0-beta06' // Needed for parsing HTML to AnnotatedString + implementation 'androidx.lifecycle:lifecycle-viewmodel-compose' + implementation 'androidx.paging:paging-compose:3.3.1' + implementation 'com.github.nanihadesuka:LazyColumnScrollbar:2.2.0' /** Debugging **/ // Memory leak detection diff --git a/app/src/main/java/org/schabi/newpipe/App.java b/app/src/main/java/org/schabi/newpipe/App.java index d92425d200e..a47e0f2fdd4 100644 --- a/app/src/main/java/org/schabi/newpipe/App.java +++ b/app/src/main/java/org/schabi/newpipe/App.java @@ -1,5 +1,6 @@ package org.schabi.newpipe; +import android.app.ActivityManager; import android.app.Application; import android.content.Context; import android.content.SharedPreferences; @@ -8,6 +9,7 @@ import androidx.annotation.NonNull; import androidx.core.app.NotificationChannelCompat; import androidx.core.app.NotificationManagerCompat; +import androidx.core.content.ContextCompat; import androidx.preference.PreferenceManager; import com.jakewharton.processphoenix.ProcessPhoenix; @@ -20,10 +22,9 @@ import org.schabi.newpipe.ktx.ExceptionUtils; import org.schabi.newpipe.settings.NewPipeSettings; import org.schabi.newpipe.util.Localization; -import org.schabi.newpipe.util.image.ImageStrategy; -import org.schabi.newpipe.util.image.PicassoHelper; import org.schabi.newpipe.util.ServiceHelper; import org.schabi.newpipe.util.StateSaver; +import org.schabi.newpipe.util.image.ImageStrategy; import org.schabi.newpipe.util.image.PreferredImageQuality; import java.io.IOException; @@ -32,6 +33,9 @@ import java.util.List; import java.util.Objects; +import coil.ImageLoader; +import coil.ImageLoaderFactory; +import coil.util.DebugLogger; import io.reactivex.rxjava3.exceptions.CompositeException; import io.reactivex.rxjava3.exceptions.MissingBackpressureException; import io.reactivex.rxjava3.exceptions.OnErrorNotImplementedException; @@ -57,7 +61,7 @@ * along with NewPipe. If not, see . */ -public class App extends Application { +public class App extends Application implements ImageLoaderFactory { public static final String PACKAGE_NAME = BuildConfig.APPLICATION_ID; private static final String TAG = App.class.toString(); @@ -108,20 +112,22 @@ public void onCreate() { // Initialize image loader final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this); - PicassoHelper.init(this); ImageStrategy.setPreferredImageQuality(PreferredImageQuality.fromPreferenceKey(this, prefs.getString(getString(R.string.image_quality_key), getString(R.string.image_quality_default)))); - PicassoHelper.setIndicatorsEnabled(MainActivity.DEBUG - && prefs.getBoolean(getString(R.string.show_image_indicators_key), false)); configureRxJavaErrorHandler(); } + @NonNull @Override - public void onTerminate() { - super.onTerminate(); - PicassoHelper.terminate(); + public ImageLoader newImageLoader() { + return new ImageLoader.Builder(this) + .allowRgb565(ContextCompat.getSystemService(this, ActivityManager.class) + .isLowRamDevice()) + .logger(BuildConfig.DEBUG ? new DebugLogger() : null) + .crossfade(true) + .build(); } protected Downloader getDownloader() { diff --git a/app/src/main/java/org/schabi/newpipe/MainActivity.java b/app/src/main/java/org/schabi/newpipe/MainActivity.java index 17569412572..d05acb057f5 100644 --- a/app/src/main/java/org/schabi/newpipe/MainActivity.java +++ b/app/src/main/java/org/schabi/newpipe/MainActivity.java @@ -44,7 +44,6 @@ import android.widget.Spinner; import androidx.annotation.NonNull; -import androidx.annotation.Nullable; import androidx.appcompat.app.ActionBar; import androidx.appcompat.app.ActionBarDrawerToggle; import androidx.appcompat.app.AppCompatActivity; @@ -52,7 +51,6 @@ import androidx.core.view.GravityCompat; import androidx.drawerlayout.widget.DrawerLayout; import androidx.fragment.app.Fragment; -import androidx.fragment.app.FragmentContainerView; import androidx.fragment.app.FragmentManager; import androidx.preference.PreferenceManager; @@ -66,13 +64,11 @@ import org.schabi.newpipe.error.ErrorUtil; import org.schabi.newpipe.extractor.NewPipe; import org.schabi.newpipe.extractor.StreamingService; -import org.schabi.newpipe.extractor.comments.CommentsInfoItem; import org.schabi.newpipe.extractor.exceptions.ExtractionException; import org.schabi.newpipe.extractor.services.peertube.PeertubeInstance; import org.schabi.newpipe.fragments.BackPressable; import org.schabi.newpipe.fragments.MainFragment; import org.schabi.newpipe.fragments.detail.VideoDetailFragment; -import org.schabi.newpipe.fragments.list.comments.CommentRepliesFragment; import org.schabi.newpipe.fragments.list.search.SearchFragment; import org.schabi.newpipe.local.feed.notifications.NotificationWorker; import org.schabi.newpipe.player.Player; @@ -558,33 +554,22 @@ public void onBackPressed() { // interacts with a fragment inside fragment_holder so all back presses should be // handled by it if (bottomSheetHiddenOrCollapsed()) { - final FragmentManager fm = getSupportFragmentManager(); - final Fragment fragment = fm.findFragmentById(R.id.fragment_holder); + final var fm = getSupportFragmentManager(); + final var fragment = fm.findFragmentById(R.id.fragment_holder); // If current fragment implements BackPressable (i.e. can/wanna handle back press) // delegate the back press to it - if (fragment instanceof BackPressable) { - if (((BackPressable) fragment).onBackPressed()) { - return; - } - } else if (fragment instanceof CommentRepliesFragment) { - // expand DetailsFragment if CommentRepliesFragment was opened - // to show the top level comments again - // Expand DetailsFragment if CommentRepliesFragment was opened - // and no other CommentRepliesFragments are on top of the back stack - // to show the top level comments again. - openDetailFragmentFromCommentReplies(fm, false); + if (fragment instanceof BackPressable backPressable && backPressable.onBackPressed()) { + return; } - } else { - final Fragment fragmentPlayer = getSupportFragmentManager() + final var fragmentPlayer = getSupportFragmentManager() .findFragmentById(R.id.fragment_player_holder); // If current fragment implements BackPressable (i.e. can/wanna handle back press) // delegate the back press to it - if (fragmentPlayer instanceof BackPressable) { - if (!((BackPressable) fragmentPlayer).onBackPressed()) { - BottomSheetBehavior.from(mainBinding.fragmentPlayerHolder) - .setState(BottomSheetBehavior.STATE_COLLAPSED); - } + if (fragmentPlayer instanceof BackPressable backPressable + && !backPressable.onBackPressed()) { + BottomSheetBehavior.from(mainBinding.fragmentPlayerHolder) + .setState(BottomSheetBehavior.STATE_COLLAPSED); return; } } @@ -648,15 +633,9 @@ public void onRequestPermissionsResult(final int requestCode, * */ private void onHomeButtonPressed() { - final FragmentManager fm = getSupportFragmentManager(); - final Fragment fragment = fm.findFragmentById(R.id.fragment_holder); - - if (fragment instanceof CommentRepliesFragment) { - // Expand DetailsFragment if CommentRepliesFragment was opened - // and no other CommentRepliesFragments are on top of the back stack - // to show the top level comments again. - openDetailFragmentFromCommentReplies(fm, true); - } else if (!NavigationHelper.tryGotoSearchFragment(fm)) { + final var fm = getSupportFragmentManager(); + + if (!NavigationHelper.tryGotoSearchFragment(fm)) { // If search fragment wasn't found in the backstack go to the main fragment NavigationHelper.gotoMainFragment(fm); } @@ -789,7 +768,7 @@ private void handleIntent(final Intent intent) { break; case PLAYLIST: NavigationHelper.openPlaylistFragment(getSupportFragmentManager(), - serviceId, url, title); + serviceId, url); break; } } else if (intent.hasExtra(Constants.KEY_OPEN_SEARCH)) { @@ -854,68 +833,6 @@ public void onReceive(final Context context, final Intent intent) { } } - private void openDetailFragmentFromCommentReplies( - @NonNull final FragmentManager fm, - final boolean popBackStack - ) { - // obtain the name of the fragment under the replies fragment that's going to be popped - @Nullable final String fragmentUnderEntryName; - if (fm.getBackStackEntryCount() < 2) { - fragmentUnderEntryName = null; - } else { - fragmentUnderEntryName = fm.getBackStackEntryAt(fm.getBackStackEntryCount() - 2) - .getName(); - } - - // the root comment is the comment for which the user opened the replies page - @Nullable final CommentRepliesFragment repliesFragment = - (CommentRepliesFragment) fm.findFragmentByTag(CommentRepliesFragment.TAG); - @Nullable final CommentsInfoItem rootComment = - repliesFragment == null ? null : repliesFragment.getCommentsInfoItem(); - - // sometimes this function pops the backstack, other times it's handled by the system - if (popBackStack) { - fm.popBackStackImmediate(); - } - - // only expand the bottom sheet back if there are no more nested comment replies fragments - // stacked under the one that is currently being popped - if (CommentRepliesFragment.TAG.equals(fragmentUnderEntryName)) { - return; - } - - final BottomSheetBehavior behavior = BottomSheetBehavior - .from(mainBinding.fragmentPlayerHolder); - // do not return to the comment if the details fragment was closed - if (behavior.getState() == BottomSheetBehavior.STATE_HIDDEN) { - return; - } - - // scroll to the root comment once the bottom sheet expansion animation is finished - behavior.addBottomSheetCallback(new BottomSheetBehavior.BottomSheetCallback() { - @Override - public void onStateChanged(@NonNull final View bottomSheet, - final int newState) { - if (newState == BottomSheetBehavior.STATE_EXPANDED) { - final Fragment detailFragment = fm.findFragmentById( - R.id.fragment_player_holder); - if (detailFragment instanceof VideoDetailFragment && rootComment != null) { - // should always be the case - ((VideoDetailFragment) detailFragment).scrollToComment(rootComment); - } - behavior.removeBottomSheetCallback(this); - } - } - - @Override - public void onSlide(@NonNull final View bottomSheet, final float slideOffset) { - // not needed, listener is removed once the sheet is expanded - } - }); - - behavior.setState(BottomSheetBehavior.STATE_EXPANDED); - } - private boolean bottomSheetHiddenOrCollapsed() { final BottomSheetBehavior bottomSheetBehavior = BottomSheetBehavior.from(mainBinding.fragmentPlayerHolder); diff --git a/app/src/main/java/org/schabi/newpipe/about/AboutActivity.kt b/app/src/main/java/org/schabi/newpipe/about/AboutActivity.kt index 7f148e9b5c2..0d0d0d48dd4 100644 --- a/app/src/main/java/org/schabi/newpipe/about/AboutActivity.kt +++ b/app/src/main/java/org/schabi/newpipe/about/AboutActivity.kt @@ -167,8 +167,8 @@ class AboutActivity : AppCompatActivity() { "https://square.github.io/okhttp/", StandardLicenses.APACHE2 ), SoftwareComponent( - "Picasso", "2013", "Square, Inc.", - "https://square.github.io/picasso/", StandardLicenses.APACHE2 + "Coil", "2023", "Coil Contributors", + "https://coil-kt.github.io/coil/", StandardLicenses.APACHE2 ), SoftwareComponent( "PrettyTime", "2012 - 2020", "Lincoln Baxter, III", diff --git a/app/src/main/java/org/schabi/newpipe/error/ReCaptchaActivity.java b/app/src/main/java/org/schabi/newpipe/error/ReCaptchaActivity.java index 3c14cfe4cac..960f98cef7d 100644 --- a/app/src/main/java/org/schabi/newpipe/error/ReCaptchaActivity.java +++ b/app/src/main/java/org/schabi/newpipe/error/ReCaptchaActivity.java @@ -27,8 +27,6 @@ import org.schabi.newpipe.extractor.utils.Utils; import org.schabi.newpipe.util.ThemeHelper; -import java.io.UnsupportedEncodingException; - /* * Created by beneth on 06.12.16. * @@ -187,14 +185,11 @@ private void handleCookiesFromUrl(@Nullable final String url) { final int abuseEnd = url.indexOf("+path"); try { - String abuseCookie = url.substring(abuseStart + 13, abuseEnd); - abuseCookie = Utils.decodeUrlUtf8(abuseCookie); - handleCookies(abuseCookie); - } catch (UnsupportedEncodingException | StringIndexOutOfBoundsException e) { + handleCookies(Utils.decodeUrlUtf8(url.substring(abuseStart + 13, abuseEnd))); + } catch (final StringIndexOutOfBoundsException e) { if (MainActivity.DEBUG) { - e.printStackTrace(); Log.d(TAG, "handleCookiesFromUrl: invalid google abuse starting at " - + abuseStart + " and ending at " + abuseEnd + " for url " + url); + + abuseStart + " and ending at " + abuseEnd + " for url " + url, e); } } } diff --git a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java index 95b54f65a70..8fcf1e663e3 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java @@ -116,7 +116,7 @@ import org.schabi.newpipe.util.ThemeHelper; import org.schabi.newpipe.util.external_communication.KoreUtils; import org.schabi.newpipe.util.external_communication.ShareUtils; -import org.schabi.newpipe.util.image.PicassoHelper; +import org.schabi.newpipe.util.image.CoilHelper; import java.util.ArrayList; import java.util.Iterator; @@ -127,6 +127,7 @@ import java.util.concurrent.TimeUnit; import java.util.function.Consumer; +import coil.util.CoilUtils; import icepick.State; import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; import io.reactivex.rxjava3.disposables.CompositeDisposable; @@ -159,8 +160,6 @@ public final class VideoDetailFragment private static final String DESCRIPTION_TAB_TAG = "DESCRIPTION TAB"; private static final String EMPTY_TAB_TAG = "EMPTY TAB"; - private static final String PICASSO_VIDEO_DETAILS_TAG = "PICASSO_VIDEO_DETAILS_TAG"; - // tabs private boolean showComments; private boolean showRelatedItems; @@ -882,8 +881,7 @@ private void initTabs() { tabContentDescriptions.clear(); if (shouldShowComments()) { - pageAdapter.addFragment( - CommentsFragment.getInstance(serviceId, url, title), COMMENTS_TAB_TAG); + pageAdapter.addFragment(CommentsFragment.getInstance(serviceId, url), COMMENTS_TAB_TAG); tabIcons.add(R.drawable.ic_comment); tabContentDescriptions.add(R.string.comments_tab_description); } @@ -1015,16 +1013,15 @@ public void scrollToTop() { public void scrollToComment(final CommentsInfoItem comment) { final int commentsTabPos = pageAdapter.getItemPositionByTitle(COMMENTS_TAB_TAG); - final Fragment fragment = pageAdapter.getItem(commentsTabPos); - if (!(fragment instanceof CommentsFragment)) { - return; - } + final var fragment = pageAdapter.getItem(commentsTabPos); + // TODO: Implement the scrolling with Compose. // unexpand the app bar only if scrolling to the comment succeeded - if (((CommentsFragment) fragment).scrollToComment(comment)) { - binding.appBarLayout.setExpanded(false, false); - binding.viewPager.setCurrentItem(commentsTabPos, false); - } +// if (fragment instanceof CommentsFragment commentsFragment && +// commentsFragment.scrollToComment(comment)) { +// binding.appBarLayout.setExpanded(false, false); +// binding.viewPager.setCurrentItem(commentsTabPos, false); +// } } /*////////////////////////////////////////////////////////////////////////// @@ -1471,7 +1468,11 @@ public void showLoading() { } } - PicassoHelper.cancelTag(PICASSO_VIDEO_DETAILS_TAG); + CoilUtils.dispose(binding.detailThumbnailImageView); + CoilUtils.dispose(binding.detailSubChannelThumbnailView); + CoilUtils.dispose(binding.overlayThumbnail); + CoilUtils.dispose(binding.detailUploaderThumbnailView); + binding.detailThumbnailImageView.setImageBitmap(null); binding.detailSubChannelThumbnailView.setImageBitmap(null); } @@ -1562,8 +1563,8 @@ public void handleResult(@NonNull final StreamInfo info) { binding.detailSecondaryControlPanel.setVisibility(View.GONE); checkUpdateProgressInfo(info); - PicassoHelper.loadDetailsThumbnail(info.getThumbnails()).tag(PICASSO_VIDEO_DETAILS_TAG) - .into(binding.detailThumbnailImageView); + CoilHelper.INSTANCE.loadDetailsThumbnail(binding.detailThumbnailImageView, + info.getThumbnails()); showMetaInfoInTextView(info.getMetaInfo(), binding.detailMetaInfoTextView, binding.detailMetaInfoSeparator, disposables); @@ -1613,8 +1614,8 @@ private void displayUploaderAsSubChannel(final StreamInfo info) { binding.detailUploaderTextView.setVisibility(View.GONE); } - PicassoHelper.loadAvatar(info.getUploaderAvatars()).tag(PICASSO_VIDEO_DETAILS_TAG) - .into(binding.detailSubChannelThumbnailView); + CoilHelper.INSTANCE.loadAvatar(binding.detailSubChannelThumbnailView, + info.getUploaderAvatars()); binding.detailSubChannelThumbnailView.setVisibility(View.VISIBLE); binding.detailUploaderThumbnailView.setVisibility(View.GONE); } @@ -1645,11 +1646,11 @@ private void displayBothUploaderAndSubChannel(final StreamInfo info) { binding.detailUploaderTextView.setVisibility(View.GONE); } - PicassoHelper.loadAvatar(info.getSubChannelAvatars()).tag(PICASSO_VIDEO_DETAILS_TAG) - .into(binding.detailSubChannelThumbnailView); + CoilHelper.INSTANCE.loadAvatar(binding.detailSubChannelThumbnailView, + info.getSubChannelAvatars()); binding.detailSubChannelThumbnailView.setVisibility(View.VISIBLE); - PicassoHelper.loadAvatar(info.getUploaderAvatars()).tag(PICASSO_VIDEO_DETAILS_TAG) - .into(binding.detailUploaderThumbnailView); + CoilHelper.INSTANCE.loadAvatar(binding.detailUploaderThumbnailView, + info.getUploaderAvatars()); binding.detailUploaderThumbnailView.setVisibility(View.VISIBLE); } @@ -2403,8 +2404,7 @@ private void updateOverlayData(@Nullable final String overlayTitle, binding.overlayTitleTextView.setText(isEmpty(overlayTitle) ? "" : overlayTitle); binding.overlayChannelTextView.setText(isEmpty(uploader) ? "" : uploader); binding.overlayThumbnail.setImageDrawable(null); - PicassoHelper.loadDetailsThumbnail(thumbnails).tag(PICASSO_VIDEO_DETAILS_TAG) - .into(binding.overlayThumbnail); + CoilHelper.INSTANCE.loadDetailsThumbnail(binding.overlayThumbnail, thumbnails); } private void setOverlayPlayPauseImage(final boolean playerIsPlaying) { diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListFragment.java index 8a117a47a9a..d433fccf6fa 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListFragment.java @@ -279,7 +279,7 @@ public void held(final StreamInfoItem selectedItem) { try { onItemSelected(selectedItem); NavigationHelper.openPlaylistFragment(getFM(), selectedItem.getServiceId(), - selectedItem.getUrl(), selectedItem.getName()); + selectedItem.getUrl()); } catch (final Exception e) { ErrorUtil.showUiErrorSnackbar(this, "Opening playlist fragment", e); } diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelFragment.java index fd382adbf46..3890e48659d 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelFragment.java @@ -50,15 +50,16 @@ import org.schabi.newpipe.util.Localization; import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.StateSaver; -import org.schabi.newpipe.util.image.ImageStrategy; -import org.schabi.newpipe.util.image.PicassoHelper; import org.schabi.newpipe.util.ThemeHelper; import org.schabi.newpipe.util.external_communication.ShareUtils; +import org.schabi.newpipe.util.image.CoilHelper; +import org.schabi.newpipe.util.image.ImageStrategy; import java.util.List; import java.util.Queue; import java.util.concurrent.TimeUnit; +import coil.util.CoilUtils; import icepick.State; import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; import io.reactivex.rxjava3.core.Observable; @@ -73,7 +74,6 @@ public class ChannelFragment extends BaseStateFragment implements StateSaver.WriteRead { private static final int BUTTON_DEBOUNCE_INTERVAL = 100; - private static final String PICASSO_CHANNEL_TAG = "PICASSO_CHANNEL_TAG"; @State protected int serviceId = Constants.NO_SERVICE_ID; @@ -576,7 +576,9 @@ private void runWorker(final boolean forceLoad) { @Override public void showLoading() { super.showLoading(); - PicassoHelper.cancelTag(PICASSO_CHANNEL_TAG); + CoilUtils.dispose(binding.channelAvatarView); + CoilUtils.dispose(binding.channelBannerImage); + CoilUtils.dispose(binding.subChannelAvatarView); animate(binding.channelSubscribeButton, false, 100); } @@ -587,17 +589,15 @@ public void handleResult(@NonNull final ChannelInfo result) { setInitialData(result.getServiceId(), result.getOriginalUrl(), result.getName()); if (ImageStrategy.shouldLoadImages() && !result.getBanners().isEmpty()) { - PicassoHelper.loadBanner(result.getBanners()).tag(PICASSO_CHANNEL_TAG) - .into(binding.channelBannerImage); + CoilHelper.INSTANCE.loadBanner(binding.channelBannerImage, result.getBanners()); } else { // do not waste space for the banner, if the user disabled images or there is not one binding.channelBannerImage.setImageDrawable(null); } - PicassoHelper.loadAvatar(result.getAvatars()).tag(PICASSO_CHANNEL_TAG) - .into(binding.channelAvatarView); - PicassoHelper.loadAvatar(result.getParentChannelAvatars()).tag(PICASSO_CHANNEL_TAG) - .into(binding.subChannelAvatarView); + CoilHelper.INSTANCE.loadAvatar(binding.channelAvatarView, result.getAvatars()); + CoilHelper.INSTANCE.loadAvatar(binding.subChannelAvatarView, + result.getParentChannelAvatars()); binding.channelTitleView.setText(result.getName()); binding.channelSubscriberView.setVisibility(View.VISIBLE); diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/comments/CommentRepliesFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/comments/CommentRepliesFragment.java deleted file mode 100644 index 304eaf55a18..00000000000 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/comments/CommentRepliesFragment.java +++ /dev/null @@ -1,170 +0,0 @@ -package org.schabi.newpipe.fragments.list.comments; - -import static org.schabi.newpipe.util.ServiceHelper.getServiceById; - -import android.os.Bundle; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.constraintlayout.widget.ConstraintLayout; -import androidx.core.text.HtmlCompat; - -import org.schabi.newpipe.R; -import org.schabi.newpipe.databinding.CommentRepliesHeaderBinding; -import org.schabi.newpipe.error.UserAction; -import org.schabi.newpipe.extractor.ListExtractor; -import org.schabi.newpipe.extractor.comments.CommentsInfoItem; -import org.schabi.newpipe.fragments.list.BaseListInfoFragment; -import org.schabi.newpipe.info_list.ItemViewMode; -import org.schabi.newpipe.util.DeviceUtils; -import org.schabi.newpipe.util.ExtractorHelper; -import org.schabi.newpipe.util.Localization; -import org.schabi.newpipe.util.NavigationHelper; -import org.schabi.newpipe.util.image.ImageStrategy; -import org.schabi.newpipe.util.image.PicassoHelper; -import org.schabi.newpipe.util.text.TextLinkifier; - -import java.util.Queue; -import java.util.function.Supplier; - -import icepick.State; -import io.reactivex.rxjava3.core.Single; -import io.reactivex.rxjava3.disposables.CompositeDisposable; - -public final class CommentRepliesFragment - extends BaseListInfoFragment { - - public static final String TAG = CommentRepliesFragment.class.getSimpleName(); - - @State - CommentsInfoItem commentsInfoItem; // the comment to show replies of - private final CompositeDisposable disposables = new CompositeDisposable(); - - - /*////////////////////////////////////////////////////////////////////////// - // Constructors and lifecycle - //////////////////////////////////////////////////////////////////////////*/ - - // only called by the Android framework, after which readFrom is called and restores all data - public CommentRepliesFragment() { - super(UserAction.REQUESTED_COMMENT_REPLIES); - } - - public CommentRepliesFragment(@NonNull final CommentsInfoItem commentsInfoItem) { - this(); - this.commentsInfoItem = commentsInfoItem; - // setting "" as title since the title will be properly set right after - setInitialData(commentsInfoItem.getServiceId(), commentsInfoItem.getUrl(), ""); - } - - @Nullable - @Override - public View onCreateView(@NonNull final LayoutInflater inflater, - @Nullable final ViewGroup container, - @Nullable final Bundle savedInstanceState) { - return inflater.inflate(R.layout.fragment_comments, container, false); - } - - @Override - public void onDestroyView() { - disposables.clear(); - super.onDestroyView(); - } - - @Override - protected Supplier getListHeaderSupplier() { - return () -> { - final CommentRepliesHeaderBinding binding = CommentRepliesHeaderBinding - .inflate(activity.getLayoutInflater(), itemsList, false); - final CommentsInfoItem item = commentsInfoItem; - - // load the author avatar - PicassoHelper.loadAvatar(item.getUploaderAvatars()).into(binding.authorAvatar); - binding.authorAvatar.setVisibility(ImageStrategy.shouldLoadImages() - ? View.VISIBLE : View.GONE); - - // setup author name and comment date - binding.authorName.setText(item.getUploaderName()); - binding.uploadDate.setText(Localization.relativeTimeOrTextual( - getContext(), item.getUploadDate(), item.getTextualUploadDate())); - binding.authorTouchArea.setOnClickListener( - v -> NavigationHelper.openCommentAuthorIfPresent(requireActivity(), item)); - - // setup like count, hearted and pinned - binding.thumbsUpCount.setText( - Localization.likeCount(requireContext(), item.getLikeCount())); - // for heartImage goneMarginEnd was used, but there is no way to tell ConstraintLayout - // not to use a different margin only when both the next two views are gone - ((ConstraintLayout.LayoutParams) binding.thumbsUpCount.getLayoutParams()) - .setMarginEnd(DeviceUtils.dpToPx( - (item.isHeartedByUploader() || item.isPinned() ? 8 : 16), - requireContext())); - binding.heartImage.setVisibility(item.isHeartedByUploader() ? View.VISIBLE : View.GONE); - binding.pinnedImage.setVisibility(item.isPinned() ? View.VISIBLE : View.GONE); - - // setup comment content - TextLinkifier.fromDescription(binding.commentContent, item.getCommentText(), - HtmlCompat.FROM_HTML_MODE_LEGACY, getServiceById(item.getServiceId()), - item.getUrl(), disposables, null); - - return binding.getRoot(); - }; - } - - - /*////////////////////////////////////////////////////////////////////////// - // State saving - //////////////////////////////////////////////////////////////////////////*/ - - @Override - public void writeTo(final Queue objectsToSave) { - super.writeTo(objectsToSave); - objectsToSave.add(commentsInfoItem); - } - - @Override - public void readFrom(@NonNull final Queue savedObjects) throws Exception { - super.readFrom(savedObjects); - commentsInfoItem = (CommentsInfoItem) savedObjects.poll(); - } - - - /*////////////////////////////////////////////////////////////////////////// - // Data loading - //////////////////////////////////////////////////////////////////////////*/ - - @Override - protected Single loadResult(final boolean forceLoad) { - return Single.fromCallable(() -> new CommentRepliesInfo(commentsInfoItem, - // the reply count string will be shown as the activity title - Localization.replyCount(requireContext(), commentsInfoItem.getReplyCount()))); - } - - @Override - protected Single> loadMoreItemsLogic() { - // commentsInfoItem.getUrl() should contain the url of the original - // ListInfo, which should be the stream url - return ExtractorHelper.getMoreCommentItems( - serviceId, commentsInfoItem.getUrl(), currentNextPage); - } - - - /*////////////////////////////////////////////////////////////////////////// - // Utils - //////////////////////////////////////////////////////////////////////////*/ - - @Override - protected ItemViewMode getItemViewMode() { - return ItemViewMode.LIST; - } - - /** - * @return the comment to which the replies are shown - */ - public CommentsInfoItem getCommentsInfoItem() { - return commentsInfoItem; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/comments/CommentRepliesInfo.java b/app/src/main/java/org/schabi/newpipe/fragments/list/comments/CommentRepliesInfo.java deleted file mode 100644 index cc160c39538..00000000000 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/comments/CommentRepliesInfo.java +++ /dev/null @@ -1,22 +0,0 @@ -package org.schabi.newpipe.fragments.list.comments; - -import org.schabi.newpipe.extractor.ListInfo; -import org.schabi.newpipe.extractor.comments.CommentsInfoItem; -import org.schabi.newpipe.extractor.linkhandler.ListLinkHandler; - -import java.util.Collections; - -public final class CommentRepliesInfo extends ListInfo { - /** - * This class is used to wrap the comment replies page into a ListInfo object. - * - * @param comment the comment from which to get replies - * @param name will be shown as the fragment title - */ - public CommentRepliesInfo(final CommentsInfoItem comment, final String name) { - super(comment.getServiceId(), - new ListLinkHandler("", "", "", Collections.emptyList(), null), name); - setNextPage(comment.getReplies()); - setRelatedItems(Collections.emptyList()); // since it must be non-null - } -} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/comments/CommentsFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/comments/CommentsFragment.java deleted file mode 100644 index e25e02794f1..00000000000 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/comments/CommentsFragment.java +++ /dev/null @@ -1,123 +0,0 @@ -package org.schabi.newpipe.fragments.list.comments; - -import android.os.Bundle; -import android.view.LayoutInflater; -import android.view.Menu; -import android.view.MenuInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.TextView; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import org.schabi.newpipe.R; -import org.schabi.newpipe.error.UserAction; -import org.schabi.newpipe.extractor.ListExtractor; -import org.schabi.newpipe.extractor.comments.CommentsInfo; -import org.schabi.newpipe.extractor.comments.CommentsInfoItem; -import org.schabi.newpipe.fragments.list.BaseListInfoFragment; -import org.schabi.newpipe.info_list.ItemViewMode; -import org.schabi.newpipe.ktx.ViewUtils; -import org.schabi.newpipe.util.ExtractorHelper; - -import io.reactivex.rxjava3.core.Single; -import io.reactivex.rxjava3.disposables.CompositeDisposable; - -public class CommentsFragment extends BaseListInfoFragment { - private final CompositeDisposable disposables = new CompositeDisposable(); - - private TextView emptyStateDesc; - - public static CommentsFragment getInstance(final int serviceId, final String url, - final String name) { - final CommentsFragment instance = new CommentsFragment(); - instance.setInitialData(serviceId, url, name); - return instance; - } - - public CommentsFragment() { - super(UserAction.REQUESTED_COMMENTS); - } - - @Override - protected void initViews(final View rootView, final Bundle savedInstanceState) { - super.initViews(rootView, savedInstanceState); - - emptyStateDesc = rootView.findViewById(R.id.empty_state_desc); - } - - /*////////////////////////////////////////////////////////////////////////// - // LifeCycle - //////////////////////////////////////////////////////////////////////////*/ - - @Override - public View onCreateView(@NonNull final LayoutInflater inflater, - @Nullable final ViewGroup container, - @Nullable final Bundle savedInstanceState) { - return inflater.inflate(R.layout.fragment_comments, container, false); - } - - @Override - public void onDestroy() { - super.onDestroy(); - disposables.clear(); - } - - /*////////////////////////////////////////////////////////////////////////// - // Load and handle - //////////////////////////////////////////////////////////////////////////*/ - - @Override - protected Single> loadMoreItemsLogic() { - return ExtractorHelper.getMoreCommentItems(serviceId, currentInfo, currentNextPage); - } - - @Override - protected Single loadResult(final boolean forceLoad) { - return ExtractorHelper.getCommentsInfo(serviceId, url, forceLoad); - } - - /*////////////////////////////////////////////////////////////////////////// - // Contract - //////////////////////////////////////////////////////////////////////////*/ - - @Override - public void handleResult(@NonNull final CommentsInfo result) { - super.handleResult(result); - - emptyStateDesc.setText( - result.isCommentsDisabled() - ? R.string.comments_are_disabled - : R.string.no_comments); - - ViewUtils.slideUp(requireView(), 120, 150, 0.06f); - disposables.clear(); - } - - /*////////////////////////////////////////////////////////////////////////// - // Utils - //////////////////////////////////////////////////////////////////////////*/ - - @Override - public void setTitle(final String title) { } - - @Override - public void onCreateOptionsMenu(@NonNull final Menu menu, - @NonNull final MenuInflater inflater) { } - - @Override - protected ItemViewMode getItemViewMode() { - return ItemViewMode.LIST; - } - - public boolean scrollToComment(final CommentsInfoItem comment) { - final int position = infoListAdapter.getItemsList().indexOf(comment); - if (position < 0) { - return false; - } - - itemsList.scrollToPosition(position); - return true; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/comments/CommentsFragment.kt b/app/src/main/java/org/schabi/newpipe/fragments/list/comments/CommentsFragment.kt new file mode 100644 index 00000000000..10eea4e786f --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/comments/CommentsFragment.kt @@ -0,0 +1,38 @@ +package org.schabi.newpipe.fragments.list.comments + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.core.os.bundleOf +import androidx.fragment.app.Fragment +import androidx.lifecycle.viewmodel.compose.viewModel +import org.schabi.newpipe.ui.components.comment.CommentSection +import org.schabi.newpipe.ui.theme.AppTheme +import org.schabi.newpipe.util.KEY_SERVICE_ID +import org.schabi.newpipe.util.KEY_URL +import org.schabi.newpipe.viewmodels.CommentsViewModel + +class CommentsFragment : Fragment() { + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ) = ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + val viewModel = viewModel() + AppTheme { + CommentSection(commentsFlow = viewModel.comments) + } + } + } + + companion object { + @JvmStatic + fun getInstance(serviceId: Int, url: String?) = CommentsFragment().apply { + arguments = bundleOf(KEY_SERVICE_ID to serviceId, KEY_URL to url) + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/playlist/PlaylistFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/playlist/PlaylistFragment.java deleted file mode 100644 index 6410fb9ee75..00000000000 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/playlist/PlaylistFragment.java +++ /dev/null @@ -1,515 +0,0 @@ -package org.schabi.newpipe.fragments.list.playlist; - -import static org.schabi.newpipe.extractor.utils.Utils.isBlank; -import static org.schabi.newpipe.ktx.ViewUtils.animate; -import static org.schabi.newpipe.ktx.ViewUtils.animateHideRecyclerViewAllowingScrolling; -import static org.schabi.newpipe.util.ServiceHelper.getServiceById; - -import android.content.Context; -import android.os.Bundle; -import android.text.TextUtils; -import android.util.Log; -import android.view.LayoutInflater; -import android.view.Menu; -import android.view.MenuInflater; -import android.view.MenuItem; -import android.view.View; -import android.view.ViewGroup; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.appcompat.content.res.AppCompatResources; - -import com.google.android.material.shape.CornerFamily; -import com.google.android.material.shape.ShapeAppearanceModel; - -import org.reactivestreams.Subscriber; -import org.reactivestreams.Subscription; -import org.schabi.newpipe.NewPipeDatabase; -import org.schabi.newpipe.R; -import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity; -import org.schabi.newpipe.database.stream.model.StreamEntity; -import org.schabi.newpipe.databinding.PlaylistControlBinding; -import org.schabi.newpipe.databinding.PlaylistHeaderBinding; -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.ListExtractor; -import org.schabi.newpipe.extractor.ServiceList; -import org.schabi.newpipe.extractor.playlist.PlaylistInfo; -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; -import org.schabi.newpipe.player.playqueue.PlaylistPlayQueue; -import org.schabi.newpipe.util.ExtractorHelper; -import org.schabi.newpipe.util.Localization; -import org.schabi.newpipe.util.NavigationHelper; -import org.schabi.newpipe.util.PlayButtonHelper; -import org.schabi.newpipe.util.external_communication.ShareUtils; -import org.schabi.newpipe.util.image.PicassoHelper; -import org.schabi.newpipe.util.text.TextEllipsizer; - -import java.util.ArrayList; -import java.util.List; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.function.Supplier; -import java.util.stream.Collectors; - -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; -import io.reactivex.rxjava3.core.Flowable; -import io.reactivex.rxjava3.core.Single; -import io.reactivex.rxjava3.disposables.CompositeDisposable; -import io.reactivex.rxjava3.disposables.Disposable; - -public class PlaylistFragment extends BaseListInfoFragment - implements PlaylistControlViewHolder { - - private static final String PICASSO_PLAYLIST_TAG = "PICASSO_PLAYLIST_TAG"; - - private CompositeDisposable disposables; - private Subscription bookmarkReactor; - private AtomicBoolean isBookmarkButtonReady; - - private RemotePlaylistManager remotePlaylistManager; - private PlaylistRemoteEntity playlistEntity; - - /*////////////////////////////////////////////////////////////////////////// - // Views - //////////////////////////////////////////////////////////////////////////*/ - - private PlaylistHeaderBinding headerBinding; - private PlaylistControlBinding playlistControlBinding; - - private MenuItem playlistBookmarkButton; - - private long streamCount; - private long playlistOverallDurationSeconds; - - public static PlaylistFragment getInstance(final int serviceId, final String url, - final String name) { - final PlaylistFragment instance = new PlaylistFragment(); - instance.setInitialData(serviceId, url, name); - return instance; - } - - public PlaylistFragment() { - super(UserAction.REQUESTED_PLAYLIST); - } - - /*////////////////////////////////////////////////////////////////////////// - // LifeCycle - //////////////////////////////////////////////////////////////////////////*/ - - @Override - public void onCreate(final Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - disposables = new CompositeDisposable(); - isBookmarkButtonReady = new AtomicBoolean(false); - remotePlaylistManager = new RemotePlaylistManager(NewPipeDatabase - .getInstance(requireContext())); - } - - @Override - public View onCreateView(@NonNull final LayoutInflater inflater, - @Nullable final ViewGroup container, - @Nullable final Bundle savedInstanceState) { - return inflater.inflate(R.layout.fragment_playlist, container, false); - } - - /*////////////////////////////////////////////////////////////////////////// - // Init - //////////////////////////////////////////////////////////////////////////*/ - - @Override - protected Supplier getListHeaderSupplier() { - headerBinding = PlaylistHeaderBinding - .inflate(activity.getLayoutInflater(), itemsList, false); - playlistControlBinding = headerBinding.playlistControl; - - return headerBinding::getRoot; - } - - @Override - protected void initViews(final View rootView, final Bundle savedInstanceState) { - super.initViews(rootView, savedInstanceState); - - // Is mini variant still relevant? - // Only the remote playlist screen uses it now - 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) { - if (DEBUG) { - Log.d(TAG, "onCreateOptionsMenu() called with: " - + "menu = [" + menu + "], inflater = [" + inflater + "]"); - } - super.onCreateOptionsMenu(menu, inflater); - inflater.inflate(R.menu.menu_playlist, menu); - - playlistBookmarkButton = menu.findItem(R.id.menu_item_bookmark); - updateBookmarkButtons(); - } - - @Override - public void onDestroyView() { - headerBinding = null; - playlistControlBinding = null; - - super.onDestroyView(); - if (isBookmarkButtonReady != null) { - isBookmarkButtonReady.set(false); - } - - if (disposables != null) { - disposables.clear(); - } - if (bookmarkReactor != null) { - bookmarkReactor.cancel(); - } - - bookmarkReactor = null; - } - - @Override - public void onDestroy() { - super.onDestroy(); - - if (disposables != null) { - disposables.dispose(); - } - - disposables = null; - remotePlaylistManager = null; - playlistEntity = null; - isBookmarkButtonReady = null; - } - - /*////////////////////////////////////////////////////////////////////////// - // Load and handle - //////////////////////////////////////////////////////////////////////////*/ - - @Override - protected Single> loadMoreItemsLogic() { - return ExtractorHelper.getMorePlaylistItems(serviceId, url, currentNextPage); - } - - @Override - protected Single loadResult(final boolean forceLoad) { - return ExtractorHelper.getPlaylistInfo(serviceId, url, forceLoad); - } - - @Override - public boolean onOptionsItemSelected(final MenuItem item) { - switch (item.getItemId()) { - case R.id.action_settings: - NavigationHelper.openSettings(requireContext()); - break; - case R.id.menu_item_openInBrowser: - ShareUtils.openUrlInBrowser(requireContext(), url); - break; - case R.id.menu_item_share: - ShareUtils.shareText(requireContext(), name, url, - currentInfo == null ? List.of() : currentInfo.getThumbnails()); - break; - case R.id.menu_item_bookmark: - onBookmarkClicked(); - 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) - )); - } - break; - default: - return super.onOptionsItemSelected(item); - } - return true; - } - - - /*////////////////////////////////////////////////////////////////////////// - // Contract - //////////////////////////////////////////////////////////////////////////*/ - - @Override - public void showLoading() { - super.showLoading(); - animate(headerBinding.getRoot(), false, 200); - animateHideRecyclerViewAllowingScrolling(itemsList); - - PicassoHelper.cancelTag(PICASSO_PLAYLIST_TAG); - animate(headerBinding.uploaderLayout, false, 200); - } - - @Override - public void handleNextItems(final ListExtractor.InfoItemsPage result) { - super.handleNextItems(result); - setStreamCountAndOverallDuration(result.getItems(), !result.hasNextPage()); - } - - @Override - public void handleResult(@NonNull final PlaylistInfo result) { - super.handleResult(result); - - animate(headerBinding.getRoot(), true, 100); - animate(headerBinding.uploaderLayout, true, 300); - headerBinding.uploaderLayout.setOnClickListener(null); - // If we have an uploader put them into the UI - if (!TextUtils.isEmpty(result.getUploaderName())) { - headerBinding.uploaderName.setText(result.getUploaderName()); - if (!TextUtils.isEmpty(result.getUploaderUrl())) { - headerBinding.uploaderLayout.setOnClickListener(v -> { - try { - NavigationHelper.openChannelFragment(getFM(), result.getServiceId(), - result.getUploaderUrl(), result.getUploaderName()); - } catch (final Exception e) { - ErrorUtil.showUiErrorSnackbar(this, "Opening channel fragment", e); - } - }); - } - } else { // Otherwise say we have no uploader - headerBinding.uploaderName.setText(R.string.playlist_no_uploader); - } - - playlistControlBinding.getRoot().setVisibility(View.VISIBLE); - - if (result.getServiceId() == ServiceList.YouTube.getServiceId() - && (YoutubeParsingHelper.isYoutubeMixId(result.getId()) - || YoutubeParsingHelper.isYoutubeMusicMixId(result.getId()))) { - // this is an auto-generated playlist (e.g. Youtube mix), so a radio is shown - final ShapeAppearanceModel model = ShapeAppearanceModel.builder() - .setAllCorners(CornerFamily.ROUNDED, 0f) - .build(); // this turns the image back into a square - headerBinding.uploaderAvatarView.setShapeAppearanceModel(model); - headerBinding.uploaderAvatarView.setStrokeColor(AppCompatResources - .getColorStateList(requireContext(), R.color.transparent_background_color)); - headerBinding.uploaderAvatarView.setImageDrawable( - AppCompatResources.getDrawable(requireContext(), - R.drawable.ic_radio) - ); - } else { - PicassoHelper.loadAvatar(result.getUploaderAvatars()).tag(PICASSO_PLAYLIST_TAG) - .into(headerBinding.uploaderAvatarView); - } - - streamCount = result.getStreamCount(); - setStreamCountAndOverallDuration(result.getRelatedItems(), !result.hasNextPage()); - - final Description description = result.getDescription(); - if (description != null && description != Description.EMPTY_DESCRIPTION - && !isBlank(description.getContent())) { - final TextEllipsizer ellipsizer = new TextEllipsizer( - headerBinding.playlistDescription, 5, getServiceById(result.getServiceId())); - ellipsizer.setStateChangeListener(isEllipsized -> - headerBinding.playlistDescriptionReadMore.setText( - Boolean.TRUE.equals(isEllipsized) ? R.string.show_more : R.string.show_less - )); - ellipsizer.setOnContentChanged(canBeEllipsized -> { - headerBinding.playlistDescriptionReadMore.setVisibility( - Boolean.TRUE.equals(canBeEllipsized) ? View.VISIBLE : View.GONE); - if (Boolean.TRUE.equals(canBeEllipsized)) { - ellipsizer.ellipsize(); - } - }); - ellipsizer.setContent(description); - headerBinding.playlistDescriptionReadMore.setOnClickListener(v -> ellipsizer.toggle()); - headerBinding.playlistDescription.setOnClickListener(v -> ellipsizer.toggle()); - } else { - headerBinding.playlistDescription.setVisibility(View.GONE); - headerBinding.playlistDescriptionReadMore.setVisibility(View.GONE); - } - - if (!result.getErrors().isEmpty()) { - showSnackBarError(new ErrorInfo(result.getErrors(), UserAction.REQUESTED_PLAYLIST, - result.getUrl(), result)); - } - - remotePlaylistManager.getPlaylist(result) - .flatMap(lists -> getUpdateProcessor(lists, result), (lists, id) -> lists) - .onBackpressureLatest() - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(getPlaylistBookmarkSubscriber()); - - PlayButtonHelper.initPlaylistControlClickListener(activity, playlistControlBinding, this); - } - - public PlayQueue getPlayQueue() { - return getPlayQueue(0); - } - - private PlayQueue getPlayQueue(final int index) { - final List infoItems = new ArrayList<>(); - for (final InfoItem i : infoListAdapter.getItemsList()) { - if (i instanceof StreamInfoItem) { - infoItems.add((StreamInfoItem) i); - } - } - return new PlaylistPlayQueue( - currentInfo.getServiceId(), - currentInfo.getUrl(), - currentInfo.getNextPage(), - infoItems, - index - ); - } - - /*////////////////////////////////////////////////////////////////////////// - // Utils - //////////////////////////////////////////////////////////////////////////*/ - - private Flowable getUpdateProcessor( - @NonNull final List playlists, - @NonNull final PlaylistInfo result) { - final Flowable noItemToUpdate = Flowable.just(/*noItemToUpdate=*/-1); - if (playlists.isEmpty()) { - return noItemToUpdate; - } - - final PlaylistRemoteEntity playlistRemoteEntity = playlists.get(0); - if (playlistRemoteEntity.isIdenticalTo(result)) { - return noItemToUpdate; - } - - return remotePlaylistManager.onUpdate(playlists.get(0).getUid(), result).toFlowable(); - } - - private Subscriber> getPlaylistBookmarkSubscriber() { - return new Subscriber<>() { - @Override - public void onSubscribe(final Subscription s) { - if (bookmarkReactor != null) { - bookmarkReactor.cancel(); - } - bookmarkReactor = s; - bookmarkReactor.request(1); - } - - @Override - public void onNext(final List playlist) { - playlistEntity = playlist.isEmpty() ? null : playlist.get(0); - - updateBookmarkButtons(); - isBookmarkButtonReady.set(true); - - if (bookmarkReactor != null) { - bookmarkReactor.request(1); - } - } - - @Override - public void onError(final Throwable throwable) { - showError(new ErrorInfo(throwable, UserAction.REQUESTED_BOOKMARK, - "Get playlist bookmarks")); - } - - @Override - public void onComplete() { } - }; - } - - @Override - public void setTitle(final String title) { - super.setTitle(title); - if (headerBinding != null) { - headerBinding.playlistTitleView.setText(title); - } - } - - private void onBookmarkClicked() { - if (isBookmarkButtonReady == null || !isBookmarkButtonReady.get() - || remotePlaylistManager == null) { - return; - } - - final Disposable action; - - if (currentInfo != null && playlistEntity == null) { - action = remotePlaylistManager.onBookmark(currentInfo) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(ignored -> { /* Do nothing */ }, throwable -> - showError(new ErrorInfo(throwable, UserAction.REQUESTED_BOOKMARK, - "Adding playlist bookmark"))); - } else if (playlistEntity != null) { - action = remotePlaylistManager.deletePlaylist(playlistEntity.getUid()) - .observeOn(AndroidSchedulers.mainThread()) - .doFinally(() -> playlistEntity = null) - .subscribe(ignored -> { /* Do nothing */ }, throwable -> - showError(new ErrorInfo(throwable, UserAction.REQUESTED_BOOKMARK, - "Deleting playlist bookmark"))); - } else { - action = Disposable.empty(); - } - - disposables.add(action); - } - - private void updateBookmarkButtons() { - if (playlistBookmarkButton == null || activity == null) { - return; - } - - final int drawable = playlistEntity == null - ? R.drawable.ic_playlist_add : R.drawable.ic_playlist_add_check; - - final int titleRes = playlistEntity == null - ? R.string.bookmark_playlist : R.string.unbookmark_playlist; - - playlistBookmarkButton.setIcon(drawable); - playlistBookmarkButton.setTitle(titleRes); - } - - private void setStreamCountAndOverallDuration(final List list, - final boolean isDurationComplete) { - if (activity != null && headerBinding != null) { - playlistOverallDurationSeconds += list.stream() - .mapToLong(x -> x.getDuration()) - .sum(); - headerBinding.playlistStreamCount.setText( - Localization.concatenateStrings( - Localization.localizeStreamCount(activity, streamCount), - Localization.getDurationString(playlistOverallDurationSeconds, - isDurationComplete, true)) - ); - } - } - -} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/playlist/PlaylistFragment.kt b/app/src/main/java/org/schabi/newpipe/fragments/list/playlist/PlaylistFragment.kt new file mode 100644 index 00000000000..f854606100f --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/playlist/PlaylistFragment.kt @@ -0,0 +1,35 @@ +package org.schabi.newpipe.fragments.list.playlist + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.core.os.bundleOf +import androidx.fragment.app.Fragment +import org.schabi.newpipe.ui.screens.PlaylistScreen +import org.schabi.newpipe.ui.theme.AppTheme +import org.schabi.newpipe.util.KEY_SERVICE_ID +import org.schabi.newpipe.util.KEY_URL + +class PlaylistFragment : Fragment() { + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ) = ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + AppTheme { + PlaylistScreen() + } + } + } + + companion object { + @JvmStatic + fun getInstance(serviceId: Int, url: String?) = PlaylistFragment().apply { + arguments = bundleOf(KEY_SERVICE_ID to serviceId, KEY_URL to url) + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/info_list/InfoItemBuilder.java b/app/src/main/java/org/schabi/newpipe/info_list/InfoItemBuilder.java index d959c63277c..a1526af2894 100644 --- a/app/src/main/java/org/schabi/newpipe/info_list/InfoItemBuilder.java +++ b/app/src/main/java/org/schabi/newpipe/info_list/InfoItemBuilder.java @@ -13,7 +13,6 @@ import org.schabi.newpipe.extractor.stream.StreamInfoItem; import org.schabi.newpipe.info_list.holder.ChannelInfoItemHolder; import org.schabi.newpipe.info_list.holder.ChannelMiniInfoItemHolder; -import org.schabi.newpipe.info_list.holder.CommentInfoItemHolder; import org.schabi.newpipe.info_list.holder.InfoItemHolder; import org.schabi.newpipe.info_list.holder.PlaylistInfoItemHolder; import org.schabi.newpipe.info_list.holder.PlaylistMiniInfoItemHolder; @@ -75,21 +74,16 @@ public View buildView(@NonNull final ViewGroup parent, @NonNull final InfoItem i private InfoItemHolder holderFromInfoType(@NonNull final ViewGroup parent, @NonNull final InfoItem.InfoType infoType, final boolean useMiniVariant) { - switch (infoType) { - case STREAM: - return useMiniVariant ? new StreamMiniInfoItemHolder(this, parent) - : new StreamInfoItemHolder(this, parent); - case CHANNEL: - return useMiniVariant ? new ChannelMiniInfoItemHolder(this, parent) - : new ChannelInfoItemHolder(this, parent); - case PLAYLIST: - return useMiniVariant ? new PlaylistMiniInfoItemHolder(this, parent) - : new PlaylistInfoItemHolder(this, parent); - case COMMENT: - return new CommentInfoItemHolder(this, parent); - default: - throw new RuntimeException("InfoType not expected = " + infoType.name()); - } + return switch (infoType) { + case STREAM -> useMiniVariant ? new StreamMiniInfoItemHolder(this, parent) + : new StreamInfoItemHolder(this, parent); + case CHANNEL -> useMiniVariant ? new ChannelMiniInfoItemHolder(this, parent) + : new ChannelInfoItemHolder(this, parent); + case PLAYLIST -> useMiniVariant ? new PlaylistMiniInfoItemHolder(this, parent) + : new PlaylistInfoItemHolder(this, parent); + case COMMENT -> + throw new IllegalArgumentException("Comments should be rendered using Compose"); + }; } public Context getContext() { diff --git a/app/src/main/java/org/schabi/newpipe/info_list/InfoListAdapter.java b/app/src/main/java/org/schabi/newpipe/info_list/InfoListAdapter.java index 575568c00f9..e7cf9ba9a9e 100644 --- a/app/src/main/java/org/schabi/newpipe/info_list/InfoListAdapter.java +++ b/app/src/main/java/org/schabi/newpipe/info_list/InfoListAdapter.java @@ -21,7 +21,6 @@ import org.schabi.newpipe.info_list.holder.ChannelGridInfoItemHolder; import org.schabi.newpipe.info_list.holder.ChannelInfoItemHolder; import org.schabi.newpipe.info_list.holder.ChannelMiniInfoItemHolder; -import org.schabi.newpipe.info_list.holder.CommentInfoItemHolder; import org.schabi.newpipe.info_list.holder.InfoItemHolder; import org.schabi.newpipe.info_list.holder.PlaylistCardInfoItemHolder; import org.schabi.newpipe.info_list.holder.PlaylistGridInfoItemHolder; @@ -283,46 +282,32 @@ public RecyclerView.ViewHolder onCreateViewHolder(@NonNull final ViewGroup paren Log.d(TAG, "onCreateViewHolder() called with: " + "parent = [" + parent + "], type = [" + type + "]"); } - switch (type) { + return switch (type) { // #4475 and #3368 // Always create a new instance otherwise the same instance // is sometimes reused which causes a crash - case HEADER_TYPE: - return new HFHolder(headerSupplier.get()); - case FOOTER_TYPE: - return new HFHolder(PignateFooterBinding - .inflate(layoutInflater, parent, false) - .getRoot() - ); - case MINI_STREAM_HOLDER_TYPE: - return new StreamMiniInfoItemHolder(infoItemBuilder, parent); - case STREAM_HOLDER_TYPE: - return new StreamInfoItemHolder(infoItemBuilder, parent); - case GRID_STREAM_HOLDER_TYPE: - return new StreamGridInfoItemHolder(infoItemBuilder, parent); - case CARD_STREAM_HOLDER_TYPE: - return new StreamCardInfoItemHolder(infoItemBuilder, parent); - case MINI_CHANNEL_HOLDER_TYPE: - return new ChannelMiniInfoItemHolder(infoItemBuilder, parent); - case CHANNEL_HOLDER_TYPE: - return new ChannelInfoItemHolder(infoItemBuilder, parent); - case CARD_CHANNEL_HOLDER_TYPE: - return new ChannelCardInfoItemHolder(infoItemBuilder, parent); - case GRID_CHANNEL_HOLDER_TYPE: - return new ChannelGridInfoItemHolder(infoItemBuilder, parent); - case MINI_PLAYLIST_HOLDER_TYPE: - return new PlaylistMiniInfoItemHolder(infoItemBuilder, parent); - case PLAYLIST_HOLDER_TYPE: - return new PlaylistInfoItemHolder(infoItemBuilder, parent); - case GRID_PLAYLIST_HOLDER_TYPE: - return new PlaylistGridInfoItemHolder(infoItemBuilder, parent); - case CARD_PLAYLIST_HOLDER_TYPE: - return new PlaylistCardInfoItemHolder(infoItemBuilder, parent); - case COMMENT_HOLDER_TYPE: - return new CommentInfoItemHolder(infoItemBuilder, parent); - default: - return new FallbackViewHolder(new View(parent.getContext())); - } + case HEADER_TYPE -> new HFHolder(headerSupplier.get()); + case FOOTER_TYPE -> new HFHolder(PignateFooterBinding + .inflate(layoutInflater, parent, false) + .getRoot() + ); + case MINI_STREAM_HOLDER_TYPE -> new StreamMiniInfoItemHolder(infoItemBuilder, parent); + case STREAM_HOLDER_TYPE -> new StreamInfoItemHolder(infoItemBuilder, parent); + case GRID_STREAM_HOLDER_TYPE -> new StreamGridInfoItemHolder(infoItemBuilder, parent); + case CARD_STREAM_HOLDER_TYPE -> new StreamCardInfoItemHolder(infoItemBuilder, parent); + case MINI_CHANNEL_HOLDER_TYPE -> new ChannelMiniInfoItemHolder(infoItemBuilder, parent); + case CHANNEL_HOLDER_TYPE -> new ChannelInfoItemHolder(infoItemBuilder, parent); + case CARD_CHANNEL_HOLDER_TYPE -> new ChannelCardInfoItemHolder(infoItemBuilder, parent); + case GRID_CHANNEL_HOLDER_TYPE -> new ChannelGridInfoItemHolder(infoItemBuilder, parent); + case MINI_PLAYLIST_HOLDER_TYPE -> + new PlaylistMiniInfoItemHolder(infoItemBuilder, parent); + case PLAYLIST_HOLDER_TYPE -> new PlaylistInfoItemHolder(infoItemBuilder, parent); + case GRID_PLAYLIST_HOLDER_TYPE -> + new PlaylistGridInfoItemHolder(infoItemBuilder, parent); + case CARD_PLAYLIST_HOLDER_TYPE -> + new PlaylistCardInfoItemHolder(infoItemBuilder, parent); + default -> new FallbackViewHolder(new View(parent.getContext())); + }; } @Override diff --git a/app/src/main/java/org/schabi/newpipe/info_list/ItemViewMode.java b/app/src/main/java/org/schabi/newpipe/info_list/ItemViewMode.java index 447c540a0cd..5d5650b925d 100644 --- a/app/src/main/java/org/schabi/newpipe/info_list/ItemViewMode.java +++ b/app/src/main/java/org/schabi/newpipe/info_list/ItemViewMode.java @@ -4,10 +4,6 @@ * Item view mode for streams & playlist listing screens. */ public enum ItemViewMode { - /** - * Default mode. - */ - AUTO, /** * Full width list item with thumb on the left and two line title & uploader in right. */ diff --git a/app/src/main/java/org/schabi/newpipe/info_list/StreamSegmentItem.kt b/app/src/main/java/org/schabi/newpipe/info_list/StreamSegmentItem.kt index 1e52d316808..83e0c408289 100644 --- a/app/src/main/java/org/schabi/newpipe/info_list/StreamSegmentItem.kt +++ b/app/src/main/java/org/schabi/newpipe/info_list/StreamSegmentItem.kt @@ -1,19 +1,18 @@ package org.schabi.newpipe.info_list import android.view.View -import android.widget.ImageView -import android.widget.TextView -import com.xwray.groupie.GroupieViewHolder -import com.xwray.groupie.Item +import com.xwray.groupie.viewbinding.BindableItem +import com.xwray.groupie.viewbinding.GroupieViewHolder import org.schabi.newpipe.R +import org.schabi.newpipe.databinding.ItemStreamSegmentBinding import org.schabi.newpipe.extractor.stream.StreamSegment import org.schabi.newpipe.util.Localization -import org.schabi.newpipe.util.image.PicassoHelper +import org.schabi.newpipe.util.image.CoilHelper class StreamSegmentItem( private val item: StreamSegment, private val onClick: StreamSegmentAdapter.StreamSegmentListener -) : Item() { +) : BindableItem() { companion object { const val PAYLOAD_SELECT = 1 @@ -21,31 +20,32 @@ class StreamSegmentItem( var isSelected = false - override fun bind(viewHolder: GroupieViewHolder, position: Int) { - item.previewUrl?.let { - PicassoHelper.loadThumbnail(it) - .into(viewHolder.root.findViewById(R.id.previewImage)) - } - viewHolder.root.findViewById(R.id.textViewTitle).text = item.title + override fun bind(viewBinding: ItemStreamSegmentBinding, position: Int) { + CoilHelper.loadThumbnail(viewBinding.previewImage, item.previewUrl) + viewBinding.textViewTitle.text = item.title if (item.channelName == null) { - viewHolder.root.findViewById(R.id.textViewChannel).visibility = View.GONE + viewBinding.textViewChannel.visibility = View.GONE // When the channel name is displayed there is less space // and thus the segment title needs to be only one line height. // But when there is no channel name displayed, the title can be two lines long. // The default maxLines value is set to 1 to display all elements in the AS preview, - viewHolder.root.findViewById(R.id.textViewTitle).maxLines = 2 + viewBinding.textViewTitle.maxLines = 2 } else { - viewHolder.root.findViewById(R.id.textViewChannel).text = item.channelName - viewHolder.root.findViewById(R.id.textViewChannel).visibility = View.VISIBLE + viewBinding.textViewChannel.text = item.channelName + viewBinding.textViewChannel.visibility = View.VISIBLE } - viewHolder.root.findViewById(R.id.textViewStartSeconds).text = + viewBinding.textViewStartSeconds.text = Localization.getDurationString(item.startTimeSeconds.toLong()) - viewHolder.root.setOnClickListener { onClick.onItemClick(this, item.startTimeSeconds) } - viewHolder.root.setOnLongClickListener { onClick.onItemLongClick(this, item.startTimeSeconds); true } - viewHolder.root.isSelected = isSelected + viewBinding.root.setOnClickListener { onClick.onItemClick(this, item.startTimeSeconds) } + viewBinding.root.setOnLongClickListener { onClick.onItemLongClick(this, item.startTimeSeconds); true } + viewBinding.root.isSelected = isSelected } - override fun bind(viewHolder: GroupieViewHolder, position: Int, payloads: MutableList) { + override fun bind( + viewHolder: GroupieViewHolder, + position: Int, + payloads: MutableList + ) { if (payloads.contains(PAYLOAD_SELECT)) { viewHolder.root.isSelected = isSelected return @@ -54,4 +54,6 @@ class StreamSegmentItem( } override fun getLayout() = R.layout.item_stream_segment + + override fun initializeViewBinding(view: View) = ItemStreamSegmentBinding.bind(view) } diff --git a/app/src/main/java/org/schabi/newpipe/info_list/holder/ChannelMiniInfoItemHolder.java b/app/src/main/java/org/schabi/newpipe/info_list/holder/ChannelMiniInfoItemHolder.java index 7afc05c6c25..92a5054e130 100644 --- a/app/src/main/java/org/schabi/newpipe/info_list/holder/ChannelMiniInfoItemHolder.java +++ b/app/src/main/java/org/schabi/newpipe/info_list/holder/ChannelMiniInfoItemHolder.java @@ -13,8 +13,8 @@ import org.schabi.newpipe.extractor.utils.Utils; import org.schabi.newpipe.info_list.InfoItemBuilder; import org.schabi.newpipe.local.history.HistoryRecordManager; -import org.schabi.newpipe.util.image.PicassoHelper; import org.schabi.newpipe.util.Localization; +import org.schabi.newpipe.util.image.CoilHelper; public class ChannelMiniInfoItemHolder extends InfoItemHolder { private final ImageView itemThumbnailView; @@ -56,7 +56,7 @@ public void updateFromItem(final InfoItem infoItem, itemAdditionalDetailView.setText(getDetailLine(item)); } - PicassoHelper.loadAvatar(item.getThumbnails()).into(itemThumbnailView); + CoilHelper.INSTANCE.loadAvatar(itemThumbnailView, item.getThumbnails()); itemView.setOnClickListener(view -> { if (itemBuilder.getOnChannelSelectedListener() != null) { diff --git a/app/src/main/java/org/schabi/newpipe/info_list/holder/CommentInfoItemHolder.java b/app/src/main/java/org/schabi/newpipe/info_list/holder/CommentInfoItemHolder.java deleted file mode 100644 index 839aa1813f3..00000000000 --- a/app/src/main/java/org/schabi/newpipe/info_list/holder/CommentInfoItemHolder.java +++ /dev/null @@ -1,210 +0,0 @@ -package org.schabi.newpipe.info_list.holder; - -import static org.schabi.newpipe.util.ServiceHelper.getServiceById; -import static org.schabi.newpipe.util.text.TouchUtils.getOffsetForHorizontalLine; - -import android.text.Spanned; -import android.text.method.LinkMovementMethod; -import android.text.style.ClickableSpan; -import android.text.style.URLSpan; -import android.view.MotionEvent; -import android.view.View; -import android.view.ViewGroup; -import android.widget.Button; -import android.widget.ImageView; -import android.widget.RelativeLayout; -import android.widget.TextView; - -import androidx.annotation.NonNull; -import androidx.fragment.app.FragmentActivity; - -import org.schabi.newpipe.R; -import org.schabi.newpipe.extractor.InfoItem; -import org.schabi.newpipe.extractor.comments.CommentsInfoItem; -import org.schabi.newpipe.info_list.InfoItemBuilder; -import org.schabi.newpipe.local.history.HistoryRecordManager; -import org.schabi.newpipe.util.DeviceUtils; -import org.schabi.newpipe.util.Localization; -import org.schabi.newpipe.util.NavigationHelper; -import org.schabi.newpipe.util.external_communication.ShareUtils; -import org.schabi.newpipe.util.image.ImageStrategy; -import org.schabi.newpipe.util.image.PicassoHelper; -import org.schabi.newpipe.util.text.TextEllipsizer; - -public class CommentInfoItemHolder extends InfoItemHolder { - - private static final int COMMENT_DEFAULT_LINES = 2; - private final int commentHorizontalPadding; - private final int commentVerticalPadding; - - private final RelativeLayout itemRoot; - private final ImageView itemThumbnailView; - private final TextView itemContentView; - private final ImageView itemThumbsUpView; - private final TextView itemLikesCountView; - private final TextView itemTitleView; - private final ImageView itemHeartView; - private final ImageView itemPinnedView; - private final Button repliesButton; - - @NonNull - private final TextEllipsizer textEllipsizer; - - public CommentInfoItemHolder(final InfoItemBuilder infoItemBuilder, - final ViewGroup parent) { - super(infoItemBuilder, R.layout.list_comment_item, parent); - - itemRoot = itemView.findViewById(R.id.itemRoot); - itemThumbnailView = itemView.findViewById(R.id.itemThumbnailView); - itemContentView = itemView.findViewById(R.id.itemCommentContentView); - itemThumbsUpView = itemView.findViewById(R.id.detail_thumbs_up_img_view); - itemLikesCountView = itemView.findViewById(R.id.detail_thumbs_up_count_view); - itemTitleView = itemView.findViewById(R.id.itemTitleView); - itemHeartView = itemView.findViewById(R.id.detail_heart_image_view); - itemPinnedView = itemView.findViewById(R.id.detail_pinned_view); - repliesButton = itemView.findViewById(R.id.replies_button); - - commentHorizontalPadding = (int) infoItemBuilder.getContext() - .getResources().getDimension(R.dimen.comments_horizontal_padding); - commentVerticalPadding = (int) infoItemBuilder.getContext() - .getResources().getDimension(R.dimen.comments_vertical_padding); - - textEllipsizer = new TextEllipsizer(itemContentView, COMMENT_DEFAULT_LINES, null); - textEllipsizer.setStateChangeListener(isEllipsized -> { - if (Boolean.TRUE.equals(isEllipsized)) { - denyLinkFocus(); - } else { - determineMovementMethod(); - } - }); - } - - @Override - public void updateFromItem(final InfoItem infoItem, - final HistoryRecordManager historyRecordManager) { - if (!(infoItem instanceof CommentsInfoItem)) { - return; - } - final CommentsInfoItem item = (CommentsInfoItem) infoItem; - - - // load the author avatar - PicassoHelper.loadAvatar(item.getUploaderAvatars()).into(itemThumbnailView); - if (ImageStrategy.shouldLoadImages()) { - itemThumbnailView.setVisibility(View.VISIBLE); - itemRoot.setPadding(commentVerticalPadding, commentVerticalPadding, - commentVerticalPadding, commentVerticalPadding); - } else { - itemThumbnailView.setVisibility(View.GONE); - itemRoot.setPadding(commentHorizontalPadding, commentVerticalPadding, - commentHorizontalPadding, commentVerticalPadding); - } - itemThumbnailView.setOnClickListener(view -> openCommentAuthor(item)); - - - // setup the top row, with pinned icon, author name and comment date - itemPinnedView.setVisibility(item.isPinned() ? View.VISIBLE : View.GONE); - itemTitleView.setText(Localization.concatenateStrings(item.getUploaderName(), - Localization.relativeTimeOrTextual(itemBuilder.getContext(), item.getUploadDate(), - item.getTextualUploadDate()))); - - - // setup bottom row, with likes, heart and replies button - itemLikesCountView.setText( - Localization.likeCount(itemBuilder.getContext(), item.getLikeCount())); - - itemHeartView.setVisibility(item.isHeartedByUploader() ? View.VISIBLE : View.GONE); - - final boolean hasReplies = item.getReplies() != null; - repliesButton.setOnClickListener(hasReplies ? v -> openCommentReplies(item) : null); - repliesButton.setVisibility(hasReplies ? View.VISIBLE : View.GONE); - repliesButton.setText(hasReplies - ? Localization.replyCount(itemBuilder.getContext(), item.getReplyCount()) : ""); - ((RelativeLayout.LayoutParams) itemThumbsUpView.getLayoutParams()).topMargin = - hasReplies ? 0 : DeviceUtils.dpToPx(6, itemBuilder.getContext()); - - - // setup comment content and click listeners to expand/ellipsize it - textEllipsizer.setStreamingService(getServiceById(item.getServiceId())); - textEllipsizer.setStreamUrl(item.getUrl()); - textEllipsizer.setContent(item.getCommentText()); - textEllipsizer.ellipsize(); - - //noinspection ClickableViewAccessibility - itemContentView.setOnTouchListener((v, event) -> { - final CharSequence text = itemContentView.getText(); - if (text instanceof Spanned buffer) { - final int action = event.getAction(); - - if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_DOWN) { - final int offset = getOffsetForHorizontalLine(itemContentView, event); - final var links = buffer.getSpans(offset, offset, ClickableSpan.class); - - if (links.length != 0) { - if (action == MotionEvent.ACTION_UP) { - links[0].onClick(itemContentView); - } - // we handle events that intersect links, so return true - return true; - } - } - } - return false; - }); - - itemView.setOnClickListener(view -> { - textEllipsizer.toggle(); - if (itemBuilder.getOnCommentsSelectedListener() != null) { - itemBuilder.getOnCommentsSelectedListener().selected(item); - } - }); - - itemView.setOnLongClickListener(view -> { - if (DeviceUtils.isTv(itemBuilder.getContext())) { - openCommentAuthor(item); - } else { - final CharSequence text = itemContentView.getText(); - if (text != null) { - ShareUtils.copyToClipboard(itemBuilder.getContext(), text.toString()); - } - } - return true; - }); - } - - private void openCommentAuthor(@NonNull final CommentsInfoItem item) { - NavigationHelper.openCommentAuthorIfPresent((FragmentActivity) itemBuilder.getContext(), - item); - } - - private void openCommentReplies(@NonNull final CommentsInfoItem item) { - NavigationHelper.openCommentRepliesFragment((FragmentActivity) itemBuilder.getContext(), - item); - } - - private void allowLinkFocus() { - itemContentView.setMovementMethod(LinkMovementMethod.getInstance()); - } - - private void denyLinkFocus() { - itemContentView.setMovementMethod(null); - } - - private boolean shouldFocusLinks() { - if (itemView.isInTouchMode()) { - return false; - } - - final URLSpan[] urls = itemContentView.getUrls(); - - return urls != null && urls.length != 0; - } - - private void determineMovementMethod() { - if (shouldFocusLinks()) { - allowLinkFocus(); - } else { - denyLinkFocus(); - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/info_list/holder/PlaylistMiniInfoItemHolder.java b/app/src/main/java/org/schabi/newpipe/info_list/holder/PlaylistMiniInfoItemHolder.java index c9216d9a9e5..b7949318d58 100644 --- a/app/src/main/java/org/schabi/newpipe/info_list/holder/PlaylistMiniInfoItemHolder.java +++ b/app/src/main/java/org/schabi/newpipe/info_list/holder/PlaylistMiniInfoItemHolder.java @@ -9,8 +9,8 @@ import org.schabi.newpipe.extractor.playlist.PlaylistInfoItem; import org.schabi.newpipe.info_list.InfoItemBuilder; import org.schabi.newpipe.local.history.HistoryRecordManager; -import org.schabi.newpipe.util.image.PicassoHelper; import org.schabi.newpipe.util.Localization; +import org.schabi.newpipe.util.image.CoilHelper; public class PlaylistMiniInfoItemHolder extends InfoItemHolder { public final ImageView itemThumbnailView; @@ -46,7 +46,7 @@ public void updateFromItem(final InfoItem infoItem, .localizeStreamCountMini(itemStreamCountView.getContext(), item.getStreamCount())); itemUploaderView.setText(item.getUploaderName()); - PicassoHelper.loadPlaylistThumbnail(item.getThumbnails()).into(itemThumbnailView); + CoilHelper.INSTANCE.loadPlaylistThumbnail(itemThumbnailView, item.getThumbnails()); itemView.setOnClickListener(view -> { if (itemBuilder.getOnPlaylistSelectedListener() != null) { diff --git a/app/src/main/java/org/schabi/newpipe/info_list/holder/StreamMiniInfoItemHolder.java b/app/src/main/java/org/schabi/newpipe/info_list/holder/StreamMiniInfoItemHolder.java index 01f3be6b328..32fa8bf608b 100644 --- a/app/src/main/java/org/schabi/newpipe/info_list/holder/StreamMiniInfoItemHolder.java +++ b/app/src/main/java/org/schabi/newpipe/info_list/holder/StreamMiniInfoItemHolder.java @@ -16,8 +16,8 @@ import org.schabi.newpipe.local.history.HistoryRecordManager; import org.schabi.newpipe.util.DependentPreferenceHelper; import org.schabi.newpipe.util.Localization; -import org.schabi.newpipe.util.image.PicassoHelper; import org.schabi.newpipe.util.StreamTypeUtil; +import org.schabi.newpipe.util.image.CoilHelper; import org.schabi.newpipe.views.AnimatedProgressBar; import java.util.concurrent.TimeUnit; @@ -87,7 +87,7 @@ public void updateFromItem(final InfoItem infoItem, } // Default thumbnail is shown on error, while loading and if the url is empty - PicassoHelper.loadThumbnail(item.getThumbnails()).into(itemThumbnailView); + CoilHelper.INSTANCE.loadThumbnail(itemThumbnailView, item.getThumbnails()); itemView.setOnClickListener(view -> { if (itemBuilder.getOnStreamSelectedListener() != null) { diff --git a/app/src/main/java/org/schabi/newpipe/ktx/Bitmap.kt b/app/src/main/java/org/schabi/newpipe/ktx/Bitmap.kt new file mode 100644 index 00000000000..4a3c3e071d4 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/ktx/Bitmap.kt @@ -0,0 +1,13 @@ +package org.schabi.newpipe.ktx + +import android.graphics.Bitmap +import android.graphics.Rect +import androidx.core.graphics.BitmapCompat + +@Suppress("NOTHING_TO_INLINE") +inline fun Bitmap.scale( + width: Int, + height: Int, + srcRect: Rect? = null, + scaleInLinearSpace: Boolean = true, +) = BitmapCompat.createScaledBitmap(this, width, height, srcRect, scaleInLinearSpace) diff --git a/app/src/main/java/org/schabi/newpipe/ktx/Bundle.kt b/app/src/main/java/org/schabi/newpipe/ktx/Bundle.kt index 61721d5467c..e01cf620e43 100644 --- a/app/src/main/java/org/schabi/newpipe/ktx/Bundle.kt +++ b/app/src/main/java/org/schabi/newpipe/ktx/Bundle.kt @@ -1,9 +1,25 @@ package org.schabi.newpipe.ktx +import android.os.Build import android.os.Bundle import android.os.Parcelable import androidx.core.os.BundleCompat +import java.io.Serializable +import kotlin.reflect.safeCast inline fun Bundle.parcelableArrayList(key: String?): ArrayList? { return BundleCompat.getParcelableArrayList(this, key, T::class.java) } + +inline fun Bundle.serializable(key: String?): T? { + return getSerializable(this, key, T::class.java) +} + +fun getSerializable(bundle: Bundle, key: String?, clazz: Class): T? { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + bundle.getSerializable(key, clazz) + } else { + @Suppress("DEPRECATION") + clazz.kotlin.safeCast(bundle.getSerializable(key)) + } +} diff --git a/app/src/main/java/org/schabi/newpipe/local/bookmark/BookmarkFragment.java b/app/src/main/java/org/schabi/newpipe/local/bookmark/BookmarkFragment.java index a366723e0f8..a8fe19dd4d7 100644 --- a/app/src/main/java/org/schabi/newpipe/local/bookmark/BookmarkFragment.java +++ b/app/src/main/java/org/schabi/newpipe/local/bookmark/BookmarkFragment.java @@ -15,7 +15,6 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.app.AlertDialog; -import androidx.fragment.app.FragmentManager; import androidx.recyclerview.widget.ItemTouchHelper; import androidx.recyclerview.widget.RecyclerView; @@ -36,10 +35,10 @@ 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.util.debounce.DebounceSavable; -import org.schabi.newpipe.util.debounce.DebounceSaver; import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.OnClickGesture; +import org.schabi.newpipe.util.debounce.DebounceSavable; +import org.schabi.newpipe.util.debounce.DebounceSaver; import java.util.ArrayList; import java.util.List; @@ -134,20 +133,14 @@ protected void initListeners() { itemListAdapter.setSelectedListener(new OnClickGesture<>() { @Override public void selected(final LocalItem selectedItem) { - final FragmentManager fragmentManager = getFM(); + final var fragmentManager = getFM(); - if (selectedItem instanceof PlaylistMetadataEntry) { - final PlaylistMetadataEntry entry = ((PlaylistMetadataEntry) selectedItem); + if (selectedItem instanceof PlaylistMetadataEntry entry) { NavigationHelper.openLocalPlaylistFragment(fragmentManager, entry.getUid(), entry.name); - - } else if (selectedItem instanceof PlaylistRemoteEntity) { - final PlaylistRemoteEntity entry = ((PlaylistRemoteEntity) selectedItem); - NavigationHelper.openPlaylistFragment( - fragmentManager, - entry.getServiceId(), - entry.getUrl(), - entry.getName()); + } else if (selectedItem instanceof PlaylistRemoteEntity entry) { + NavigationHelper.openPlaylistFragment(fragmentManager, entry.getServiceId(), + entry.getUrl()); } } diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/FeedFragment.kt b/app/src/main/java/org/schabi/newpipe/local/feed/FeedFragment.kt index e8c5b1e3497..b9929130905 100644 --- a/app/src/main/java/org/schabi/newpipe/local/feed/FeedFragment.kt +++ b/app/src/main/java/org/schabi/newpipe/local/feed/FeedFragment.kt @@ -202,6 +202,7 @@ class FeedFragment : BaseStateFragment() { // Menu // ///////////////////////////////////////////////////////////////////////// + @Deprecated("Deprecated in Java") override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { super.onCreateOptionsMenu(menu, inflater) @@ -212,6 +213,7 @@ class FeedFragment : BaseStateFragment() { inflater.inflate(R.menu.menu_feed_fragment, menu) } + @Deprecated("Deprecated in Java") override fun onOptionsItemSelected(item: MenuItem): Boolean { if (item.itemId == R.id.menu_item_feed_help) { val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(requireContext()) @@ -253,7 +255,7 @@ class FeedFragment : BaseStateFragment() { viewModel.getShowFutureItemsFromPreferences() ) - AlertDialog.Builder(context!!) + AlertDialog.Builder(requireContext()) .setTitle(R.string.feed_hide_streams_title) .setMultiChoiceItems(dialogItems, checkedDialogItems) { _, which, isChecked -> checkedDialogItems[which] = isChecked @@ -267,6 +269,7 @@ class FeedFragment : BaseStateFragment() { .show() } + @Deprecated("Deprecated in Java") override fun onDestroyOptionsMenu() { super.onDestroyOptionsMenu() activity?.supportActionBar?.subtitle = null diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/item/StreamItem.kt b/app/src/main/java/org/schabi/newpipe/local/feed/item/StreamItem.kt index 4a071d6df75..030bb7a7668 100644 --- a/app/src/main/java/org/schabi/newpipe/local/feed/item/StreamItem.kt +++ b/app/src/main/java/org/schabi/newpipe/local/feed/item/StreamItem.kt @@ -19,7 +19,7 @@ import org.schabi.newpipe.extractor.stream.StreamType.POST_LIVE_STREAM import org.schabi.newpipe.extractor.stream.StreamType.VIDEO_STREAM import org.schabi.newpipe.util.Localization import org.schabi.newpipe.util.StreamTypeUtil -import org.schabi.newpipe.util.image.PicassoHelper +import org.schabi.newpipe.util.image.CoilHelper import java.util.concurrent.TimeUnit import java.util.function.Consumer @@ -101,7 +101,7 @@ data class StreamItem( viewBinding.itemProgressView.visibility = View.GONE } - PicassoHelper.loadThumbnail(stream.thumbnailUrl).into(viewBinding.itemThumbnailView) + CoilHelper.loadThumbnail(viewBinding.itemThumbnailView, stream.thumbnailUrl) if (itemVersion != ItemVersion.MINI) { viewBinding.itemAdditionalDetails.text = diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/notifications/NotificationHelper.kt b/app/src/main/java/org/schabi/newpipe/local/feed/notifications/NotificationHelper.kt index 8ea89368d6b..4f70cee50e2 100644 --- a/app/src/main/java/org/schabi/newpipe/local/feed/notifications/NotificationHelper.kt +++ b/app/src/main/java/org/schabi/newpipe/local/feed/notifications/NotificationHelper.kt @@ -6,7 +6,6 @@ import android.app.PendingIntent import android.content.Context import android.content.Intent import android.graphics.Bitmap -import android.graphics.drawable.Drawable import android.net.Uri import android.os.Build import android.provider.Settings @@ -16,20 +15,17 @@ import androidx.core.app.PendingIntentCompat import androidx.core.content.ContextCompat import androidx.core.content.getSystemService import androidx.preference.PreferenceManager -import com.squareup.picasso.Picasso -import com.squareup.picasso.Target import org.schabi.newpipe.R import org.schabi.newpipe.extractor.stream.StreamInfoItem import org.schabi.newpipe.local.feed.service.FeedUpdateInfo import org.schabi.newpipe.util.NavigationHelper -import org.schabi.newpipe.util.image.PicassoHelper +import org.schabi.newpipe.util.image.CoilHelper /** * Helper for everything related to show notifications about new streams to the user. */ class NotificationHelper(val context: Context) { private val manager = NotificationManagerCompat.from(context) - private val iconLoadingTargets = ArrayList() /** * Show notifications for new streams from a single channel. The individual notifications are @@ -68,51 +64,22 @@ class NotificationHelper(val context: Context) { summaryBuilder.setStyle(style) // open the channel page when clicking on the summary notification + val intent = NavigationHelper + .getChannelIntent(context, data.serviceId, data.url) + .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK) summaryBuilder.setContentIntent( - PendingIntentCompat.getActivity( - context, - data.pseudoId, - NavigationHelper - .getChannelIntent(context, data.serviceId, data.url) - .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK), - 0, - false - ) + PendingIntentCompat.getActivity(context, data.pseudoId, intent, 0, false) ) - // a Target is like a listener for image loading events - val target = object : Target { - override fun onBitmapLoaded(bitmap: Bitmap, from: Picasso.LoadedFrom) { - // set channel icon only if there is actually one (for Android versions < 7.0) - summaryBuilder.setLargeIcon(bitmap) - - // Show individual stream notifications, set channel icon only if there is actually - // one - showStreamNotifications(newStreams, data.serviceId, bitmap) - // Show summary notification - manager.notify(data.pseudoId, summaryBuilder.build()) - - iconLoadingTargets.remove(this) // allow it to be garbage-collected - } - - override fun onBitmapFailed(e: Exception, errorDrawable: Drawable) { - // Show individual stream notifications - showStreamNotifications(newStreams, data.serviceId, null) - // Show summary notification - manager.notify(data.pseudoId, summaryBuilder.build()) - iconLoadingTargets.remove(this) // allow it to be garbage-collected - } - - override fun onPrepareLoad(placeHolderDrawable: Drawable) { - // Nothing to do - } - } + val avatarIcon = + CoilHelper.loadBitmapBlocking(context, data.avatarUrl, R.drawable.ic_newpipe_triangle_white) - // add the target to the list to hold a strong reference and prevent it from being garbage - // collected, since Picasso only holds weak references to targets - iconLoadingTargets.add(target) + summaryBuilder.setLargeIcon(avatarIcon) - PicassoHelper.loadNotificationIcon(data.avatarUrl).into(target) + // Show individual stream notifications, set channel icon only if there is actually one + showStreamNotifications(newStreams, data.serviceId, avatarIcon) + // Show summary notification + manager.notify(data.pseudoId, summaryBuilder.build()) } private fun showStreamNotifications( diff --git a/app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistItemHolder.java b/app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistItemHolder.java index 336f5cfe30b..a11438374d1 100644 --- a/app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistItemHolder.java +++ b/app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistItemHolder.java @@ -8,8 +8,8 @@ import org.schabi.newpipe.database.playlist.PlaylistMetadataEntry; import org.schabi.newpipe.local.LocalItemBuilder; import org.schabi.newpipe.local.history.HistoryRecordManager; -import org.schabi.newpipe.util.image.PicassoHelper; import org.schabi.newpipe.util.Localization; +import org.schabi.newpipe.util.image.CoilHelper; import java.time.format.DateTimeFormatter; @@ -30,17 +30,16 @@ public LocalPlaylistItemHolder(final LocalItemBuilder infoItemBuilder, final Vie public void updateFromItem(final LocalItem localItem, final HistoryRecordManager historyRecordManager, final DateTimeFormatter dateTimeFormatter) { - if (!(localItem instanceof PlaylistMetadataEntry)) { + if (!(localItem instanceof PlaylistMetadataEntry item)) { return; } - final PlaylistMetadataEntry item = (PlaylistMetadataEntry) localItem; itemTitleView.setText(item.name); itemStreamCountView.setText(Localization.localizeStreamCountMini( itemStreamCountView.getContext(), item.streamCount)); itemUploaderView.setVisibility(View.INVISIBLE); - PicassoHelper.loadPlaylistThumbnail(item.thumbnailUrl).into(itemThumbnailView); + CoilHelper.INSTANCE.loadPlaylistThumbnail(itemThumbnailView, item.thumbnailUrl); if (item instanceof PlaylistDuplicatesEntry && ((PlaylistDuplicatesEntry) item).timesStreamIsContained > 0) { diff --git a/app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistStreamItemHolder.java b/app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistStreamItemHolder.java index 89a714fd7f6..7dc71bfb4ac 100644 --- a/app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistStreamItemHolder.java +++ b/app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistStreamItemHolder.java @@ -16,8 +16,8 @@ import org.schabi.newpipe.local.history.HistoryRecordManager; import org.schabi.newpipe.util.DependentPreferenceHelper; import org.schabi.newpipe.util.Localization; -import org.schabi.newpipe.util.image.PicassoHelper; import org.schabi.newpipe.util.ServiceHelper; +import org.schabi.newpipe.util.image.CoilHelper; import org.schabi.newpipe.views.AnimatedProgressBar; import java.time.format.DateTimeFormatter; @@ -83,8 +83,8 @@ public void updateFromItem(final LocalItem localItem, } // Default thumbnail is shown on error, while loading and if the url is empty - PicassoHelper.loadThumbnail(item.getStreamEntity().getThumbnailUrl()) - .into(itemThumbnailView); + CoilHelper.INSTANCE.loadThumbnail(itemThumbnailView, + item.getStreamEntity().getThumbnailUrl()); itemView.setOnClickListener(view -> { if (itemBuilder.getOnItemSelectedListener() != null) { diff --git a/app/src/main/java/org/schabi/newpipe/local/holder/LocalStatisticStreamItemHolder.java b/app/src/main/java/org/schabi/newpipe/local/holder/LocalStatisticStreamItemHolder.java index 150a35eb59c..f26a76ad9f7 100644 --- a/app/src/main/java/org/schabi/newpipe/local/holder/LocalStatisticStreamItemHolder.java +++ b/app/src/main/java/org/schabi/newpipe/local/holder/LocalStatisticStreamItemHolder.java @@ -16,8 +16,8 @@ import org.schabi.newpipe.local.history.HistoryRecordManager; import org.schabi.newpipe.util.DependentPreferenceHelper; import org.schabi.newpipe.util.Localization; -import org.schabi.newpipe.util.image.PicassoHelper; import org.schabi.newpipe.util.ServiceHelper; +import org.schabi.newpipe.util.image.CoilHelper; import org.schabi.newpipe.views.AnimatedProgressBar; import java.time.format.DateTimeFormatter; @@ -117,8 +117,8 @@ public void updateFromItem(final LocalItem localItem, } // Default thumbnail is shown on error, while loading and if the url is empty - PicassoHelper.loadThumbnail(item.getStreamEntity().getThumbnailUrl()) - .into(itemThumbnailView); + CoilHelper.INSTANCE.loadThumbnail(itemThumbnailView, + item.getStreamEntity().getThumbnailUrl()); itemView.setOnClickListener(view -> { if (itemBuilder.getOnItemSelectedListener() != null) { diff --git a/app/src/main/java/org/schabi/newpipe/local/holder/RemotePlaylistItemHolder.java b/app/src/main/java/org/schabi/newpipe/local/holder/RemotePlaylistItemHolder.java index 7657320634c..f79f3c78532 100644 --- a/app/src/main/java/org/schabi/newpipe/local/holder/RemotePlaylistItemHolder.java +++ b/app/src/main/java/org/schabi/newpipe/local/holder/RemotePlaylistItemHolder.java @@ -8,8 +8,8 @@ import org.schabi.newpipe.local.LocalItemBuilder; import org.schabi.newpipe.local.history.HistoryRecordManager; import org.schabi.newpipe.util.Localization; -import org.schabi.newpipe.util.image.PicassoHelper; import org.schabi.newpipe.util.ServiceHelper; +import org.schabi.newpipe.util.image.CoilHelper; import java.time.format.DateTimeFormatter; @@ -29,10 +29,9 @@ public RemotePlaylistItemHolder(final LocalItemBuilder infoItemBuilder, public void updateFromItem(final LocalItem localItem, final HistoryRecordManager historyRecordManager, final DateTimeFormatter dateTimeFormatter) { - if (!(localItem instanceof PlaylistRemoteEntity)) { + if (!(localItem instanceof PlaylistRemoteEntity item)) { return; } - final PlaylistRemoteEntity item = (PlaylistRemoteEntity) localItem; itemTitleView.setText(item.getName()); itemStreamCountView.setText(Localization.localizeStreamCountMini( @@ -45,7 +44,7 @@ public void updateFromItem(final LocalItem localItem, itemUploaderView.setText(ServiceHelper.getNameOfServiceById(item.getServiceId())); } - PicassoHelper.loadPlaylistThumbnail(item.getThumbnailUrl()).into(itemThumbnailView); + CoilHelper.INSTANCE.loadPlaylistThumbnail(itemThumbnailView, item.getThumbnailUrl()); super.updateFromItem(localItem, historyRecordManager, dateTimeFormatter); } diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionFragment.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionFragment.kt index fe232105913..59bbaee9d22 100644 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionFragment.kt +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionFragment.kt @@ -129,6 +129,7 @@ class SubscriptionFragment : BaseStateFragment() { // Menu // //////////////////////////////////////////////////////////////////////// + @Deprecated("Deprecated in Java") override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { super.onCreateOptionsMenu(menu, inflater) diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/dialog/FeedGroupDialog.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/dialog/FeedGroupDialog.kt index 41761fb0102..954b872a637 100644 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/dialog/FeedGroupDialog.kt +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/dialog/FeedGroupDialog.kt @@ -94,6 +94,7 @@ class FeedGroupDialog : DialogFragment(), BackPressable { override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { return object : Dialog(requireActivity(), theme) { + @Deprecated("Deprecated in Java") override fun onBackPressed() { if (!this@FeedGroupDialog.onBackPressed()) { super.onBackPressed() diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/item/ChannelItem.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/item/ChannelItem.kt index bc39dafe632..ca626e704f1 100644 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/item/ChannelItem.kt +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/item/ChannelItem.kt @@ -9,7 +9,7 @@ import org.schabi.newpipe.R import org.schabi.newpipe.extractor.channel.ChannelInfoItem import org.schabi.newpipe.util.Localization import org.schabi.newpipe.util.OnClickGesture -import org.schabi.newpipe.util.image.PicassoHelper +import org.schabi.newpipe.util.image.CoilHelper class ChannelItem( private val infoItem: ChannelInfoItem, @@ -39,7 +39,7 @@ class ChannelItem( itemChannelDescriptionView.text = infoItem.description } - PicassoHelper.loadAvatar(infoItem.thumbnails).into(itemThumbnailView) + CoilHelper.loadAvatar(itemThumbnailView, infoItem.thumbnails) gesturesListener?.run { viewHolder.root.setOnClickListener { selected(infoItem) } diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/item/PickerSubscriptionItem.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/item/PickerSubscriptionItem.kt index 3a4c6e41b99..da35447e38d 100644 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/item/PickerSubscriptionItem.kt +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/item/PickerSubscriptionItem.kt @@ -10,7 +10,7 @@ import org.schabi.newpipe.database.subscription.SubscriptionEntity import org.schabi.newpipe.databinding.PickerSubscriptionItemBinding import org.schabi.newpipe.ktx.AnimationType import org.schabi.newpipe.ktx.animate -import org.schabi.newpipe.util.image.PicassoHelper +import org.schabi.newpipe.util.image.CoilHelper data class PickerSubscriptionItem( val subscriptionEntity: SubscriptionEntity, @@ -21,7 +21,7 @@ data class PickerSubscriptionItem( override fun getSpanSize(spanCount: Int, position: Int): Int = 1 override fun bind(viewBinding: PickerSubscriptionItemBinding, position: Int) { - PicassoHelper.loadAvatar(subscriptionEntity.avatarUrl).into(viewBinding.thumbnailView) + CoilHelper.loadAvatar(viewBinding.thumbnailView, subscriptionEntity.avatarUrl) viewBinding.titleView.text = subscriptionEntity.name viewBinding.selectedHighlight.isVisible = isSelected } diff --git a/app/src/main/java/org/schabi/newpipe/paging/CommentsSource.kt b/app/src/main/java/org/schabi/newpipe/paging/CommentsSource.kt new file mode 100644 index 00000000000..aec24a344d5 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/paging/CommentsSource.kt @@ -0,0 +1,45 @@ +package org.schabi.newpipe.paging + +import androidx.paging.PagingSource +import androidx.paging.PagingState +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.schabi.newpipe.extractor.NewPipe +import org.schabi.newpipe.extractor.Page +import org.schabi.newpipe.extractor.comments.CommentsInfo +import org.schabi.newpipe.extractor.comments.CommentsInfoItem +import org.schabi.newpipe.util.NO_SERVICE_ID + +class CommentsSource( + serviceId: Int, + private val url: String?, + private val repliesPage: Page? +) : PagingSource() { + init { + require(serviceId != NO_SERVICE_ID) { "serviceId is NO_SERVICE_ID" } + } + private val service = NewPipe.getService(serviceId) + + override suspend fun load(params: LoadParams): LoadResult { + // repliesPage is non-null only when used to load the comment replies + val nextKey = params.key ?: repliesPage + + return withContext(Dispatchers.IO) { + nextKey?.let { + val info = CommentsInfo.getMoreItems(service, url, it) + LoadResult.Page(info.items, null, info.nextPage) + } ?: run { + val info = CommentsInfo.getInfo(service, url) + if (info.isCommentsDisabled) { + LoadResult.Error(CommentsDisabledException()) + } else { + LoadResult.Page(info.relatedItems, null, info.nextPage) + } + } + } + } + + override fun getRefreshKey(state: PagingState) = null +} + +class CommentsDisabledException : RuntimeException() diff --git a/app/src/main/java/org/schabi/newpipe/paging/PlaylistItemsSource.kt b/app/src/main/java/org/schabi/newpipe/paging/PlaylistItemsSource.kt new file mode 100644 index 00000000000..809ed6f7ce9 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/paging/PlaylistItemsSource.kt @@ -0,0 +1,29 @@ +package org.schabi.newpipe.paging + +import androidx.paging.PagingSource +import androidx.paging.PagingState +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.schabi.newpipe.extractor.NewPipe +import org.schabi.newpipe.extractor.Page +import org.schabi.newpipe.extractor.stream.StreamInfoItem +import org.schabi.newpipe.ui.components.playlist.PlaylistInfo +import org.schabi.newpipe.extractor.playlist.PlaylistInfo as ExtractorPlaylistInfo + +class PlaylistItemsSource( + private val playlistInfo: PlaylistInfo, +) : PagingSource() { + private val service = NewPipe.getService(playlistInfo.serviceId) + + override suspend fun load(params: LoadParams): LoadResult { + return params.key?.let { + withContext(Dispatchers.IO) { + val response = ExtractorPlaylistInfo + .getMoreItems(service, playlistInfo.url, playlistInfo.nextPage) + LoadResult.Page(response.items, null, response.nextPage) + } + } ?: LoadResult.Page(playlistInfo.relatedItems, null, playlistInfo.nextPage) + } + + override fun getRefreshKey(state: PagingState) = null +} diff --git a/app/src/main/java/org/schabi/newpipe/player/Player.java b/app/src/main/java/org/schabi/newpipe/player/Player.java index 49e72328e40..74d35cf31e6 100644 --- a/app/src/main/java/org/schabi/newpipe/player/Player.java +++ b/app/src/main/java/org/schabi/newpipe/player/Player.java @@ -60,6 +60,7 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.core.graphics.drawable.DrawableKt; import androidx.core.math.MathUtils; import androidx.preference.PreferenceManager; @@ -77,8 +78,6 @@ import com.google.android.exoplayer2.trackselection.MappingTrackSelector; import com.google.android.exoplayer2.upstream.DefaultBandwidthMeter; import com.google.android.exoplayer2.video.VideoSize; -import com.squareup.picasso.Picasso; -import com.squareup.picasso.Target; import org.schabi.newpipe.MainActivity; import org.schabi.newpipe.R; @@ -86,8 +85,8 @@ import org.schabi.newpipe.error.ErrorInfo; import org.schabi.newpipe.error.ErrorUtil; import org.schabi.newpipe.error.UserAction; -import org.schabi.newpipe.extractor.stream.AudioStream; import org.schabi.newpipe.extractor.Image; +import org.schabi.newpipe.extractor.stream.AudioStream; import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.extractor.stream.StreamType; import org.schabi.newpipe.extractor.stream.VideoStream; @@ -118,14 +117,15 @@ import org.schabi.newpipe.util.DependentPreferenceHelper; import org.schabi.newpipe.util.ListHelper; import org.schabi.newpipe.util.NavigationHelper; -import org.schabi.newpipe.util.image.PicassoHelper; import org.schabi.newpipe.util.SerializedCache; import org.schabi.newpipe.util.StreamTypeUtil; +import org.schabi.newpipe.util.image.CoilHelper; import java.util.List; import java.util.Optional; import java.util.stream.IntStream; +import coil.target.Target; import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; import io.reactivex.rxjava3.core.Observable; import io.reactivex.rxjava3.disposables.CompositeDisposable; @@ -174,7 +174,6 @@ public final class Player implements PlaybackListener, Listener { //////////////////////////////////////////////////////////////////////////*/ public static final int RENDERER_UNAVAILABLE = -1; - private static final String PICASSO_PLAYER_THUMBNAIL_TAG = "PICASSO_PLAYER_THUMBNAIL_TAG"; /*////////////////////////////////////////////////////////////////////////// // Playback @@ -193,6 +192,8 @@ public final class Player implements PlaybackListener, Listener { private MediaItemTag currentMetadata; @Nullable private Bitmap currentThumbnail; + @Nullable + private coil.request.Disposable thumbnailDisposable; /*////////////////////////////////////////////////////////////////////////// // Player @@ -246,12 +247,6 @@ public final class Player implements PlaybackListener, Listener { @NonNull private final CompositeDisposable databaseUpdateDisposable = new CompositeDisposable(); - // This is the only listener we need for thumbnail loading, since there is always at most only - // one thumbnail being loaded at a time. This field is also here to maintain a strong reference, - // which would otherwise be garbage collected since Picasso holds weak references to targets. - @NonNull - private final Target currentThumbnailTarget; - /*////////////////////////////////////////////////////////////////////////// // Utils //////////////////////////////////////////////////////////////////////////*/ @@ -295,8 +290,6 @@ public Player(@NonNull final PlayerService service) { videoResolver = new VideoPlaybackResolver(context, dataSource, getQualityResolver()); audioResolver = new AudioPlaybackResolver(context, dataSource); - currentThumbnailTarget = getCurrentThumbnailTarget(); - // The UIs added here should always be present. They will be initialized when the player // reaches the initialization step. Make sure the media session ui is before the // notification ui in the UIs list, since the notification depends on the media session in @@ -602,7 +595,6 @@ public void destroy() { databaseUpdateDisposable.clear(); progressUpdateDisposable.set(null); - cancelLoadingCurrentThumbnail(); UIs.destroyAll(Object.class); // destroy every UI: obviously every UI extends Object } @@ -776,67 +768,58 @@ private void unregisterBroadcastReceiver() { //////////////////////////////////////////////////////////////////////////*/ //region Thumbnail loading - private Target getCurrentThumbnailTarget() { - // a Picasso target is just a listener for thumbnail loading events - return new Target() { - @Override - public void onBitmapLoaded(final Bitmap bitmap, final Picasso.LoadedFrom from) { - if (DEBUG) { - Log.d(TAG, "Thumbnail - onBitmapLoaded() called with: bitmap = [" + bitmap - + " -> " + bitmap.getWidth() + "x" + bitmap.getHeight() + "], from = [" - + from + "]"); - } - // there is a new thumbnail, so e.g. the end screen thumbnail needs to change, too. - onThumbnailLoaded(bitmap); - } - - @Override - public void onBitmapFailed(final Exception e, final Drawable errorDrawable) { - Log.e(TAG, "Thumbnail - onBitmapFailed() called", e); - // there is a new thumbnail, so e.g. the end screen thumbnail needs to change, too. - onThumbnailLoaded(null); - } - - @Override - public void onPrepareLoad(final Drawable placeHolderDrawable) { - if (DEBUG) { - Log.d(TAG, "Thumbnail - onPrepareLoad() called"); - } - } - }; - } - private void loadCurrentThumbnail(final List thumbnails) { if (DEBUG) { Log.d(TAG, "Thumbnail - loadCurrentThumbnail() called with thumbnails = [" + thumbnails.size() + "]"); } - // first cancel any previous loading - cancelLoadingCurrentThumbnail(); + // Cancel any ongoing image loading + if (thumbnailDisposable != null) { + thumbnailDisposable.dispose(); + } // Unset currentThumbnail, since it is now outdated. This ensures it is not used in media - // session metadata while the new thumbnail is being loaded by Picasso. + // session metadata while the new thumbnail is being loaded by Coil. onThumbnailLoaded(null); if (thumbnails.isEmpty()) { return; } // scale down the notification thumbnail for performance - PicassoHelper.loadScaledDownThumbnail(context, thumbnails) - .tag(PICASSO_PLAYER_THUMBNAIL_TAG) - .into(currentThumbnailTarget); - } + final var thumbnailTarget = new Target() { + @Override + public void onError(@Nullable final Drawable error) { + Log.e(TAG, "Thumbnail - onError() called"); + // there is a new thumbnail, so e.g. the end screen thumbnail needs to change, too. + onThumbnailLoaded(null); + } + + @Override + public void onStart(@Nullable final Drawable placeholder) { + if (DEBUG) { + Log.d(TAG, "Thumbnail - onStart() called"); + } + } - private void cancelLoadingCurrentThumbnail() { - // cancel the Picasso job associated with the player thumbnail, if any - PicassoHelper.cancelTag(PICASSO_PLAYER_THUMBNAIL_TAG); + @Override + public void onSuccess(@NonNull final Drawable result) { + if (DEBUG) { + Log.d(TAG, "Thumbnail - onSuccess() called with: drawable = [" + result + "]"); + } + // there is a new thumbnail, so e.g. the end screen thumbnail needs to change, too. + onThumbnailLoaded(DrawableKt.toBitmapOrNull(result, result.getIntrinsicWidth(), + result.getIntrinsicHeight(), null)); + } + }; + thumbnailDisposable = CoilHelper.INSTANCE + .loadScaledDownThumbnail(context, thumbnails, thumbnailTarget); } private void onThumbnailLoaded(@Nullable final Bitmap bitmap) { // Avoid useless thumbnail updates, if the thumbnail has not actually changed. Based on the // thumbnail loading code, this if would be skipped only when both bitmaps are `null`, since - // onThumbnailLoaded won't be called twice with the same nonnull bitmap by Picasso's target. + // onThumbnailLoaded won't be called twice with the same nonnull bitmap by Coil's target. if (currentThumbnail != bitmap) { currentThumbnail = bitmap; UIs.call(playerUi -> playerUi.onThumbnailLoaded(bitmap)); diff --git a/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueItemBuilder.java b/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueItemBuilder.java index 066f92c2607..8994aef79df 100644 --- a/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueItemBuilder.java +++ b/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueItemBuilder.java @@ -6,8 +6,8 @@ import android.view.View; import org.schabi.newpipe.util.Localization; -import org.schabi.newpipe.util.image.PicassoHelper; import org.schabi.newpipe.util.ServiceHelper; +import org.schabi.newpipe.util.image.CoilHelper; public class PlayQueueItemBuilder { private static final String TAG = PlayQueueItemBuilder.class.toString(); @@ -33,7 +33,7 @@ public void buildStreamInfoItem(final PlayQueueItemHolder holder, final PlayQueu holder.itemDurationView.setVisibility(View.GONE); } - PicassoHelper.loadThumbnail(item.getThumbnails()).into(holder.itemThumbnailView); + CoilHelper.INSTANCE.loadThumbnail(holder.itemThumbnailView, item.getThumbnails()); holder.itemRoot.setOnClickListener(view -> { if (onItemClickListener != null) { diff --git a/app/src/main/java/org/schabi/newpipe/player/seekbarpreview/SeekbarPreviewThumbnailHolder.java b/app/src/main/java/org/schabi/newpipe/player/seekbarpreview/SeekbarPreviewThumbnailHolder.java index 26065de1572..d09664aeb40 100644 --- a/app/src/main/java/org/schabi/newpipe/player/seekbarpreview/SeekbarPreviewThumbnailHolder.java +++ b/app/src/main/java/org/schabi/newpipe/player/seekbarpreview/SeekbarPreviewThumbnailHolder.java @@ -13,8 +13,9 @@ import com.google.common.base.Stopwatch; +import org.schabi.newpipe.App; import org.schabi.newpipe.extractor.stream.Frameset; -import org.schabi.newpipe.util.image.PicassoHelper; +import org.schabi.newpipe.util.image.CoilHelper; import java.util.Comparator; import java.util.List; @@ -177,8 +178,8 @@ private Bitmap getBitMapFrom(final String url) { Log.d(TAG, "Downloading bitmap for seekbarPreview from '" + url + "'"); // Gets the bitmap within the timeout of 15 seconds imposed by default by OkHttpClient - // Ensure that your are not running on the main-Thread this will otherwise hang - final Bitmap bitmap = PicassoHelper.loadSeekbarThumbnailPreview(url).get(); + // Ensure that you are not running on the main thread, otherwise this will hang + final var bitmap = CoilHelper.INSTANCE.loadBitmapBlocking(App.getApp(), url); if (sw != null) { Log.d(TAG, "Download of bitmap for seekbarPreview from '" + url + "' took " diff --git a/app/src/main/java/org/schabi/newpipe/settings/ContentSettingsFragment.java b/app/src/main/java/org/schabi/newpipe/settings/ContentSettingsFragment.java index ec2bed67a44..c47abb93065 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/ContentSettingsFragment.java +++ b/app/src/main/java/org/schabi/newpipe/settings/ContentSettingsFragment.java @@ -13,10 +13,9 @@ import org.schabi.newpipe.extractor.localization.ContentCountry; import org.schabi.newpipe.extractor.localization.Localization; import org.schabi.newpipe.util.image.ImageStrategy; -import org.schabi.newpipe.util.image.PicassoHelper; import org.schabi.newpipe.util.image.PreferredImageQuality; -import java.io.IOException; +import coil.Coil; public class ContentSettingsFragment extends BasePreferenceFragment { private String youtubeRestrictedModeEnabledKey; @@ -42,14 +41,13 @@ public void onCreatePreferences(final Bundle savedInstanceState, final String ro (preference, newValue) -> { ImageStrategy.setPreferredImageQuality(PreferredImageQuality .fromPreferenceKey(requireContext(), (String) newValue)); - try { - PicassoHelper.clearCache(preference.getContext()); - Toast.makeText(preference.getContext(), - R.string.thumbnail_cache_wipe_complete_notice, Toast.LENGTH_SHORT) - .show(); - } catch (final IOException e) { - Log.e(TAG, "Unable to clear Picasso cache", e); - } + final var loader = Coil.imageLoader(preference.getContext()); + loader.getMemoryCache().clear(); + loader.getDiskCache().clear(); + Toast.makeText(preference.getContext(), + R.string.thumbnail_cache_wipe_complete_notice, Toast.LENGTH_SHORT) + .show(); + return true; }); } diff --git a/app/src/main/java/org/schabi/newpipe/settings/DebugSettingsFragment.java b/app/src/main/java/org/schabi/newpipe/settings/DebugSettingsFragment.java index d78ade49df6..c6abb5405aa 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/DebugSettingsFragment.java +++ b/app/src/main/java/org/schabi/newpipe/settings/DebugSettingsFragment.java @@ -10,7 +10,6 @@ import org.schabi.newpipe.error.ErrorUtil; import org.schabi.newpipe.error.UserAction; import org.schabi.newpipe.local.feed.notifications.NotificationWorker; -import org.schabi.newpipe.util.image.PicassoHelper; import java.util.Optional; @@ -25,8 +24,6 @@ public void onCreatePreferences(final Bundle savedInstanceState, final String ro findPreference(getString(R.string.allow_heap_dumping_key)); final Preference showMemoryLeaksPreference = findPreference(getString(R.string.show_memory_leaks_key)); - final Preference showImageIndicatorsPreference = - findPreference(getString(R.string.show_image_indicators_key)); final Preference checkNewStreamsPreference = findPreference(getString(R.string.check_new_streams_key)); final Preference crashTheAppPreference = @@ -38,7 +35,6 @@ public void onCreatePreferences(final Bundle savedInstanceState, final String ro assert allowHeapDumpingPreference != null; assert showMemoryLeaksPreference != null; - assert showImageIndicatorsPreference != null; assert checkNewStreamsPreference != null; assert crashTheAppPreference != null; assert showErrorSnackbarPreference != null; @@ -61,11 +57,6 @@ public void onCreatePreferences(final Bundle savedInstanceState, final String ro showMemoryLeaksPreference.setSummary(R.string.leak_canary_not_available); } - showImageIndicatorsPreference.setOnPreferenceChangeListener((preference, newValue) -> { - PicassoHelper.setIndicatorsEnabled((Boolean) newValue); - return true; - }); - checkNewStreamsPreference.setOnPreferenceClickListener(preference -> { NotificationWorker.runNow(preference.getContext()); return true; diff --git a/app/src/main/java/org/schabi/newpipe/settings/DownloadSettingsFragment.java b/app/src/main/java/org/schabi/newpipe/settings/DownloadSettingsFragment.java index 472db6afe6f..ff7811af3e6 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/DownloadSettingsFragment.java +++ b/app/src/main/java/org/schabi/newpipe/settings/DownloadSettingsFragment.java @@ -1,6 +1,5 @@ package org.schabi.newpipe.settings; -import static org.schabi.newpipe.extractor.utils.Utils.decodeUrlUtf8; import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage; import android.app.Activity; @@ -30,8 +29,6 @@ import java.io.File; import java.io.IOException; -import java.io.UnsupportedEncodingException; -import java.net.URI; public class DownloadSettingsFragment extends BasePreferenceFragment { public static final boolean IGNORE_RELEASE_ON_OLD_PATH = true; @@ -108,28 +105,15 @@ private void updatePreferencesSummary() { private void showPathInSummary(final String prefKey, @StringRes final int defaultString, final Preference target) { - String rawUri = defaultPreferences.getString(prefKey, null); - if (rawUri == null || rawUri.isEmpty()) { + final Uri uri = Uri.parse(defaultPreferences.getString(prefKey, "")); + if (uri.equals(Uri.EMPTY)) { target.setSummary(getString(defaultString)); return; } - if (rawUri.charAt(0) == File.separatorChar) { - target.setSummary(rawUri); - return; - } - if (rawUri.startsWith(ContentResolver.SCHEME_FILE)) { - target.setSummary(new File(URI.create(rawUri)).getPath()); - return; - } - - try { - rawUri = decodeUrlUtf8(rawUri); - } catch (final UnsupportedEncodingException e) { - // nothing to do - } - - target.setSummary(rawUri); + final String summary = ContentResolver.SCHEME_FILE.equals(uri.getScheme()) + ? uri.getPath() : uri.toString(); + target.setSummary(summary); } private boolean isFileUri(final String path) { diff --git a/app/src/main/java/org/schabi/newpipe/settings/SelectChannelFragment.java b/app/src/main/java/org/schabi/newpipe/settings/SelectChannelFragment.java index 37335421d16..c566313e37a 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/SelectChannelFragment.java +++ b/app/src/main/java/org/schabi/newpipe/settings/SelectChannelFragment.java @@ -19,8 +19,8 @@ import org.schabi.newpipe.database.subscription.SubscriptionEntity; import org.schabi.newpipe.error.ErrorUtil; import org.schabi.newpipe.local.subscription.SubscriptionManager; -import org.schabi.newpipe.util.image.PicassoHelper; import org.schabi.newpipe.util.ThemeHelper; +import org.schabi.newpipe.util.image.CoilHelper; import java.util.List; import java.util.Vector; @@ -190,7 +190,7 @@ public void onBindViewHolder(final SelectChannelItemHolder holder, final int pos final SubscriptionEntity entry = subscriptions.get(position); holder.titleView.setText(entry.getName()); holder.view.setOnClickListener(view -> clickedItem(position)); - PicassoHelper.loadAvatar(entry.getAvatarUrl()).into(holder.thumbnailView); + CoilHelper.INSTANCE.loadAvatar(holder.thumbnailView, entry.getAvatarUrl()); } @Override diff --git a/app/src/main/java/org/schabi/newpipe/settings/SelectPlaylistFragment.java b/app/src/main/java/org/schabi/newpipe/settings/SelectPlaylistFragment.java index 36abef9e5ca..c340dca2231 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/SelectPlaylistFragment.java +++ b/app/src/main/java/org/schabi/newpipe/settings/SelectPlaylistFragment.java @@ -27,7 +27,7 @@ import org.schabi.newpipe.error.UserAction; import org.schabi.newpipe.local.playlist.LocalPlaylistManager; import org.schabi.newpipe.local.playlist.RemotePlaylistManager; -import org.schabi.newpipe.util.image.PicassoHelper; +import org.schabi.newpipe.util.image.CoilHelper; import java.util.List; import java.util.Vector; @@ -154,20 +154,15 @@ public void onBindViewHolder(@NonNull final SelectPlaylistItemHolder holder, final int position) { final PlaylistLocalItem selectedItem = playlists.get(position); - if (selectedItem instanceof PlaylistMetadataEntry) { - final PlaylistMetadataEntry entry = ((PlaylistMetadataEntry) selectedItem); - + if (selectedItem instanceof PlaylistMetadataEntry entry) { holder.titleView.setText(entry.name); holder.view.setOnClickListener(view -> clickedItem(position)); - PicassoHelper.loadPlaylistThumbnail(entry.thumbnailUrl).into(holder.thumbnailView); - - } else if (selectedItem instanceof PlaylistRemoteEntity) { - final PlaylistRemoteEntity entry = ((PlaylistRemoteEntity) selectedItem); - + CoilHelper.INSTANCE.loadPlaylistThumbnail(holder.thumbnailView, entry.thumbnailUrl); + } else if (selectedItem instanceof PlaylistRemoteEntity entry) { holder.titleView.setText(entry.getName()); holder.view.setOnClickListener(view -> clickedItem(position)); - PicassoHelper.loadPlaylistThumbnail(entry.getThumbnailUrl()) - .into(holder.thumbnailView); + CoilHelper.INSTANCE.loadPlaylistThumbnail(holder.thumbnailView, + entry.getThumbnailUrl()); } } diff --git a/app/src/main/java/org/schabi/newpipe/settings/notifications/NotificationModeConfigFragment.kt b/app/src/main/java/org/schabi/newpipe/settings/notifications/NotificationModeConfigFragment.kt index 581768c304d..2df3e33b669 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/notifications/NotificationModeConfigFragment.kt +++ b/app/src/main/java/org/schabi/newpipe/settings/notifications/NotificationModeConfigFragment.kt @@ -77,11 +77,13 @@ class NotificationModeConfigFragment : Fragment() { super.onDestroy() } + @Deprecated("Deprecated in Java") override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { super.onCreateOptionsMenu(menu, inflater) inflater.inflate(R.menu.menu_notifications_channels, menu) } + @Deprecated("Deprecated in Java") override fun onOptionsItemSelected(item: MenuItem): Boolean { return when (item.itemId) { R.id.action_toggle_all -> { diff --git a/app/src/main/java/org/schabi/newpipe/settings/tabs/Tab.java b/app/src/main/java/org/schabi/newpipe/settings/tabs/Tab.java index 7e3f5d0c825..06cbb8b6c45 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/tabs/Tab.java +++ b/app/src/main/java/org/schabi/newpipe/settings/tabs/Tab.java @@ -577,9 +577,8 @@ public int getTabIconRes(final Context context) { public Fragment getFragment(final Context context) { if (playlistType == LocalItemType.PLAYLIST_LOCAL_ITEM) { return LocalPlaylistFragment.getInstance(playlistId, playlistName); - } else { // playlistType == LocalItemType.PLAYLIST_REMOTE_ITEM - return PlaylistFragment.getInstance(playlistServiceId, playlistUrl, playlistName); + return PlaylistFragment.getInstance(playlistServiceId, playlistUrl); } } @@ -606,12 +605,10 @@ protected void readDataFromJson(final JsonObject jsonObject) { @Override public boolean equals(final Object obj) { - if (!(obj instanceof PlaylistTab)) { + if (!(obj instanceof PlaylistTab other)) { return false; } - final PlaylistTab other = (PlaylistTab) obj; - return super.equals(obj) && playlistServiceId == other.playlistServiceId // Remote && playlistId == other.playlistId // Local diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/comment/Comment.kt b/app/src/main/java/org/schabi/newpipe/ui/components/comment/Comment.kt new file mode 100644 index 00000000000..8ecc87fdde8 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/ui/components/comment/Comment.kt @@ -0,0 +1,211 @@ +package org.schabi.newpipe.ui.components.comment + +import android.content.res.Configuration +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +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.foundation.shape.CircleShape +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.compose.ui.unit.dp +import androidx.fragment.app.FragmentActivity +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.cachedIn +import coil.compose.AsyncImage +import org.schabi.newpipe.R +import org.schabi.newpipe.extractor.Page +import org.schabi.newpipe.extractor.comments.CommentsInfoItem +import org.schabi.newpipe.extractor.stream.Description +import org.schabi.newpipe.paging.CommentsSource +import org.schabi.newpipe.ui.components.common.DescriptionText +import org.schabi.newpipe.ui.theme.AppTheme +import org.schabi.newpipe.util.Localization +import org.schabi.newpipe.util.NavigationHelper +import org.schabi.newpipe.util.image.ImageStrategy + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun Comment(comment: CommentsInfoItem) { + val context = LocalContext.current + var isExpanded by rememberSaveable { mutableStateOf(false) } + var showReplies by rememberSaveable { mutableStateOf(false) } + + Row( + modifier = Modifier + .animateContentSize() + .clickable { isExpanded = !isExpanded } + .padding(8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + if (ImageStrategy.shouldLoadImages()) { + AsyncImage( + model = ImageStrategy.choosePreferredImage(comment.uploaderAvatars), + contentDescription = null, + placeholder = painterResource(R.drawable.placeholder_person), + error = painterResource(R.drawable.placeholder_person), + modifier = Modifier + .size(42.dp) + .clip(CircleShape) + .clickable { + NavigationHelper.openCommentAuthorIfPresent( + context as FragmentActivity, comment + ) + } + ) + } + + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + if (comment.isPinned) { + Image( + painter = painterResource(R.drawable.ic_pin), + contentDescription = stringResource(R.string.detail_pinned_comment_view_description) + ) + } + + val nameAndDate = remember(comment) { + val date = Localization.relativeTimeOrTextual( + context, comment.uploadDate, comment.textualUploadDate + ) + Localization.concatenateStrings(comment.uploaderName, date) + } + Text(text = nameAndDate, color = MaterialTheme.colorScheme.secondary) + } + + DescriptionText( + description = comment.commentText, + // If the comment is expanded, we display all its content + // otherwise we only display the first two lines + maxLines = if (isExpanded) Int.MAX_VALUE else 2, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodyMedium, + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Image( + painter = painterResource(R.drawable.ic_thumb_up), + contentDescription = stringResource(R.string.detail_likes_img_view_description) + ) + Text(text = Localization.likeCount(context, comment.likeCount)) + + if (comment.isHeartedByUploader) { + Image( + painter = painterResource(R.drawable.ic_heart), + contentDescription = stringResource(R.string.detail_heart_img_view_description) + ) + } + } + + if (comment.replies != null) { + TextButton(onClick = { showReplies = true }) { + val text = pluralStringResource( + R.plurals.replies, comment.replyCount, comment.replyCount.toString() + ) + Text(text = text) + } + } + } + } + } + + if (showReplies) { + ModalBottomSheet(onDismissRequest = { showReplies = false }) { + val coroutineScope = rememberCoroutineScope() + val flow = remember { + Pager(PagingConfig(pageSize = 20, enablePlaceholders = false)) { + CommentsSource(comment.serviceId, comment.url, comment.replies) + }.flow + .cachedIn(coroutineScope) + } + + CommentSection(parentComment = comment, commentsFlow = flow) + } + } +} + +fun CommentsInfoItem( + serviceId: Int = 1, + url: String = "", + name: String = "", + commentText: Description, + uploaderName: String, + textualUploadDate: String = "5 months ago", + likeCount: Int = 0, + isHeartedByUploader: Boolean = false, + isPinned: Boolean = false, + replies: Page? = null, + replyCount: Int = 0, +) = CommentsInfoItem(serviceId, url, name).apply { + this.commentText = commentText + this.uploaderName = uploaderName + this.textualUploadDate = textualUploadDate + this.likeCount = likeCount + this.isHeartedByUploader = isHeartedByUploader + this.isPinned = isPinned + this.replies = replies + this.replyCount = replyCount +} + +private class DescriptionPreviewProvider : PreviewParameterProvider { + override val values = sequenceOf( + Description("Hello world!

This line should be hidden by default.", Description.HTML), + Description("Hello world!\n\nThis line should be hidden by default.", Description.PLAIN_TEXT), + ) +} + +@Preview(name = "Light mode", uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(name = "Dark mode", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun CommentPreview( + @PreviewParameter(DescriptionPreviewProvider::class) description: Description +) { + val comment = CommentsInfoItem( + commentText = description, + uploaderName = "Test", + likeCount = 100, + isPinned = true, + isHeartedByUploader = true, + replies = Page(""), + replyCount = 10 + ) + + AppTheme { + Surface(color = MaterialTheme.colorScheme.background) { + Comment(comment) + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/comment/CommentRepliesHeader.kt b/app/src/main/java/org/schabi/newpipe/ui/components/comment/CommentRepliesHeader.kt new file mode 100644 index 00000000000..a8e33a49d00 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/ui/components/comment/CommentRepliesHeader.kt @@ -0,0 +1,129 @@ +package org.schabi.newpipe.ui.components.comment + +import android.content.res.Configuration +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +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.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.fragment.app.FragmentActivity +import coil.compose.AsyncImage +import org.schabi.newpipe.R +import org.schabi.newpipe.extractor.comments.CommentsInfoItem +import org.schabi.newpipe.extractor.stream.Description +import org.schabi.newpipe.ui.components.common.DescriptionText +import org.schabi.newpipe.ui.theme.AppTheme +import org.schabi.newpipe.util.Localization +import org.schabi.newpipe.util.NavigationHelper +import org.schabi.newpipe.util.image.ImageStrategy + +@Composable +fun CommentRepliesHeader(comment: CommentsInfoItem) { + val context = LocalContext.current + + Column(modifier = Modifier.padding(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + modifier = Modifier.clickable { + val activity = context as FragmentActivity + NavigationHelper.openCommentAuthorIfPresent(activity, comment) + }, + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + if (ImageStrategy.shouldLoadImages()) { + AsyncImage( + model = ImageStrategy.choosePreferredImage(comment.uploaderAvatars), + contentDescription = null, + placeholder = painterResource(R.drawable.placeholder_person), + error = painterResource(R.drawable.placeholder_person), + modifier = Modifier + .size(42.dp) + .clip(CircleShape) + ) + } + + Column { + Text(text = comment.uploaderName) + + Text( + color = MaterialTheme.colorScheme.secondary, + style = MaterialTheme.typography.bodySmall, + text = Localization.relativeTimeOrTextual( + context, comment.uploadDate, comment.textualUploadDate + ) + ) + } + } + + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Image( + painter = painterResource(R.drawable.ic_thumb_up), + contentDescription = stringResource(R.string.detail_likes_img_view_description) + ) + Text(text = Localization.likeCount(context, comment.likeCount)) + + if (comment.isHeartedByUploader) { + Image( + painter = painterResource(R.drawable.ic_heart), + contentDescription = stringResource(R.string.detail_heart_img_view_description) + ) + } + + if (comment.isPinned) { + Image( + painter = painterResource(R.drawable.ic_pin), + contentDescription = stringResource(R.string.detail_pinned_comment_view_description) + ) + } + } + } + + DescriptionText( + description = comment.commentText, + style = MaterialTheme.typography.bodyMedium + ) + } +} + +@Preview(name = "Light mode", uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(name = "Dark mode", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +fun CommentRepliesHeaderPreview() { + val comment = CommentsInfoItem( + commentText = Description("Hello world!", Description.PLAIN_TEXT), + uploaderName = "Test", + likeCount = 1000, + isPinned = true, + isHeartedByUploader = true + ) + + AppTheme { + Surface(color = MaterialTheme.colorScheme.background) { + CommentRepliesHeader(comment) + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/comment/CommentSection.kt b/app/src/main/java/org/schabi/newpipe/ui/components/comment/CommentSection.kt new file mode 100644 index 00000000000..3f2a5a1acb1 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/ui/components/comment/CommentSection.kt @@ -0,0 +1,156 @@ +package org.schabi.newpipe.ui.components.comment + +import android.content.res.Configuration +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.paging.LoadState +import androidx.paging.LoadStates +import androidx.paging.PagingData +import androidx.paging.compose.collectAsLazyPagingItems +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import my.nanihadesuka.compose.LazyColumnScrollbar +import org.schabi.newpipe.R +import org.schabi.newpipe.extractor.comments.CommentsInfoItem +import org.schabi.newpipe.extractor.stream.Description +import org.schabi.newpipe.paging.CommentsDisabledException +import org.schabi.newpipe.ui.components.common.LoadingIndicator +import org.schabi.newpipe.ui.theme.AppTheme + +@Composable +fun CommentSection( + parentComment: CommentsInfoItem? = null, + commentsFlow: Flow> +) { + Surface(color = MaterialTheme.colorScheme.background) { + val comments = commentsFlow.collectAsLazyPagingItems() + val itemCount by remember { derivedStateOf { comments.itemCount } } + val listState = rememberLazyListState() + + LazyColumnScrollbar(state = listState) { + LazyColumn(state = listState) { + if (parentComment != null) { + item { + CommentRepliesHeader(comment = parentComment) + HorizontalDivider(thickness = 1.dp) + } + } + + if (itemCount == 0) { + item { + val refresh = comments.loadState.refresh + if (refresh is LoadState.Loading) { + LoadingIndicator(modifier = Modifier.padding(top = 8.dp)) + } else { + NoCommentsMessage((refresh as? LoadState.Error)?.error) + } + } + } else { + items(itemCount) { + Comment(comment = comments[it]!!) + } + } + } + } + } +} + +@Composable +private fun NoCommentsMessage(error: Throwable?) { + val message = if (error is CommentsDisabledException) { + R.string.comments_are_disabled + } else { + R.string.no_comments + } + + Column( + modifier = Modifier + .fillMaxWidth() + .wrapContentSize(Alignment.Center), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text(text = "(╯°-°)╯", fontSize = 35.sp) + Text(text = stringResource(id = message), fontSize = 24.sp) + } +} + +private class CommentDataProvider : PreviewParameterProvider> { + private val notLoading = LoadState.NotLoading(true) + + override val values = sequenceOf( + // Normal view + PagingData.from( + (1..100).map { + CommentsInfoItem( + commentText = Description("Comment $it", Description.PLAIN_TEXT), + uploaderName = "Test" + ) + } + ), + // Comments disabled + PagingData.from( + listOf(), + LoadStates(LoadState.Error(CommentsDisabledException()), notLoading, notLoading) + ), + // No comments + PagingData.from( + listOf(), + LoadStates(notLoading, notLoading, notLoading) + ) + ) +} + +@Preview(name = "Light mode", uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(name = "Dark mode", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun CommentSectionPreview( + @PreviewParameter(CommentDataProvider::class) pagingData: PagingData +) { + AppTheme { + CommentSection(commentsFlow = flowOf(pagingData)) + } +} + +@Preview(name = "Light mode", uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(name = "Dark mode", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun CommentRepliesPreview() { + val comment = CommentsInfoItem( + commentText = Description("Hello world!", Description.PLAIN_TEXT), + uploaderName = "Test", + likeCount = 100, + isPinned = true, + isHeartedByUploader = true + ) + val replies = (1..100).map { + CommentsInfoItem( + commentText = Description("Reply $it", Description.PLAIN_TEXT), + uploaderName = "Test" + ) + } + val flow = flowOf(PagingData.from(replies)) + + AppTheme { + CommentSection(parentComment = comment, commentsFlow = flow) + } +} diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/common/DescriptionText.kt b/app/src/main/java/org/schabi/newpipe/ui/components/common/DescriptionText.kt new file mode 100644 index 00000000000..3127794f590 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/ui/components/common/DescriptionText.kt @@ -0,0 +1,46 @@ +package org.schabi.newpipe.ui.components.common + +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.ParagraphStyle +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.text.TextLinkStyles +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.fromHtml +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.style.TextOverflow +import org.schabi.newpipe.extractor.stream.Description + +@Composable +fun DescriptionText( + description: Description, + modifier: Modifier = Modifier, + overflow: TextOverflow = TextOverflow.Clip, + maxLines: Int = Int.MAX_VALUE, + onTextLayout: (TextLayoutResult) -> Unit = {}, + style: TextStyle = LocalTextStyle.current +) { + // TODO: Handle links and hashtags, Markdown. + val parsedDescription = remember(description) { + if (description.type == Description.HTML) { + val styles = TextLinkStyles(SpanStyle(textDecoration = TextDecoration.Underline)) + AnnotatedString.fromHtml(description.content, styles) + } else { + AnnotatedString(description.content, ParagraphStyle()) + } + } + + Text( + modifier = modifier, + text = parsedDescription, + maxLines = maxLines, + style = style, + overflow = overflow, + onTextLayout = onTextLayout + ) +} diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/common/LoadingIndicator.kt b/app/src/main/java/org/schabi/newpipe/ui/components/common/LoadingIndicator.kt new file mode 100644 index 00000000000..4a6a88190ca --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/ui/components/common/LoadingIndicator.kt @@ -0,0 +1,20 @@ +package org.schabi.newpipe.ui.components.common + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier + +@Composable +fun LoadingIndicator(modifier: Modifier = Modifier) { + CircularProgressIndicator( + modifier = modifier + .fillMaxSize() + .wrapContentSize(Alignment.Center), + color = MaterialTheme.colorScheme.primary, + trackColor = MaterialTheme.colorScheme.surfaceVariant, + ) +} diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/playlist/PlaylistHeader.kt b/app/src/main/java/org/schabi/newpipe/ui/components/playlist/PlaylistHeader.kt new file mode 100644 index 00000000000..b46abc606fe --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/ui/components/playlist/PlaylistHeader.kt @@ -0,0 +1,157 @@ +package org.schabi.newpipe.ui.components.playlist + +import android.content.res.Configuration +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Image +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +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.foundation.shape.CircleShape +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.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.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.fragment.app.FragmentActivity +import coil.compose.AsyncImage +import org.schabi.newpipe.R +import org.schabi.newpipe.error.ErrorUtil +import org.schabi.newpipe.extractor.ServiceList +import org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper +import org.schabi.newpipe.extractor.stream.Description +import org.schabi.newpipe.ui.components.common.DescriptionText +import org.schabi.newpipe.ui.theme.AppTheme +import org.schabi.newpipe.util.Localization +import org.schabi.newpipe.util.NavigationHelper +import org.schabi.newpipe.util.image.ImageStrategy +import java.util.concurrent.TimeUnit + +@Composable +fun PlaylistHeader(playlistInfo: PlaylistInfo, totalDuration: Long) { + val context = LocalContext.current + + Column(modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text(text = playlistInfo.name, style = MaterialTheme.typography.titleMedium) + + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + val clickable = playlistInfo.uploaderName != null && playlistInfo.uploaderUrl != null + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.clickable(clickable) { + try { + NavigationHelper.openChannelFragment( + (context as FragmentActivity).supportFragmentManager, + playlistInfo.serviceId, playlistInfo.uploaderUrl, + playlistInfo.uploaderName!! + ) + } catch (e: Exception) { + ErrorUtil.showUiErrorSnackbar(context, "Opening channel fragment", e) + } + } + ) { + val imageModifier = Modifier + .size(24.dp) + .border(BorderStroke(1.dp, Color.White), CircleShape) + .padding(1.dp) + .clip(CircleShape) + val isMix = YoutubeParsingHelper.isYoutubeMixId(playlistInfo.id) || + YoutubeParsingHelper.isYoutubeMusicMixId(playlistInfo.id) + + if (playlistInfo.serviceId == ServiceList.YouTube.serviceId && isMix) { + Image( + painter = painterResource(R.drawable.ic_radio), + contentDescription = null, + modifier = imageModifier + ) + } else { + AsyncImage( + model = ImageStrategy.choosePreferredImage(playlistInfo.uploaderAvatars), + contentDescription = null, + placeholder = painterResource(R.drawable.placeholder_person), + error = painterResource(R.drawable.placeholder_person), + modifier = imageModifier + ) + } + + val uploader = playlistInfo.uploaderName.orEmpty() + .ifEmpty { stringResource(R.string.playlist_no_uploader) } + Text(text = uploader, style = MaterialTheme.typography.bodySmall) + } + + val count = Localization.localizeStreamCount(context, playlistInfo.streamCount) + val formattedDuration = Localization.getDurationString(totalDuration, true, true) + Text(text = "$count • $formattedDuration", style = MaterialTheme.typography.bodySmall) + } + + if (playlistInfo.description != Description.EMPTY_DESCRIPTION) { + var isExpanded by rememberSaveable { mutableStateOf(false) } + var isExpandable by rememberSaveable { mutableStateOf(false) } + + DescriptionText( + modifier = Modifier.animateContentSize(), + description = playlistInfo.description, + maxLines = if (isExpanded) Int.MAX_VALUE else 5, + style = MaterialTheme.typography.bodyMedium, + overflow = TextOverflow.Ellipsis, + onTextLayout = { + if (it.hasVisualOverflow) { + isExpandable = true + } + } + ) + + if (isExpandable) { + TextButton( + onClick = { isExpanded = !isExpanded }, + modifier = Modifier.align(Alignment.End) + ) { + Text( + text = stringResource(if (isExpanded) R.string.show_less else R.string.show_more) + ) + } + } + } + } +} + +@Preview(name = "Light mode", uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(name = "Dark mode", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun PlaylistHeaderPreview() { + val description = Description("Example description", Description.PLAIN_TEXT) + val playlistInfo = PlaylistInfo( + "", 1, "", "Example playlist", description, listOf(), 1L, + null, "Uploader", listOf(), null + ) + + AppTheme { + Surface(color = MaterialTheme.colorScheme.background) { + PlaylistHeader( + playlistInfo = playlistInfo, + totalDuration = TimeUnit.HOURS.toSeconds(1) + ) + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/playlist/PlaylistInfo.kt b/app/src/main/java/org/schabi/newpipe/ui/components/playlist/PlaylistInfo.kt new file mode 100644 index 00000000000..5147d57a6db --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/ui/components/playlist/PlaylistInfo.kt @@ -0,0 +1,22 @@ +package org.schabi.newpipe.ui.components.playlist + +import androidx.compose.runtime.Immutable +import org.schabi.newpipe.extractor.Image +import org.schabi.newpipe.extractor.Page +import org.schabi.newpipe.extractor.stream.Description +import org.schabi.newpipe.extractor.stream.StreamInfoItem + +@Immutable +class PlaylistInfo( + val id: String, + val serviceId: Int, + val url: String, + val name: String, + val description: Description, + val relatedItems: List, + val streamCount: Long, + val uploaderUrl: String?, + val uploaderName: String?, + val uploaderAvatars: List, + val nextPage: Page? +) diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/stream/StreamCardItem.kt b/app/src/main/java/org/schabi/newpipe/ui/components/stream/StreamCardItem.kt new file mode 100644 index 00000000000..74216582df1 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/ui/components/stream/StreamCardItem.kt @@ -0,0 +1,88 @@ +package org.schabi.newpipe.ui.components.stream + +import android.content.res.Configuration +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import org.schabi.newpipe.extractor.stream.StreamInfoItem +import org.schabi.newpipe.ui.theme.AppTheme + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun StreamCardItem( + stream: StreamInfoItem, + isSelected: Boolean = false, + onClick: (StreamInfoItem) -> Unit = {}, + onLongClick: (StreamInfoItem) -> Unit = {}, + onDismissPopup: () -> Unit = {} +) { + Box { + Column( + modifier = Modifier + .combinedClickable( + onLongClick = { onLongClick(stream) }, + onClick = { onClick(stream) } + ) + .padding(top = 12.dp, start = 2.dp, end = 2.dp) + ) { + StreamThumbnail( + stream = stream, + modifier = Modifier.fillMaxWidth(), + contentScale = ContentScale.FillWidth + ) + + Column(modifier = Modifier.padding(10.dp)) { + Text( + text = stream.name, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.titleSmall, + maxLines = 2 + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text(text = stream.uploaderName.orEmpty(), style = MaterialTheme.typography.bodySmall) + + Text( + text = getStreamInfoDetail(stream), + style = MaterialTheme.typography.bodySmall + ) + } + } + } + + if (isSelected) { + StreamMenu(stream, onDismissPopup) + } + } +} + +@Preview(name = "Light mode", uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(name = "Dark mode", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun StreamCardItemPreview( + @PreviewParameter(StreamItemPreviewProvider::class) stream: StreamInfoItem +) { + AppTheme { + Surface(color = MaterialTheme.colorScheme.background) { + StreamCardItem(stream) + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/stream/StreamGridItem.kt b/app/src/main/java/org/schabi/newpipe/ui/components/stream/StreamGridItem.kt new file mode 100644 index 00000000000..31cc971b8c3 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/ui/components/stream/StreamGridItem.kt @@ -0,0 +1,91 @@ +package org.schabi.newpipe.ui.components.stream + +import android.content.res.Configuration +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import org.schabi.newpipe.extractor.stream.StreamInfoItem +import org.schabi.newpipe.ui.theme.AppTheme + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun StreamGridItem( + stream: StreamInfoItem, + isSelected: Boolean = false, + isMini: Boolean = false, + onClick: (StreamInfoItem) -> Unit = {}, + onLongClick: (StreamInfoItem) -> Unit = {}, + onDismissPopup: () -> Unit = {} +) { + Box { + Column( + modifier = Modifier + .combinedClickable( + onLongClick = { onLongClick(stream) }, + onClick = { onClick(stream) } + ) + .padding(12.dp) + ) { + val size = if (isMini) DpSize(150.dp, 85.dp) else DpSize(246.dp, 138.dp) + + StreamThumbnail(stream = stream, modifier = Modifier.size(size)) + + Text( + text = stream.name, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.titleSmall, + maxLines = 2 + ) + + Text(text = stream.uploaderName.orEmpty(), style = MaterialTheme.typography.bodySmall) + + Text( + text = getStreamInfoDetail(stream), + style = MaterialTheme.typography.bodySmall + ) + } + + if (isSelected) { + StreamMenu(stream, onDismissPopup) + } + } +} + +@Preview(name = "Light mode", uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(name = "Dark mode", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun StreamGridItemPreview( + @PreviewParameter(StreamItemPreviewProvider::class) stream: StreamInfoItem +) { + AppTheme { + Surface(color = MaterialTheme.colorScheme.background) { + StreamGridItem(stream) + } + } +} + +@Preview(name = "Light mode", uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(name = "Dark mode", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun StreamMiniGridItemPreview( + @PreviewParameter(StreamItemPreviewProvider::class) stream: StreamInfoItem +) { + AppTheme { + Surface(color = MaterialTheme.colorScheme.background) { + StreamGridItem(stream, isMini = true) + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/stream/StreamList.kt b/app/src/main/java/org/schabi/newpipe/ui/components/stream/StreamList.kt new file mode 100644 index 00000000000..6d4569de1df --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/ui/components/stream/StreamList.kt @@ -0,0 +1,126 @@ +package org.schabi.newpipe.ui.components.stream + +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyGridScope +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.rememberLazyGridState +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.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.fragment.app.FragmentActivity +import androidx.paging.compose.LazyPagingItems +import androidx.preference.PreferenceManager +import androidx.window.core.layout.WindowWidthSizeClass +import my.nanihadesuka.compose.LazyColumnScrollbar +import my.nanihadesuka.compose.LazyVerticalGridScrollbar +import org.schabi.newpipe.R +import org.schabi.newpipe.extractor.stream.StreamInfoItem +import org.schabi.newpipe.info_list.ItemViewMode +import org.schabi.newpipe.util.NavigationHelper + +@Composable +fun StreamList( + streams: LazyPagingItems, + itemViewMode: ItemViewMode = determineItemViewMode(), + gridHeader: LazyGridScope.() -> Unit = {}, + listHeader: LazyListScope.() -> Unit = {} +) { + val context = LocalContext.current + val onClick = remember { + { stream: StreamInfoItem -> + NavigationHelper.openVideoDetailFragment( + context, (context as FragmentActivity).supportFragmentManager, + stream.serviceId, stream.url, stream.name, null, false + ) + } + } + + // Handle long clicks + // TODO: Adjust the menu display depending on where it was triggered + var selectedStream by remember { mutableStateOf(null) } + val onLongClick = remember { + { stream: StreamInfoItem -> + selectedStream = stream + } + } + val onDismissPopup = remember { + { + selectedStream = null + } + } + + if (itemViewMode == ItemViewMode.GRID) { + val gridState = rememberLazyGridState() + + LazyVerticalGridScrollbar(state = gridState) { + val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass + val isCompact = windowSizeClass.windowWidthSizeClass == WindowWidthSizeClass.COMPACT + val minSize = if (isCompact) 150.dp else 250.dp + + LazyVerticalGrid(state = gridState, columns = GridCells.Adaptive(minSize)) { + gridHeader() + + items(streams.itemCount) { + val stream = streams[it]!! + StreamGridItem( + stream, selectedStream == stream, isCompact, onClick, onLongClick, + onDismissPopup + ) + } + } + } + } else { + // Card or list views + val listState = rememberLazyListState() + + LazyColumnScrollbar(state = listState) { + LazyColumn(state = listState) { + listHeader() + + items(streams.itemCount) { + val stream = streams[it]!! + val isSelected = selectedStream == stream + + if (itemViewMode == ItemViewMode.CARD) { + StreamCardItem(stream, isSelected, onClick, onLongClick, onDismissPopup) + } else { + StreamListItem(stream, isSelected, onClick, onLongClick, onDismissPopup) + } + } + } + } + } +} + +@Composable +private fun determineItemViewMode(): ItemViewMode { + val listMode = PreferenceManager.getDefaultSharedPreferences(LocalContext.current) + .getString( + stringResource(R.string.list_view_mode_key), + stringResource(R.string.list_view_mode_value) + ) + + return when (listMode) { + stringResource(R.string.list_view_mode_list_key) -> ItemViewMode.LIST + stringResource(R.string.list_view_mode_grid_key) -> ItemViewMode.GRID + stringResource(R.string.list_view_mode_card_key) -> ItemViewMode.CARD + else -> { + // Auto mode - evaluate whether to use Grid based on screen real estate. + val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass + if (windowSizeClass.windowWidthSizeClass == WindowWidthSizeClass.EXPANDED) { + ItemViewMode.GRID + } else { + ItemViewMode.LIST + } + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/stream/StreamListItem.kt b/app/src/main/java/org/schabi/newpipe/ui/components/stream/StreamListItem.kt new file mode 100644 index 00000000000..57f9afd032b --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/ui/components/stream/StreamListItem.kt @@ -0,0 +1,86 @@ +package org.schabi.newpipe.ui.components.stream + +import android.content.res.Configuration +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +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.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import org.schabi.newpipe.extractor.stream.StreamInfoItem +import org.schabi.newpipe.ui.theme.AppTheme + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun StreamListItem( + stream: StreamInfoItem, + isSelected: Boolean = false, + onClick: (StreamInfoItem) -> Unit = {}, + onLongClick: (StreamInfoItem) -> Unit = {}, + onDismissPopup: () -> Unit = {} +) { + Box { + Row( + modifier = Modifier + .combinedClickable( + onLongClick = { onLongClick(stream) }, + onClick = { onClick(stream) } + ) + .fillMaxWidth() + .padding(12.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + StreamThumbnail( + stream = stream, + modifier = Modifier.size(width = 98.dp, height = 55.dp) + ) + + Column { + Text( + text = stream.name, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.titleSmall, + maxLines = 1 + ) + + Text(text = stream.uploaderName.orEmpty(), style = MaterialTheme.typography.bodySmall) + + Text( + text = getStreamInfoDetail(stream), + style = MaterialTheme.typography.bodySmall + ) + } + } + + if (isSelected) { + StreamMenu(stream, onDismissPopup) + } + } +} + +@Preview(name = "Light mode", uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(name = "Dark mode", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun StreamListItemPreview( + @PreviewParameter(StreamItemPreviewProvider::class) stream: StreamInfoItem +) { + AppTheme { + Surface(color = MaterialTheme.colorScheme.background) { + StreamListItem(stream) + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/stream/StreamMenu.kt b/app/src/main/java/org/schabi/newpipe/ui/components/stream/StreamMenu.kt new file mode 100644 index 00000000000..cf832d67bcb --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/ui/components/stream/StreamMenu.kt @@ -0,0 +1,74 @@ +package org.schabi.newpipe.ui.components.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.fragment.app.FragmentActivity +import org.schabi.newpipe.R +import org.schabi.newpipe.download.DownloadDialog +import org.schabi.newpipe.extractor.stream.StreamInfo +import org.schabi.newpipe.extractor.stream.StreamInfoItem +import org.schabi.newpipe.util.SparseItemUtil +import org.schabi.newpipe.util.external_communication.ShareUtils + +@Composable +fun StreamMenu( + stream: StreamInfoItem, + onDismissRequest: () -> Unit +) { + val context = LocalContext.current + + // TODO: Implement remaining click actions + DropdownMenu(expanded = true, onDismissRequest = onDismissRequest) { + DropdownMenuItem( + text = { Text(text = stringResource(R.string.start_here_on_background)) }, + onClick = onDismissRequest + ) + DropdownMenuItem( + text = { Text(text = stringResource(R.string.start_here_on_popup)) }, + onClick = onDismissRequest + ) + DropdownMenuItem( + text = { Text(text = stringResource(R.string.download)) }, + onClick = { + onDismissRequest() + SparseItemUtil.fetchStreamInfoAndSaveToDatabase( + context, stream.serviceId, stream.url + ) { info: StreamInfo -> + val downloadDialog = DownloadDialog(context, info) + val fragmentManager = (context as FragmentActivity).supportFragmentManager + downloadDialog.show(fragmentManager, "downloadDialog") + } + } + ) + DropdownMenuItem( + text = { Text(text = stringResource(R.string.add_to_playlist)) }, + onClick = onDismissRequest + ) + 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 + ) + DropdownMenuItem( + text = { Text(text = stringResource(R.string.show_channel_details)) }, + onClick = onDismissRequest + ) + } +} diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/stream/StreamThumbnail.kt b/app/src/main/java/org/schabi/newpipe/ui/components/stream/StreamThumbnail.kt new file mode 100644 index 00000000000..43d776e589b --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/ui/components/stream/StreamThumbnail.kt @@ -0,0 +1,55 @@ +package org.schabi.newpipe.ui.components.stream + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import org.schabi.newpipe.R +import org.schabi.newpipe.extractor.stream.StreamInfoItem +import org.schabi.newpipe.util.Localization +import org.schabi.newpipe.util.StreamTypeUtil +import org.schabi.newpipe.util.image.ImageStrategy + +@Composable +fun StreamThumbnail( + stream: StreamInfoItem, + modifier: Modifier = Modifier, + contentScale: ContentScale = ContentScale.Fit +) { + Box(modifier = modifier, contentAlignment = Alignment.BottomEnd) { + AsyncImage( + model = ImageStrategy.choosePreferredImage(stream.thumbnails), + contentDescription = null, + placeholder = painterResource(R.drawable.placeholder_thumbnail_video), + error = painterResource(R.drawable.placeholder_thumbnail_video), + contentScale = contentScale, + modifier = modifier + ) + + val isLive = StreamTypeUtil.isLiveStream(stream.streamType) + val background = if (isLive) Color.Red else Color.Black + Text( + text = if (isLive) { + stringResource(R.string.duration_live) + } else { + Localization.getDurationString(stream.duration) + }, + color = Color.White, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier + .padding(2.dp) + .background(background.copy(alpha = 0.5f)) + .padding(2.dp) + ) + } +} diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/stream/StreamUtils.kt b/app/src/main/java/org/schabi/newpipe/ui/components/stream/StreamUtils.kt new file mode 100644 index 00000000000..c1c4620684e --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/ui/components/stream/StreamUtils.kt @@ -0,0 +1,68 @@ +package org.schabi.newpipe.ui.components.stream + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import org.schabi.newpipe.extractor.Image +import org.schabi.newpipe.extractor.stream.StreamInfoItem +import org.schabi.newpipe.extractor.stream.StreamType +import org.schabi.newpipe.util.Localization +import org.schabi.newpipe.util.NO_SERVICE_ID +import java.util.concurrent.TimeUnit + +fun StreamInfoItem( + serviceId: Int = NO_SERVICE_ID, + url: String = "", + name: String = "Stream", + streamType: StreamType, + uploaderName: String? = "Uploader", + uploaderUrl: String? = null, + uploaderAvatars: List = emptyList(), + duration: Long = TimeUnit.HOURS.toSeconds(1), + viewCount: Long = 10, + textualUploadDate: String = "1 month ago" +) = StreamInfoItem(serviceId, url, name, streamType).apply { + this.uploaderName = uploaderName + this.uploaderUrl = uploaderUrl + this.uploaderAvatars = uploaderAvatars + this.duration = duration + this.viewCount = viewCount + this.textualUploadDate = textualUploadDate +} + +@Composable +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 date = + Localization.relativeTimeOrTextual(context, stream.uploadDate, stream.textualUploadDate) + + if (views.isEmpty()) { + date.orEmpty() + } else if (date.isNullOrEmpty()) { + views + } else { + "$views • $date" + } + } +} + +internal class StreamItemPreviewProvider : PreviewParameterProvider { + override val values = sequenceOf( + StreamInfoItem(streamType = StreamType.NONE), + StreamInfoItem(streamType = StreamType.LIVE_STREAM), + StreamInfoItem(streamType = StreamType.AUDIO_LIVE_STREAM), + ) +} diff --git a/app/src/main/java/org/schabi/newpipe/ui/screens/PlaylistScreen.kt b/app/src/main/java/org/schabi/newpipe/ui/screens/PlaylistScreen.kt new file mode 100644 index 00000000000..ddd978dc742 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/ui/screens/PlaylistScreen.kt @@ -0,0 +1,87 @@ +package org.schabi.newpipe.ui.screens + +import android.content.res.Configuration +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.tooling.preview.Preview +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.paging.PagingData +import androidx.paging.compose.collectAsLazyPagingItems +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import org.schabi.newpipe.extractor.stream.Description +import org.schabi.newpipe.extractor.stream.StreamInfoItem +import org.schabi.newpipe.extractor.stream.StreamType +import org.schabi.newpipe.ui.components.common.LoadingIndicator +import org.schabi.newpipe.ui.components.playlist.PlaylistHeader +import org.schabi.newpipe.ui.components.playlist.PlaylistInfo +import org.schabi.newpipe.ui.components.stream.StreamInfoItem +import org.schabi.newpipe.ui.components.stream.StreamList +import org.schabi.newpipe.ui.theme.AppTheme +import org.schabi.newpipe.viewmodels.PlaylistViewModel + +@Composable +fun PlaylistScreen(playlistViewModel: PlaylistViewModel = viewModel()) { + Surface(color = MaterialTheme.colorScheme.background) { + val playlistInfo by playlistViewModel.playlistInfo.collectAsState() + PlaylistScreen(playlistInfo, playlistViewModel.streamItems) + } +} + +@Composable +private fun PlaylistScreen( + playlistInfo: PlaylistInfo?, + streamFlow: Flow> +) { + playlistInfo?.let { + val streams = streamFlow.collectAsLazyPagingItems() + + // Paging's load states only indicate when loading is currently happening, not if it can/will + // happen. As such, the duration initially displayed will be the incomplete duration if more + // items can be loaded. + val totalDuration by remember { + derivedStateOf { + streams.itemSnapshotList.sumOf { it!!.duration } + } + } + + StreamList( + streams = streams, + gridHeader = { + item(span = { GridItemSpan(maxLineSpan) }) { + PlaylistHeader(it, totalDuration) + } + }, + listHeader = { + item { + PlaylistHeader(it, totalDuration) + } + } + ) + } ?: LoadingIndicator() +} + +@Preview(name = "Light mode", uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(name = "Dark mode", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun PlaylistPreview() { + val description = Description("Example description", Description.PLAIN_TEXT) + val playlistInfo = PlaylistInfo( + "", 1, "", "Example playlist", description, listOf(), 1L, + null, "Uploader", listOf(), null + ) + val stream = StreamInfoItem(streamType = StreamType.VIDEO_STREAM) + val streamFlow = flowOf(PagingData.from(listOf(stream))) + + AppTheme { + Surface(color = MaterialTheme.colorScheme.background) { + PlaylistScreen(playlistInfo, streamFlow) + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/util/ExtractorHelper.java b/app/src/main/java/org/schabi/newpipe/util/ExtractorHelper.java index 066d5f57047..83f2332ed87 100644 --- a/app/src/main/java/org/schabi/newpipe/util/ExtractorHelper.java +++ b/app/src/main/java/org/schabi/newpipe/util/ExtractorHelper.java @@ -42,8 +42,6 @@ import org.schabi.newpipe.extractor.Page; import org.schabi.newpipe.extractor.channel.ChannelInfo; import org.schabi.newpipe.extractor.channel.tabs.ChannelTabInfo; -import org.schabi.newpipe.extractor.comments.CommentsInfo; -import org.schabi.newpipe.extractor.comments.CommentsInfoItem; import org.schabi.newpipe.extractor.kiosk.KioskInfo; import org.schabi.newpipe.extractor.linkhandler.ListLinkHandler; import org.schabi.newpipe.extractor.playlist.PlaylistInfo; @@ -146,33 +144,6 @@ public static Single> getMoreChannelTabItems( listLinkHandler, nextPage)); } - public static Single getCommentsInfo(final int serviceId, - final String url, - final boolean forceLoad) { - checkServiceId(serviceId); - return checkCache(forceLoad, serviceId, url, InfoCache.Type.COMMENTS, - Single.fromCallable(() -> - CommentsInfo.getInfo(NewPipe.getService(serviceId), url))); - } - - public static Single> getMoreCommentItems( - final int serviceId, - final CommentsInfo info, - final Page nextPage) { - checkServiceId(serviceId); - return Single.fromCallable(() -> - CommentsInfo.getMoreItems(NewPipe.getService(serviceId), info, nextPage)); - } - - public static Single> getMoreCommentItems( - final int serviceId, - final String url, - final Page nextPage) { - checkServiceId(serviceId); - return Single.fromCallable(() -> - CommentsInfo.getMoreItems(NewPipe.getService(serviceId), url, nextPage)); - } - public static Single getPlaylistInfo(final int serviceId, final String url, final boolean forceLoad) { diff --git a/app/src/main/java/org/schabi/newpipe/util/Localization.java b/app/src/main/java/org/schabi/newpipe/util/Localization.java index bc113e8f868..097097d8915 100644 --- a/app/src/main/java/org/schabi/newpipe/util/Localization.java +++ b/app/src/main/java/org/schabi/newpipe/util/Localization.java @@ -219,11 +219,6 @@ public static String deletedDownloadCount(@NonNull final Context context, deletedCount, shortCount(context, deletedCount)); } - public static String replyCount(@NonNull final Context context, final int replyCount) { - return getQuantity(context, R.plurals.replies, 0, replyCount, - String.valueOf(replyCount)); - } - /** * @param context the Android context * @param likeCount the like count, possibly negative if unknown diff --git a/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java b/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java index 5dee32371b5..e7da003d126 100644 --- a/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java +++ b/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java @@ -9,6 +9,7 @@ import android.content.Intent; import android.net.Uri; import android.os.Build; +import android.os.Bundle; import android.util.Log; import android.widget.Toast; @@ -45,7 +46,6 @@ import org.schabi.newpipe.fragments.MainFragment; import org.schabi.newpipe.fragments.detail.VideoDetailFragment; import org.schabi.newpipe.fragments.list.channel.ChannelFragment; -import org.schabi.newpipe.fragments.list.comments.CommentRepliesFragment; import org.schabi.newpipe.fragments.list.kiosk.KioskFragment; import org.schabi.newpipe.fragments.list.playlist.PlaylistFragment; import org.schabi.newpipe.fragments.list.search.SearchFragment; @@ -501,20 +501,14 @@ public static void openCommentAuthorIfPresent(@NonNull final FragmentActivity ac } } - public static void openCommentRepliesFragment(@NonNull final FragmentActivity activity, - @NonNull final CommentsInfoItem comment) { - defaultTransaction(activity.getSupportFragmentManager()) - .replace(R.id.fragment_holder, new CommentRepliesFragment(comment), - CommentRepliesFragment.TAG) - .addToBackStack(CommentRepliesFragment.TAG) - .commit(); - } - public static void openPlaylistFragment(final FragmentManager fragmentManager, - final int serviceId, final String url, - @NonNull final String name) { + final int serviceId, final String url) { + final var args = new Bundle(); + args.putInt(Constants.KEY_SERVICE_ID, serviceId); + args.putString(Constants.KEY_URL, url); + defaultTransaction(fragmentManager) - .replace(R.id.fragment_holder, PlaylistFragment.getInstance(serviceId, url, name)) + .replace(R.id.fragment_holder, PlaylistFragment.class, args) .addToBackStack(null) .commit(); } diff --git a/app/src/main/java/org/schabi/newpipe/util/external_communication/ShareUtils.java b/app/src/main/java/org/schabi/newpipe/util/external_communication/ShareUtils.java index 7524e5413c5..9008a213deb 100644 --- a/app/src/main/java/org/schabi/newpipe/util/external_communication/ShareUtils.java +++ b/app/src/main/java/org/schabi/newpipe/util/external_communication/ShareUtils.java @@ -10,6 +10,7 @@ import android.content.pm.PackageManager; import android.content.pm.ResolveInfo; import android.graphics.Bitmap; +import android.graphics.BitmapFactory; import android.net.Uri; import android.os.Build; import android.text.TextUtils; @@ -25,12 +26,15 @@ import org.schabi.newpipe.R; import org.schabi.newpipe.extractor.Image; import org.schabi.newpipe.util.image.ImageStrategy; -import org.schabi.newpipe.util.image.PicassoHelper; -import java.io.File; -import java.io.FileOutputStream; +import java.nio.file.Files; +import java.util.Collections; import java.util.List; +import coil.Coil; +import coil.disk.DiskCache; +import coil.memory.MemoryCache; + public final class ShareUtils { private static final String TAG = ShareUtils.class.getSimpleName(); @@ -278,7 +282,7 @@ public static void shareText(@NonNull final Context context, * @param content the content to share * @param images a set of possible {@link Image}s of the subject, among which to choose with * {@link ImageStrategy#choosePreferredImage(List)} since that's likely to - * provide an image that is in Picasso's cache + * provide an image that is in Coil's cache */ public static void shareText(@NonNull final Context context, @NonNull final String title, @@ -339,11 +343,9 @@ public static void copyToClipboard(@NonNull final Context context, final String * *

* In order not to worry about network issues (timeouts, DNS issues, low connection speed, ...) - * when sharing a content, only images in the {@link com.squareup.picasso.LruCache LruCache} - * used by the Picasso library inside {@link PicassoHelper} are used as preview images. If the - * thumbnail image is not in the cache, no {@link ClipData} will be generated and {@code null} - * will be returned. - *

+ * when sharing a content, only images in the {@link MemoryCache} or {@link DiskCache} + * used by the Coil library are used as preview images. If the thumbnail image is not in the + * cache, no {@link ClipData} will be generated and {@code null} will be returned. * *

* In order to display the image in the content preview of the Android share sheet, an URI of @@ -359,12 +361,6 @@ public static void copyToClipboard(@NonNull final Context context, final String *

* *

- * This method will call {@link PicassoHelper#getImageFromCacheIfPresent(String)} to get the - * thumbnail of the content in the {@link com.squareup.picasso.LruCache LruCache} used by - * the Picasso library inside {@link PicassoHelper}. - *

- * - *

* Using the result of this method when sharing has only an effect on the system share sheet (if * OEMs didn't change Android system standard behavior) on Android API 29 and higher. *

@@ -378,33 +374,46 @@ private static ClipData generateClipDataForImagePreview( @NonNull final Context context, @NonNull final String thumbnailUrl) { try { - final Bitmap bitmap = PicassoHelper.getImageFromCacheIfPresent(thumbnailUrl); - if (bitmap == null) { - return null; - } - // Save the image in memory to the application's cache because we need a URI to the // image to generate a ClipData which will show the share sheet, and so an image file final Context applicationContext = context.getApplicationContext(); - final String appFolder = applicationContext.getCacheDir().getAbsolutePath(); - final File thumbnailPreviewFile = new File(appFolder - + "/android_share_sheet_image_preview.jpg"); + final var loader = Coil.imageLoader(context); + final var value = loader.getMemoryCache() + .get(new MemoryCache.Key(thumbnailUrl, Collections.emptyMap())); + + final Bitmap cachedBitmap; + if (value != null) { + cachedBitmap = value.getBitmap(); + } else { + try (var snapshot = loader.getDiskCache().openSnapshot(thumbnailUrl)) { + if (snapshot != null) { + cachedBitmap = BitmapFactory.decodeFile(snapshot.getData().toString()); + } else { + cachedBitmap = null; + } + } + } - // Any existing file will be overwritten with FileOutputStream - final FileOutputStream fileOutputStream = new FileOutputStream(thumbnailPreviewFile); - bitmap.compress(Bitmap.CompressFormat.JPEG, 90, fileOutputStream); - fileOutputStream.close(); + if (cachedBitmap == null) { + return null; + } + + final var path = applicationContext.getCacheDir().toPath() + .resolve("android_share_sheet_image_preview.jpg"); + // Any existing file will be overwritten + try (var outputStream = Files.newOutputStream(path)) { + cachedBitmap.compress(Bitmap.CompressFormat.JPEG, 90, outputStream); + } final ClipData clipData = ClipData.newUri(applicationContext.getContentResolver(), "", FileProvider.getUriForFile(applicationContext, BuildConfig.APPLICATION_ID + ".provider", - thumbnailPreviewFile)); + path.toFile())); if (DEBUG) { Log.d(TAG, "ClipData successfully generated for Android share sheet: " + clipData); } return clipData; - } catch (final Exception e) { Log.w(TAG, "Error when setting preview image for share sheet", e); return null; diff --git a/app/src/main/java/org/schabi/newpipe/util/image/CoilHelper.kt b/app/src/main/java/org/schabi/newpipe/util/image/CoilHelper.kt new file mode 100644 index 00000000000..2608090dc95 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/image/CoilHelper.kt @@ -0,0 +1,145 @@ +package org.schabi.newpipe.util.image + +import android.content.Context +import android.graphics.Bitmap +import android.util.Log +import android.widget.ImageView +import androidx.annotation.DrawableRes +import androidx.core.graphics.drawable.toBitmapOrNull +import coil.executeBlocking +import coil.imageLoader +import coil.request.Disposable +import coil.request.ImageRequest +import coil.size.Size +import coil.target.Target +import coil.transform.Transformation +import org.schabi.newpipe.MainActivity +import org.schabi.newpipe.R +import org.schabi.newpipe.extractor.Image +import org.schabi.newpipe.ktx.scale +import kotlin.math.min + +object CoilHelper { + private val TAG = CoilHelper::class.java.simpleName + + @JvmOverloads + fun loadBitmapBlocking( + context: Context, + url: String?, + @DrawableRes placeholderResId: Int = 0 + ): Bitmap? { + val request = getImageRequest(context, url, placeholderResId).build() + return context.imageLoader.executeBlocking(request).drawable?.toBitmapOrNull() + } + + fun loadAvatar(target: ImageView, images: List) { + loadImageDefault(target, images, R.drawable.placeholder_person) + } + + fun loadAvatar(target: ImageView, url: String?) { + loadImageDefault(target, url, R.drawable.placeholder_person) + } + + fun loadThumbnail(target: ImageView, images: List) { + loadImageDefault(target, images, R.drawable.placeholder_thumbnail_video) + } + + fun loadThumbnail(target: ImageView, url: String?) { + loadImageDefault(target, url, R.drawable.placeholder_thumbnail_video) + } + + fun loadScaledDownThumbnail(context: Context, images: List, target: Target): Disposable { + val url = ImageStrategy.choosePreferredImage(images) + val request = getImageRequest(context, url, R.drawable.placeholder_thumbnail_video) + .target(target) + .transformations(object : Transformation { + override val cacheKey = "COIL_PLAYER_THUMBNAIL_TRANSFORMATION_KEY" + + override suspend fun transform(input: Bitmap, size: Size): Bitmap { + if (MainActivity.DEBUG) { + Log.d(TAG, "Thumbnail - transform() called") + } + + val notificationThumbnailWidth = min( + context.resources.getDimension(R.dimen.player_notification_thumbnail_width), + input.width.toFloat() + ).toInt() + + var newHeight = input.height / (input.width / notificationThumbnailWidth) + val result = input.scale(notificationThumbnailWidth, newHeight) + + return if (result == input || !result.isMutable) { + // create a new mutable bitmap to prevent strange crashes on some + // devices (see #4638) + newHeight = input.height / (input.width / (notificationThumbnailWidth - 1)) + input.scale(notificationThumbnailWidth, newHeight) + } else { + result + } + } + }) + .build() + + return context.imageLoader.enqueue(request) + } + + fun loadDetailsThumbnail(target: ImageView, images: List) { + val url = ImageStrategy.choosePreferredImage(images) + loadImageDefault(target, url, R.drawable.placeholder_thumbnail_video, false) + } + + fun loadBanner(target: ImageView, images: List) { + loadImageDefault(target, images, R.drawable.placeholder_channel_banner) + } + + fun loadPlaylistThumbnail(target: ImageView, images: List) { + loadImageDefault(target, images, R.drawable.placeholder_thumbnail_playlist) + } + + fun loadPlaylistThumbnail(target: ImageView, url: String?) { + loadImageDefault(target, url, R.drawable.placeholder_thumbnail_playlist) + } + + private fun loadImageDefault( + target: ImageView, + images: List, + @DrawableRes placeholderResId: Int + ) { + loadImageDefault(target, ImageStrategy.choosePreferredImage(images), placeholderResId) + } + + private fun loadImageDefault( + target: ImageView, + url: String?, + @DrawableRes placeholderResId: Int, + showPlaceholder: Boolean = true + ) { + val request = getImageRequest(target.context, url, placeholderResId, showPlaceholder) + .target(target) + .build() + target.context.imageLoader.enqueue(request) + } + + private fun getImageRequest( + context: Context, + url: String?, + @DrawableRes placeholderResId: Int, + showPlaceholderWhileLoading: Boolean = true + ): ImageRequest.Builder { + // if the URL was chosen with `choosePreferredImage` it will be null, but check again + // `shouldLoadImages` in case the URL was chosen with `imageListToDbUrl` (which is the case + // for URLs stored in the database) + val takenUrl = url?.takeIf { it.isNotEmpty() && ImageStrategy.shouldLoadImages() } + + return ImageRequest.Builder(context) + .data(takenUrl) + .error(placeholderResId) + .memoryCacheKey(takenUrl) + .diskCacheKey(takenUrl) + .apply { + if (takenUrl != null || showPlaceholderWhileLoading) { + placeholder(placeholderResId) + } + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/util/image/PicassoHelper.java b/app/src/main/java/org/schabi/newpipe/util/image/PicassoHelper.java deleted file mode 100644 index 4b116bdf906..00000000000 --- a/app/src/main/java/org/schabi/newpipe/util/image/PicassoHelper.java +++ /dev/null @@ -1,224 +0,0 @@ -package org.schabi.newpipe.util.image; - -import static org.schabi.newpipe.MainActivity.DEBUG; -import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty; -import static org.schabi.newpipe.util.image.ImageStrategy.choosePreferredImage; - -import android.annotation.SuppressLint; -import android.content.Context; -import android.graphics.Bitmap; -import android.util.Log; - -import androidx.annotation.DrawableRes; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.core.graphics.BitmapCompat; - -import com.squareup.picasso.Cache; -import com.squareup.picasso.LruCache; -import com.squareup.picasso.OkHttp3Downloader; -import com.squareup.picasso.Picasso; -import com.squareup.picasso.RequestCreator; -import com.squareup.picasso.Transformation; - -import org.schabi.newpipe.R; -import org.schabi.newpipe.extractor.Image; - -import java.io.File; -import java.io.IOException; -import java.util.List; -import java.util.concurrent.TimeUnit; - -import okhttp3.OkHttpClient; - -public final class PicassoHelper { - private static final String TAG = PicassoHelper.class.getSimpleName(); - private static final String PLAYER_THUMBNAIL_TRANSFORMATION_KEY = - "PICASSO_PLAYER_THUMBNAIL_TRANSFORMATION_KEY"; - - private PicassoHelper() { - } - - private static Cache picassoCache; - private static OkHttpClient picassoDownloaderClient; - - // suppress because terminate() is called in App.onTerminate(), preventing leaks - @SuppressLint("StaticFieldLeak") - private static Picasso picassoInstance; - - - public static void init(final Context context) { - picassoCache = new LruCache(10 * 1024 * 1024); - picassoDownloaderClient = new OkHttpClient.Builder() - .cache(new okhttp3.Cache(new File(context.getExternalCacheDir(), "picasso"), - 50L * 1024L * 1024L)) - // this should already be the default timeout in OkHttp3, but just to be sure... - .callTimeout(15, TimeUnit.SECONDS) - .build(); - - picassoInstance = new Picasso.Builder(context) - .memoryCache(picassoCache) // memory cache - .downloader(new OkHttp3Downloader(picassoDownloaderClient)) // disk cache - .defaultBitmapConfig(Bitmap.Config.RGB_565) - .build(); - } - - public static void terminate() { - picassoCache = null; - picassoDownloaderClient = null; - - if (picassoInstance != null) { - picassoInstance.shutdown(); - picassoInstance = null; - } - } - - public static void clearCache(final Context context) throws IOException { - picassoInstance.shutdown(); - picassoCache.clear(); // clear memory cache - final okhttp3.Cache diskCache = picassoDownloaderClient.cache(); - if (diskCache != null) { - diskCache.delete(); // clear disk cache - } - init(context); - } - - public static void cancelTag(final Object tag) { - picassoInstance.cancelTag(tag); - } - - public static void setIndicatorsEnabled(final boolean enabled) { - picassoInstance.setIndicatorsEnabled(enabled); // useful for debugging - } - - - public static RequestCreator loadAvatar(@NonNull final List images) { - return loadImageDefault(images, R.drawable.placeholder_person); - } - - public static RequestCreator loadAvatar(@Nullable final String url) { - return loadImageDefault(url, R.drawable.placeholder_person); - } - - public static RequestCreator loadThumbnail(@NonNull final List images) { - return loadImageDefault(images, R.drawable.placeholder_thumbnail_video); - } - - public static RequestCreator loadThumbnail(@Nullable final String url) { - return loadImageDefault(url, R.drawable.placeholder_thumbnail_video); - } - - public static RequestCreator loadDetailsThumbnail(@NonNull final List images) { - return loadImageDefault(choosePreferredImage(images), - R.drawable.placeholder_thumbnail_video, false); - } - - public static RequestCreator loadBanner(@NonNull final List images) { - return loadImageDefault(images, R.drawable.placeholder_channel_banner); - } - - public static RequestCreator loadPlaylistThumbnail(@NonNull final List images) { - return loadImageDefault(images, R.drawable.placeholder_thumbnail_playlist); - } - - public static RequestCreator loadPlaylistThumbnail(@Nullable final String url) { - return loadImageDefault(url, R.drawable.placeholder_thumbnail_playlist); - } - - public static RequestCreator loadSeekbarThumbnailPreview(@Nullable final String url) { - return picassoInstance.load(url); - } - - public static RequestCreator loadNotificationIcon(@Nullable final String url) { - return loadImageDefault(url, R.drawable.ic_newpipe_triangle_white); - } - - - public static RequestCreator loadScaledDownThumbnail(final Context context, - @NonNull final List images) { - // scale down the notification thumbnail for performance - return PicassoHelper.loadThumbnail(images) - .transform(new Transformation() { - @Override - public Bitmap transform(final Bitmap source) { - if (DEBUG) { - Log.d(TAG, "Thumbnail - transform() called"); - } - - final float notificationThumbnailWidth = Math.min( - context.getResources() - .getDimension(R.dimen.player_notification_thumbnail_width), - source.getWidth()); - - final Bitmap result = BitmapCompat.createScaledBitmap( - source, - (int) notificationThumbnailWidth, - (int) (source.getHeight() - / (source.getWidth() / notificationThumbnailWidth)), - null, - true); - - if (result == source || !result.isMutable()) { - // create a new mutable bitmap to prevent strange crashes on some - // devices (see #4638) - final Bitmap copied = BitmapCompat.createScaledBitmap( - source, - (int) notificationThumbnailWidth - 1, - (int) (source.getHeight() / (source.getWidth() - / (notificationThumbnailWidth - 1))), - null, - true); - source.recycle(); - return copied; - } else { - source.recycle(); - return result; - } - } - - @Override - public String key() { - return PLAYER_THUMBNAIL_TRANSFORMATION_KEY; - } - }); - } - - @Nullable - public static Bitmap getImageFromCacheIfPresent(@NonNull final String imageUrl) { - // URLs in the internal cache finish with \n so we need to add \n to image URLs - return picassoCache.get(imageUrl + "\n"); - } - - - private static RequestCreator loadImageDefault(@NonNull final List images, - @DrawableRes final int placeholderResId) { - return loadImageDefault(choosePreferredImage(images), placeholderResId); - } - - private static RequestCreator loadImageDefault(@Nullable final String url, - @DrawableRes final int placeholderResId) { - return loadImageDefault(url, placeholderResId, true); - } - - private static RequestCreator loadImageDefault(@Nullable final String url, - @DrawableRes final int placeholderResId, - final boolean showPlaceholderWhileLoading) { - // if the URL was chosen with `choosePreferredImage` it will be null, but check again - // `shouldLoadImages` in case the URL was chosen with `imageListToDbUrl` (which is the case - // for URLs stored in the database) - if (isNullOrEmpty(url) || !ImageStrategy.shouldLoadImages()) { - return picassoInstance - .load((String) null) - .placeholder(placeholderResId) // show placeholder when no image should load - .error(placeholderResId); - } else { - final RequestCreator requestCreator = picassoInstance - .load(url) - .error(placeholderResId); - if (showPlaceholderWhileLoading) { - requestCreator.placeholder(placeholderResId); - } - return requestCreator; - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/util/text/TextEllipsizer.java b/app/src/main/java/org/schabi/newpipe/util/text/TextEllipsizer.java deleted file mode 100644 index 184b73304d8..00000000000 --- a/app/src/main/java/org/schabi/newpipe/util/text/TextEllipsizer.java +++ /dev/null @@ -1,193 +0,0 @@ -package org.schabi.newpipe.util.text; - -import android.graphics.Paint; -import android.text.Layout; -import android.view.View; -import android.widget.TextView; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.core.text.HtmlCompat; - -import org.schabi.newpipe.extractor.StreamingService; -import org.schabi.newpipe.extractor.stream.Description; - -import java.util.function.Consumer; - - -import io.reactivex.rxjava3.disposables.CompositeDisposable; - -/** - *

Class to ellipsize text inside a {@link TextView}.

- * This class provides all utils to automatically ellipsize and expand a text - */ -public final class TextEllipsizer { - private static final int EXPANDED_LINES = Integer.MAX_VALUE; - private static final String ELLIPSIS = "…"; - - @NonNull private final CompositeDisposable disposable = new CompositeDisposable(); - - @NonNull private final TextView view; - private final int maxLines; - @NonNull private Description content; - @Nullable private StreamingService streamingService; - @Nullable private String streamUrl; - private boolean isEllipsized = false; - @Nullable private Boolean canBeEllipsized = null; - - @NonNull private final Paint paintAtContentSize = new Paint(); - private final float ellipsisWidthPx; - @Nullable private Consumer stateChangeListener = null; - @Nullable private Consumer onContentChanged; - - public TextEllipsizer(@NonNull final TextView view, - final int maxLines, - @Nullable final StreamingService streamingService) { - this.view = view; - this.maxLines = maxLines; - this.content = Description.EMPTY_DESCRIPTION; - this.streamingService = streamingService; - - paintAtContentSize.setTextSize(view.getTextSize()); - ellipsisWidthPx = paintAtContentSize.measureText(ELLIPSIS); - } - - public void setOnContentChanged(@Nullable final Consumer onContentChanged) { - this.onContentChanged = onContentChanged; - } - - public void setContent(@NonNull final Description content) { - this.content = content; - canBeEllipsized = null; - linkifyContentView(v -> { - final int currentMaxLines = view.getMaxLines(); - view.setMaxLines(EXPANDED_LINES); - canBeEllipsized = view.getLineCount() > maxLines; - view.setMaxLines(currentMaxLines); - if (onContentChanged != null) { - onContentChanged.accept(canBeEllipsized); - } - }); - } - - public void setStreamUrl(@Nullable final String streamUrl) { - this.streamUrl = streamUrl; - } - - public void setStreamingService(@NonNull final StreamingService streamingService) { - this.streamingService = streamingService; - } - - /** - * Expand the {@link TextEllipsizer#content} to its full length. - */ - public void expand() { - view.setMaxLines(EXPANDED_LINES); - linkifyContentView(v -> isEllipsized = false); - } - - /** - * Shorten the {@link TextEllipsizer#content} to the given number of - * {@link TextEllipsizer#maxLines maximum lines} and add trailing '{@code …}' - * if the text was shorted. - */ - public void ellipsize() { - // expand text to see whether it is necessary to ellipsize the text - view.setMaxLines(EXPANDED_LINES); - linkifyContentView(v -> { - final CharSequence charSeqText = view.getText(); - if (charSeqText != null && view.getLineCount() > maxLines) { - // Note that converting to String removes spans (i.e. links), but that's something - // we actually want since when the text is ellipsized we want all clicks on the - // comment to expand the comment, not to open links. - final String text = charSeqText.toString(); - - final Layout layout = view.getLayout(); - final float lineWidth = layout.getLineWidth(maxLines - 1); - final float layoutWidth = layout.getWidth(); - final int lineStart = layout.getLineStart(maxLines - 1); - final int lineEnd = layout.getLineEnd(maxLines - 1); - - // remove characters up until there is enough space for the ellipsis - // (also summing 2 more pixels, just to be sure to avoid float rounding errors) - int end = lineEnd; - float removedCharactersWidth = 0.0f; - while (lineWidth - removedCharactersWidth + ellipsisWidthPx + 2.0f > layoutWidth - && end >= lineStart) { - end -= 1; - // recalculate each time to account for ligatures or other similar things - removedCharactersWidth = paintAtContentSize.measureText( - text.substring(end, lineEnd)); - } - - // remove trailing spaces and newlines - while (end > 0 && Character.isWhitespace(text.charAt(end - 1))) { - end -= 1; - } - - final String newVal = text.substring(0, end) + ELLIPSIS; - view.setText(newVal); - isEllipsized = true; - } else { - isEllipsized = false; - } - view.setMaxLines(maxLines); - }); - } - - /** - * Toggle the view between the ellipsized and expanded state. - */ - public void toggle() { - if (isEllipsized) { - expand(); - } else { - ellipsize(); - } - } - - /** - * Whether the {@link #view} can be ellipsized. - * This is only the case when the {@link #content} has more lines - * than allowed via {@link #maxLines}. - * @return {@code true} if the {@link #content} has more lines than allowed via - * {@link #maxLines} and thus can be shortened, {@code false} if the {@code content} fits into - * the {@link #view} without being shortened and {@code null} if the initialization is not - * completed yet. - */ - @Nullable - public Boolean canBeEllipsized() { - return canBeEllipsized; - } - - private void linkifyContentView(final Consumer consumer) { - final boolean oldState = isEllipsized; - disposable.clear(); - TextLinkifier.fromDescription(view, content, - HtmlCompat.FROM_HTML_MODE_LEGACY, streamingService, streamUrl, disposable, - v -> { - consumer.accept(v); - notifyStateChangeListener(oldState); - }); - - } - - /** - * Add a listener which is called when the given content is changed, - * either from ellipsized to full or vice versa. - * @param listener The listener to be called, or {@code null} to remove it. - * The Boolean parameter is the new state. - * Ellipsized content is represented as {@code true}, - * normal or full content by {@code false}. - */ - public void setStateChangeListener(@Nullable final Consumer listener) { - this.stateChangeListener = listener; - } - - private void notifyStateChangeListener(final boolean oldState) { - if (oldState != isEllipsized && stateChangeListener != null) { - stateChangeListener.accept(isEllipsized); - } - } - -} diff --git a/app/src/main/java/org/schabi/newpipe/viewmodels/CommentsViewModel.kt b/app/src/main/java/org/schabi/newpipe/viewmodels/CommentsViewModel.kt new file mode 100644 index 00000000000..62babb186ca --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/viewmodels/CommentsViewModel.kt @@ -0,0 +1,22 @@ +package org.schabi.newpipe.viewmodels + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.cachedIn +import org.schabi.newpipe.paging.CommentsSource +import org.schabi.newpipe.util.KEY_SERVICE_ID +import org.schabi.newpipe.util.KEY_URL +import org.schabi.newpipe.util.NO_SERVICE_ID + +class CommentsViewModel(savedStateHandle: SavedStateHandle) : ViewModel() { + private val serviceId = savedStateHandle[KEY_SERVICE_ID] ?: NO_SERVICE_ID + private val url = savedStateHandle.get(KEY_URL) + + val comments = Pager(PagingConfig(pageSize = 20, enablePlaceholders = false)) { + CommentsSource(serviceId, url, null) + }.flow + .cachedIn(viewModelScope) +} diff --git a/app/src/main/java/org/schabi/newpipe/viewmodels/PlaylistViewModel.kt b/app/src/main/java/org/schabi/newpipe/viewmodels/PlaylistViewModel.kt new file mode 100644 index 00000000000..8efe697913e --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/viewmodels/PlaylistViewModel.kt @@ -0,0 +1,51 @@ +package org.schabi.newpipe.viewmodels + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.cachedIn +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.stateIn +import org.schabi.newpipe.extractor.NewPipe +import org.schabi.newpipe.extractor.stream.Description +import org.schabi.newpipe.paging.PlaylistItemsSource +import org.schabi.newpipe.ui.components.playlist.PlaylistInfo +import org.schabi.newpipe.util.KEY_SERVICE_ID +import org.schabi.newpipe.util.KEY_URL +import org.schabi.newpipe.util.NO_SERVICE_ID +import org.schabi.newpipe.extractor.playlist.PlaylistInfo as ExtractorPlaylistInfo + +class PlaylistViewModel(savedStateHandle: SavedStateHandle) : ViewModel() { + private val serviceIdState = savedStateHandle.getStateFlow(KEY_SERVICE_ID, NO_SERVICE_ID) + private val urlState = savedStateHandle.getStateFlow(KEY_URL, "") + + val playlistInfo = serviceIdState.combine(urlState) { id, url -> + val info = ExtractorPlaylistInfo.getInfo(NewPipe.getService(id), url) + val description = info.description ?: Description.EMPTY_DESCRIPTION + PlaylistInfo( + info.id, info.serviceId, info.url, info.name, description, info.relatedItems, + info.streamCount, info.uploaderUrl, info.uploaderName, info.uploaderAvatars, + info.nextPage + ) + } + .flowOn(Dispatchers.IO) + .stateIn(viewModelScope, SharingStarted.Eagerly, null) + + @OptIn(ExperimentalCoroutinesApi::class) + val streamItems = playlistInfo + .filterNotNull() + .flatMapLatest { + Pager(PagingConfig(pageSize = 20, enablePlaceholders = false)) { + PlaylistItemsSource(it) + }.flow + } + .cachedIn(viewModelScope) +} diff --git a/app/src/main/res/layout/comment_replies_header.xml b/app/src/main/res/layout/comment_replies_header.xml deleted file mode 100644 index ed5ba1a1084..00000000000 --- a/app/src/main/res/layout/comment_replies_header.xml +++ /dev/null @@ -1,137 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_comments.xml b/app/src/main/res/layout/fragment_comments.xml deleted file mode 100644 index 2a8c747cd63..00000000000 --- a/app/src/main/res/layout/fragment_comments.xml +++ /dev/null @@ -1,71 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/list_comment_item.xml b/app/src/main/res/layout/list_comment_item.xml deleted file mode 100644 index 631ab204b3f..00000000000 --- a/app/src/main/res/layout/list_comment_item.xml +++ /dev/null @@ -1,104 +0,0 @@ - - - - - - - - - - - - - - - - - -