diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9ae3a77c2..54415858e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,10 +47,10 @@ jobs: BRANCH: ${{ github.head_ref }} run: git checkout -B "$BRANCH" - - name: set up JDK 17 + - name: set up JDK uses: actions/setup-java@v4 with: - java-version: 17 + java-version: 21 distribution: "temurin" cache: 'gradle' @@ -88,10 +88,10 @@ jobs: sudo udevadm control --reload-rules sudo udevadm trigger --name-match=kvm - - name: set up JDK 17 + - name: set up JDK uses: actions/setup-java@v4 with: - java-version: 17 + java-version: 21 distribution: "temurin" cache: 'gradle' @@ -121,10 +121,10 @@ jobs: with: fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis - - name: Set up JDK 17 + - name: Set up JDK uses: actions/setup-java@v4 with: - java-version: 17 + java-version: 21 distribution: "temurin" cache: 'gradle' diff --git a/.gitignore b/.gitignore index 1352b6917..7bccc3132 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ captures/ *.class app/debug/ app/release/ +.kotlin/ # vscode / eclipse files *.classpath diff --git a/app/build.gradle b/app/build.gradle index 9cc7acc74..c02560eea 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -96,6 +96,7 @@ android { buildFeatures { viewBinding true compose true + buildConfig true } packagingOptions { @@ -116,7 +117,7 @@ ext { androidxRoomVersion = '2.6.1' androidxWorkVersion = '2.8.1' - icepickVersion = '3.2.0' + stateSaverVersion = '1.4.1' exoPlayerVersion = '2.18.7' googleAutoServiceVersion = '1.1.1' groupieVersion = '2.10.1' @@ -206,7 +207,8 @@ 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.2' + // WORKAROUND: v0.24.2 can't be resolved by jitpack -> use git commit hash instead + implementation 'com.github.TeamNewPipe:NewPipeExtractor:176da72cb4c3ec4679211339b0e59f6b01bf2f52' implementation 'com.github.TeamNewPipe:NoNonsense-FilePicker:5.0.0' /** Checkstyle **/ @@ -222,7 +224,7 @@ dependencies { implementation 'androidx.constraintlayout:constraintlayout:2.1.4' implementation 'androidx.core:core-ktx:1.12.0' implementation 'androidx.documentfile:documentfile:1.0.1' - implementation 'androidx.fragment:fragment-ktx:1.6.2' + implementation 'androidx.fragment:fragment-compose:1.8.2' implementation "androidx.lifecycle:lifecycle-livedata-ktx:${androidxLifecycleVersion}" implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:${androidxLifecycleVersion}" implementation 'androidx.localbroadcastmanager:localbroadcastmanager:1.1.0' @@ -239,8 +241,9 @@ dependencies { /** Third-party libraries **/ // Instance state boilerplate elimination - implementation "frankiesardo:icepick:${icepickVersion}" - kapt "frankiesardo:icepick-processor:${icepickVersion}" + implementation 'com.github.livefront:bridge:v2.0.2' + implementation "com.evernote:android-state:$stateSaverVersion" + kapt "com.evernote:android-state-processor:$stateSaverVersion" // HTML parser implementation "org.jsoup:jsoup:1.17.2" @@ -288,18 +291,19 @@ dependencies { // Date and time formatting implementation "org.ocpsoft.prettytime:prettytime:5.0.8.Final" - // Jetpack Compose BOM group - implementation(platform('androidx.compose:compose-bom:2024.09.03')) + // Jetpack Compose + implementation(platform('androidx.compose:compose-bom:2024.10.01')) implementation 'androidx.compose.material3:material3' + implementation 'androidx.compose.material3.adaptive:adaptive' + implementation 'androidx.activity:activity-compose' implementation 'androidx.compose.ui:ui-tooling-preview' + implementation 'androidx.lifecycle:lifecycle-viewmodel-compose' implementation 'androidx.compose.ui:ui-text' // Needed for parsing HTML to AnnotatedString + implementation 'androidx.compose.material:material-icons-extended' // Jetpack Compose related dependencies - implementation 'androidx.compose.material3.adaptive:adaptive:1.0.0' - implementation 'androidx.activity:activity-compose:1.9.2' - implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.8.6' implementation 'androidx.paging:paging-compose:3.3.2' - implementation "androidx.navigation:navigation-compose:2.8.2" + implementation "androidx.navigation:navigation-compose:2.8.3" // Coroutines interop implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-rx3:1.8.1' diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 435c4e29b..215df0da5 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -13,15 +13,6 @@ ## Rules for ExoPlayer -keep class com.google.android.exoplayer2.** { *; } -## Rules for Icepick. Copy pasted from https://github.com/frankiesardo/icepick --dontwarn icepick.** --keep class icepick.** { *; } --keep class **$$Icepick { *; } --keepclasseswithmembernames class * { - @icepick.* ; -} --keepnames class * { @icepick.State *;} - ## Rules for OkHttp. Copy pasted from https://github.com/square/okhttp -dontwarn okhttp3.** -dontwarn okio.** diff --git a/app/src/main/java/org/schabi/newpipe/App.java b/app/src/main/java/org/schabi/newpipe/App.java index f1876c5dd..d271dba0d 100644 --- a/app/src/main/java/org/schabi/newpipe/App.java +++ b/app/src/main/java/org/schabi/newpipe/App.java @@ -21,6 +21,7 @@ import org.schabi.newpipe.extractor.NewPipe; import org.schabi.newpipe.extractor.downloader.Downloader; import org.schabi.newpipe.ktx.ExceptionUtils; import org.schabi.newpipe.settings.NewPipeSettings; +import org.schabi.newpipe.util.BridgeStateSaverInitializer; import org.schabi.newpipe.util.Localization; import org.schabi.newpipe.util.ServiceHelper; import org.schabi.newpipe.util.StateSaver; @@ -107,6 +108,7 @@ public class App extends Application implements ImageLoaderFactory { Localization.getPreferredContentCountry(this)); Localization.initPrettyTime(Localization.resolvePrettyTime(getApplicationContext())); + BridgeStateSaverInitializer.init(this); StateSaver.init(this); initNotificationChannels(); diff --git a/app/src/main/java/org/schabi/newpipe/BaseFragment.java b/app/src/main/java/org/schabi/newpipe/BaseFragment.java index 7a06771dd..a55a341e6 100644 --- a/app/src/main/java/org/schabi/newpipe/BaseFragment.java +++ b/app/src/main/java/org/schabi/newpipe/BaseFragment.java @@ -10,8 +10,9 @@ import androidx.appcompat.app.AppCompatActivity; import androidx.fragment.app.Fragment; import androidx.fragment.app.FragmentManager; -import icepick.Icepick; -import icepick.State; +import com.evernote.android.state.State; +import com.livefront.bridge.Bridge; + public abstract class BaseFragment extends Fragment { protected final String TAG = getClass().getSimpleName() + "@" + Integer.toHexString(hashCode()); @@ -48,7 +49,7 @@ public abstract class BaseFragment extends Fragment { + "savedInstanceState = [" + savedInstanceState + "]"); } super.onCreate(savedInstanceState); - Icepick.restoreInstanceState(this, savedInstanceState); + Bridge.restoreInstanceState(this, savedInstanceState); if (savedInstanceState != null) { onRestoreInstanceState(savedInstanceState); } @@ -70,7 +71,7 @@ public abstract class BaseFragment extends Fragment { @Override public void onSaveInstanceState(@NonNull final Bundle outState) { super.onSaveInstanceState(outState); - Icepick.saveInstanceState(this, outState); + Bridge.saveInstanceState(this, outState); } protected void onRestoreInstanceState(@NonNull final Bundle savedInstanceState) { diff --git a/app/src/main/java/org/schabi/newpipe/MainActivity.java b/app/src/main/java/org/schabi/newpipe/MainActivity.java index 175694125..41abd10c1 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.FrameLayout; 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.app.ActivityCompat; 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.databinding.ToolbarLayoutBinding; 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; @@ -557,39 +553,27 @@ public class MainActivity extends AppCompatActivity { // In case bottomSheet is not visible on the screen or collapsed we can assume that the user // 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); - // 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); - } + final var fragmentManager = getSupportFragmentManager(); - } else { - final Fragment fragmentPlayer = getSupportFragmentManager() - .findFragmentById(R.id.fragment_player_holder); + if (bottomSheetHiddenOrCollapsed()) { + final var fragment = fragmentManager.findFragmentById(R.id.fragment_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 (fragment instanceof BackPressable backPressable && backPressable.onBackPressed()) { + return; + } + } else { + final var player = fragmentManager.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 (player instanceof BackPressable backPressable && !backPressable.onBackPressed()) { + BottomSheetBehavior.from(mainBinding.fragmentPlayerHolder) + .setState(BottomSheetBehavior.STATE_COLLAPSED); return; } } - if (getSupportFragmentManager().getBackStackEntryCount() == 1) { + if (fragmentManager.getBackStackEntryCount() == 1) { finish(); } else { super.onBackPressed(); @@ -648,15 +632,9 @@ public class MainActivity extends AppCompatActivity { * */ private void onHomeButtonPressed() { - final FragmentManager fm = getSupportFragmentManager(); - final Fragment fragment = fm.findFragmentById(R.id.fragment_holder); + final var fm = getSupportFragmentManager(); - 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)) { + if (!NavigationHelper.tryGotoSearchFragment(fm)) { // If search fragment wasn't found in the backstack go to the main fragment NavigationHelper.gotoMainFragment(fm); } @@ -854,68 +832,6 @@ public class MainActivity extends AppCompatActivity { } } - 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/RouterActivity.java b/app/src/main/java/org/schabi/newpipe/RouterActivity.java index c59dc7532..197c965ba 100644 --- a/app/src/main/java/org/schabi/newpipe/RouterActivity.java +++ b/app/src/main/java/org/schabi/newpipe/RouterActivity.java @@ -41,6 +41,9 @@ import androidx.lifecycle.Lifecycle; import androidx.lifecycle.LifecycleOwner; import androidx.preference.PreferenceManager; +import com.evernote.android.state.State; +import com.livefront.bridge.Bridge; + import org.schabi.newpipe.database.stream.model.StreamEntity; import org.schabi.newpipe.databinding.ListRadioIconItemBinding; import org.schabi.newpipe.databinding.SingleChoiceDialogViewBinding; @@ -98,8 +101,6 @@ import java.util.List; import java.util.Optional; import java.util.function.Consumer; -import icepick.Icepick; -import icepick.State; import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; import io.reactivex.rxjava3.core.Observable; import io.reactivex.rxjava3.core.Single; @@ -152,7 +153,7 @@ public class RouterActivity extends AppCompatActivity { getWindow().setAttributes(params); super.onCreate(savedInstanceState); - Icepick.restoreInstanceState(this, savedInstanceState); + Bridge.restoreInstanceState(this, savedInstanceState); // FragmentManager will take care to recreate (Playlist|Download)Dialog when screen rotates // We used to .setOnDismissListener(dialog -> finish()); when creating these DialogFragments @@ -197,7 +198,7 @@ public class RouterActivity extends AppCompatActivity { @Override protected void onSaveInstanceState(@NonNull final Bundle outState) { super.onSaveInstanceState(outState); - Icepick.saveInstanceState(this, outState); + Bridge.saveInstanceState(this, outState); } @Override diff --git a/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java b/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java index db2066b27..34a4ba022 100644 --- a/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java +++ b/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java @@ -39,6 +39,8 @@ import androidx.documentfile.provider.DocumentFile; import androidx.fragment.app.DialogFragment; import androidx.preference.PreferenceManager; +import com.evernote.android.state.State; +import com.livefront.bridge.Bridge; import com.nononsenseapps.filepicker.Utils; import org.schabi.newpipe.MainActivity; @@ -59,6 +61,8 @@ import org.schabi.newpipe.settings.NewPipeSettings; import org.schabi.newpipe.streams.io.NoFileManagerSafeGuard; import org.schabi.newpipe.streams.io.StoredDirectoryHelper; import org.schabi.newpipe.streams.io.StoredFileHelper; +import org.schabi.newpipe.util.AudioTrackAdapter; +import org.schabi.newpipe.util.AudioTrackAdapter.AudioTracksWrapper; import org.schabi.newpipe.util.FilePickerActivityHelper; import org.schabi.newpipe.util.FilenameUtils; import org.schabi.newpipe.util.ListHelper; @@ -67,8 +71,6 @@ import org.schabi.newpipe.util.SecondaryStreamHelper; import org.schabi.newpipe.util.SimpleOnSeekBarChangeListener; import org.schabi.newpipe.util.StreamItemAdapter; import org.schabi.newpipe.util.StreamItemAdapter.StreamInfoWrapper; -import org.schabi.newpipe.util.AudioTrackAdapter; -import org.schabi.newpipe.util.AudioTrackAdapter.AudioTracksWrapper; import org.schabi.newpipe.util.ThemeHelper; import java.io.File; @@ -79,8 +81,6 @@ import java.util.Locale; import java.util.Objects; import java.util.Optional; -import icepick.Icepick; -import icepick.State; import io.reactivex.rxjava3.disposables.CompositeDisposable; import us.shandian.giga.get.MissionRecoveryInfo; import us.shandian.giga.postprocessing.Postprocessing; @@ -214,7 +214,7 @@ public class DownloadDialog extends DialogFragment context = getContext(); setStyle(STYLE_NO_TITLE, ThemeHelper.getDialogTheme(context)); - Icepick.restoreInstanceState(this, savedInstanceState); + Bridge.restoreInstanceState(this, savedInstanceState); this.audioTrackAdapter = new AudioTrackAdapter(wrappedAudioTracks); this.subtitleStreamsAdapter = new StreamItemAdapter<>(wrappedSubtitleStreams); @@ -372,7 +372,7 @@ public class DownloadDialog extends DialogFragment @Override public void onSaveInstanceState(@NonNull final Bundle outState) { super.onSaveInstanceState(outState); - Icepick.saveInstanceState(this, outState); + Bridge.saveInstanceState(this, outState); } 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 42ef261a1..51a0ff1e6 100644 --- a/app/src/main/java/org/schabi/newpipe/error/ReCaptchaActivity.java +++ b/app/src/main/java/org/schabi/newpipe/error/ReCaptchaActivity.java @@ -185,10 +185,8 @@ public class ReCaptchaActivity extends AppCompatActivity { final int abuseEnd = url.indexOf("+path"); try { - String abuseCookie = url.substring(abuseStart + 13, abuseEnd); - abuseCookie = Utils.decodeUrlUtf8(abuseCookie); - handleCookies(abuseCookie); - } catch (IllegalArgumentException | StringIndexOutOfBoundsException e) { + handleCookies(Utils.decodeUrlUtf8(url.substring(abuseStart + 13, abuseEnd))); + } catch (final StringIndexOutOfBoundsException e) { if (MainActivity.DEBUG) { Log.e(TAG, "handleCookiesFromUrl: invalid google abuse starting at " + abuseStart + " and ending at " + abuseEnd + " for url " + url, e); diff --git a/app/src/main/java/org/schabi/newpipe/fragments/BaseStateFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/BaseStateFragment.java index a3d3d8b60..8361953b9 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/BaseStateFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/BaseStateFragment.java @@ -13,6 +13,8 @@ import androidx.annotation.Nullable; import androidx.annotation.StringRes; import androidx.fragment.app.Fragment; +import com.evernote.android.state.State; + import org.schabi.newpipe.BaseFragment; import org.schabi.newpipe.R; import org.schabi.newpipe.error.ErrorInfo; @@ -22,8 +24,6 @@ import org.schabi.newpipe.util.InfoCache; import java.util.concurrent.atomic.AtomicBoolean; -import icepick.State; - public abstract class BaseStateFragment extends BaseFragment implements ViewContract { @State protected AtomicBoolean wasLoading = new AtomicBoolean(); @@ -134,6 +134,7 @@ public abstract class BaseStateFragment extends BaseFragment implements ViewC hideErrorPanel(); } + @Override public void showEmptyState() { isLoading.set(false); if (emptyStateView != null) { diff --git a/app/src/main/java/org/schabi/newpipe/fragments/detail/DescriptionFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/detail/DescriptionFragment.java index 581e54156..52fb3f29e 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/detail/DescriptionFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/detail/DescriptionFragment.java @@ -11,6 +11,8 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.StringRes; +import com.evernote.android.state.State; + import org.schabi.newpipe.R; import org.schabi.newpipe.extractor.StreamingService; import org.schabi.newpipe.extractor.stream.Description; @@ -19,8 +21,6 @@ import org.schabi.newpipe.util.Localization; import java.util.List; -import icepick.State; - public class DescriptionFragment extends BaseDescriptionFragment { @State @@ -31,7 +31,7 @@ public class DescriptionFragment extends BaseDescriptionFragment { } public DescriptionFragment() { - // keep empty constructor for IcePick when resuming fragment from memory + // keep empty constructor for State when resuming fragment from memory } 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 11a315d69..4f5bd9e94 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 @@ -56,6 +56,7 @@ import androidx.core.content.ContextCompat; import androidx.fragment.app.Fragment; import androidx.preference.PreferenceManager; +import com.evernote.android.state.State; import com.google.android.exoplayer2.PlaybackException; import com.google.android.exoplayer2.PlaybackParameters; import com.google.android.material.appbar.AppBarLayout; @@ -73,7 +74,6 @@ import org.schabi.newpipe.error.ReCaptchaActivity; import org.schabi.newpipe.error.UserAction; import org.schabi.newpipe.extractor.Image; import org.schabi.newpipe.extractor.NewPipe; -import org.schabi.newpipe.extractor.comments.CommentsInfoItem; import org.schabi.newpipe.extractor.exceptions.ContentNotSupportedException; import org.schabi.newpipe.extractor.exceptions.ExtractionException; import org.schabi.newpipe.extractor.stream.AudioStream; @@ -128,7 +128,6 @@ 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; import io.reactivex.rxjava3.disposables.Disposable; @@ -881,8 +880,7 @@ public final class VideoDetailFragment 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); } @@ -1012,20 +1010,6 @@ public final class VideoDetailFragment updateTabLayoutVisibility(); } - 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; - } - - // 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); - } - } - /*////////////////////////////////////////////////////////////////////////// // Play Utils //////////////////////////////////////////////////////////////////////////*/ diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListInfoFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListInfoFragment.java index dd5eb6c8a..61a361f23 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListInfoFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListInfoFragment.java @@ -9,6 +9,8 @@ import android.view.View; import androidx.annotation.NonNull; +import com.evernote.android.state.State; + import org.schabi.newpipe.R; import org.schabi.newpipe.error.ErrorInfo; import org.schabi.newpipe.error.UserAction; @@ -24,7 +26,6 @@ import java.util.ArrayList; import java.util.List; import java.util.Queue; -import icepick.State; import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; import io.reactivex.rxjava3.core.Single; import io.reactivex.rxjava3.disposables.Disposable; @@ -143,7 +144,7 @@ public abstract class BaseListInfoFragment { + .subscribe((@NonNull final L result) -> { isLoading.set(false); currentInfo = result; currentNextPage = result.getNextPage(); diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelAboutFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelAboutFragment.java index 0dc2fb65a..b7f4a9d3d 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelAboutFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelAboutFragment.java @@ -10,6 +10,8 @@ import android.widget.LinearLayout; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import com.evernote.android.state.State; + import org.schabi.newpipe.R; import org.schabi.newpipe.extractor.StreamingService; import org.schabi.newpipe.extractor.channel.ChannelInfo; @@ -20,8 +22,6 @@ import org.schabi.newpipe.util.Localization; import java.util.List; -import icepick.State; - public class ChannelAboutFragment extends BaseDescriptionFragment { @State protected ChannelInfo channelInfo; @@ -31,7 +31,7 @@ public class ChannelAboutFragment extends BaseDescriptionFragment { } public ChannelAboutFragment() { - // keep empty constructor for IcePick when resuming fragment from memory + // keep empty constructor for State when resuming fragment from memory } @Override 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 3890e4865..56d8a9315 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 @@ -25,6 +25,7 @@ import androidx.core.graphics.ColorUtils; import androidx.core.view.MenuProvider; import androidx.preference.PreferenceManager; +import com.evernote.android.state.State; import com.google.android.material.snackbar.Snackbar; import com.google.android.material.tabs.TabLayout; import com.jakewharton.rxbinding4.view.RxView; @@ -60,7 +61,6 @@ 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; import io.reactivex.rxjava3.disposables.CompositeDisposable; @@ -249,7 +249,7 @@ public class ChannelFragment extends BaseStateFragment //////////////////////////////////////////////////////////////////////////*/ private void monitorSubscription(final ChannelInfo info) { - final Consumer onError = (Throwable throwable) -> { + final Consumer onError = (final Throwable throwable) -> { animate(binding.channelSubscribeButton, false, 100); showSnackBarError(new ErrorInfo(throwable, UserAction.SUBSCRIPTION_GET, "Get subscription status", currentInfo)); @@ -284,14 +284,14 @@ public class ChannelFragment extends BaseStateFragment } private Function mapOnSubscribe(final SubscriptionEntity subscription) { - return (@NonNull Object o) -> { + return (@NonNull final Object o) -> { subscriptionManager.insertSubscription(subscription); return o; }; } private Function mapOnUnsubscribe(final SubscriptionEntity subscription) { - return (@NonNull Object o) -> { + return (@NonNull final Object o) -> { subscriptionManager.deleteSubscription(subscription); return o; }; @@ -318,7 +318,7 @@ public class ChannelFragment extends BaseStateFragment } private Disposable monitorSubscribeButton(final Function action) { - final Consumer onNext = (@NonNull Object o) -> { + final Consumer onNext = (@NonNull final Object o) -> { if (DEBUG) { Log.d(TAG, "Changed subscription status to this channel!"); } @@ -338,7 +338,7 @@ public class ChannelFragment extends BaseStateFragment } private Consumer> getSubscribeUpdateMonitor(final ChannelInfo info) { - return (List subscriptionEntities) -> { + return (final List subscriptionEntities) -> { if (DEBUG) { Log.d(TAG, "subscriptionManager.subscriptionTable.doOnNext() called with: " + "subscriptionEntities = [" + subscriptionEntities + "]"); diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelTabFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelTabFragment.java index 95ac42eed..5d398821a 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelTabFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelTabFragment.java @@ -9,6 +9,8 @@ import android.view.ViewGroup; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import com.evernote.android.state.State; + import org.schabi.newpipe.R; import org.schabi.newpipe.databinding.PlaylistControlBinding; import org.schabi.newpipe.error.UserAction; @@ -32,13 +34,12 @@ import java.util.List; import java.util.function.Supplier; import java.util.stream.Collectors; -import icepick.State; import io.reactivex.rxjava3.core.Single; public class ChannelTabFragment extends BaseListInfoFragment implements PlaylistControlViewHolder { - // states must be protected and not private for IcePick being able to access them + // states must be protected and not private for State being able to access them @State protected ListLinkHandler tabHandler; @State @@ -156,6 +157,7 @@ public class ChannelTabFragment extends BaseListInfoFragment streamItems = infoListAdapter.getItemsList().stream() .filter(StreamInfoItem.class::isInstance) 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 4eb73520f..000000000 --- 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.CoilHelper; -import org.schabi.newpipe.util.image.ImageStrategy; -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 - CoilHelper.INSTANCE.loadAvatar(binding.authorAvatar, item.getUploaderAvatars()); - 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 cc160c395..000000000 --- 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 e25e02794..000000000 --- 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 000000000..6e20e1425 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/comments/CommentsFragment.kt @@ -0,0 +1,35 @@ +package org.schabi.newpipe.fragments.list.comments + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.core.os.bundleOf +import androidx.fragment.app.Fragment +import androidx.fragment.compose.content +import org.schabi.newpipe.ui.components.video.comment.CommentSection +import org.schabi.newpipe.ui.theme.AppTheme +import org.schabi.newpipe.util.KEY_SERVICE_ID +import org.schabi.newpipe.util.KEY_URL + +class CommentsFragment : Fragment() { + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ) = content { + AppTheme { + Surface(color = MaterialTheme.colorScheme.background) { + CommentSection() + } + } + } + + 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/kiosk/KioskFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/kiosk/KioskFragment.java index b90dccb17..6823e13d3 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/kiosk/KioskFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/kiosk/KioskFragment.java @@ -11,6 +11,8 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.app.ActionBar; +import com.evernote.android.state.State; + import org.schabi.newpipe.R; import org.schabi.newpipe.error.ErrorInfo; import org.schabi.newpipe.error.UserAction; @@ -29,7 +31,6 @@ import org.schabi.newpipe.util.ExtractorHelper; import org.schabi.newpipe.util.KioskTranslator; import org.schabi.newpipe.util.Localization; -import icepick.State; import io.reactivex.rxjava3.core.Single; /** diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/search/SearchFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/search/SearchFragment.java index eef3455ae..18c60400b 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/search/SearchFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/search/SearchFragment.java @@ -40,6 +40,8 @@ import androidx.preference.PreferenceManager; import androidx.recyclerview.widget.ItemTouchHelper; import androidx.recyclerview.widget.RecyclerView; +import com.evernote.android.state.State; + import org.schabi.newpipe.R; import org.schabi.newpipe.databinding.FragmentSearchBinding; import org.schabi.newpipe.error.ErrorInfo; @@ -77,7 +79,6 @@ import java.util.Queue; import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; -import icepick.State; import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; import io.reactivex.rxjava3.core.Observable; import io.reactivex.rxjava3.core.Single; @@ -550,7 +551,7 @@ public class SearchFragment extends BaseListFragment { + searchEditText.setOnFocusChangeListener((final View v, final boolean hasFocus) -> { if (DEBUG) { Log.d(TAG, "onFocusChange() called with: " + "v = [" + v + "], hasFocus = [" + hasFocus + "]"); @@ -611,7 +612,7 @@ public class SearchFragment extends BaseListFragment { + (final TextView v, final int actionId, final KeyEvent event) -> { if (DEBUG) { Log.d(TAG, "onEditorAction() called with: v = [" + v + "], " + "actionId = [" + actionId + "], event = [" + event + "]"); diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/videos/RelatedItemsFragment.kt b/app/src/main/java/org/schabi/newpipe/fragments/list/videos/RelatedItemsFragment.kt index b48347f4f..88ebe28f0 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/videos/RelatedItemsFragment.kt +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/videos/RelatedItemsFragment.kt @@ -2,14 +2,12 @@ package org.schabi.newpipe.fragments.list.videos import android.os.Bundle import android.view.LayoutInflater -import android.view.View import android.view.ViewGroup import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface -import androidx.compose.ui.platform.ComposeView -import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.core.os.bundleOf import androidx.fragment.app.Fragment +import androidx.fragment.compose.content import org.schabi.newpipe.extractor.stream.StreamInfo import org.schabi.newpipe.ktx.serializable import org.schabi.newpipe.ui.components.video.RelatedItems @@ -21,15 +19,10 @@ class RelatedItemsFragment : Fragment() { inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? - ): View { - return ComposeView(requireContext()).apply { - setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) - setContent { - AppTheme { - Surface(color = MaterialTheme.colorScheme.background) { - RelatedItems(requireArguments().serializable(KEY_INFO)!!) - } - } + ) = content { + AppTheme { + Surface(color = MaterialTheme.colorScheme.background) { + RelatedItems(requireArguments().serializable(KEY_INFO)!!) } } } 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 d959c6327..a1526af28 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.playlist.PlaylistInfoItem; 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 class InfoItemBuilder { 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 575568c00..e7cf9ba9a 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.ChannelCardInfoItemHolder; 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 class InfoListAdapter extends RecyclerView.Adapter 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/holder/CommentInfoItemHolder.java b/app/src/main/java/org/schabi/newpipe/info_list/holder/CommentInfoItemHolder.java deleted file mode 100644 index a3316d3fe..000000000 --- a/app/src/main/java/org/schabi/newpipe/info_list/holder/CommentInfoItemHolder.java +++ /dev/null @@ -1,208 +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.CoilHelper; -import org.schabi.newpipe.util.image.ImageStrategy; -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 item)) { - return; - } - - // load the author avatar - CoilHelper.INSTANCE.loadAvatar(itemThumbnailView, item.getUploaderAvatars()); - 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/ktx/Context.kt b/app/src/main/java/org/schabi/newpipe/ktx/Context.kt new file mode 100644 index 000000000..f2f4e9613 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/ktx/Context.kt @@ -0,0 +1,13 @@ +package org.schabi.newpipe.ktx + +import android.content.Context +import android.content.ContextWrapper +import androidx.fragment.app.FragmentActivity + +tailrec fun Context.findFragmentActivity(): FragmentActivity { + return when (this) { + is FragmentActivity -> this + is ContextWrapper -> baseContext.findFragmentActivity() + else -> throw IllegalStateException("Unable to find FragmentActivity") + } +} 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 a366723e0..a5e1594d1 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 @@ -19,6 +19,8 @@ import androidx.fragment.app.FragmentManager; import androidx.recyclerview.widget.ItemTouchHelper; import androidx.recyclerview.widget.RecyclerView; +import com.evernote.android.state.State; + import org.reactivestreams.Subscriber; import org.reactivestreams.Subscription; import org.schabi.newpipe.NewPipeDatabase; @@ -36,16 +38,15 @@ import org.schabi.newpipe.local.holder.LocalBookmarkPlaylistItemHolder; import org.schabi.newpipe.local.holder.RemoteBookmarkPlaylistItemHolder; import org.schabi.newpipe.local.playlist.LocalPlaylistManager; import org.schabi.newpipe.local.playlist.RemotePlaylistManager; -import org.schabi.newpipe.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; import java.util.concurrent.atomic.AtomicBoolean; -import icepick.State; import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; import io.reactivex.rxjava3.disposables.CompositeDisposable; import io.reactivex.rxjava3.disposables.Disposable; 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 b99291309..a4e53aab1 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 @@ -44,11 +44,11 @@ import androidx.lifecycle.ViewModelProvider import androidx.preference.PreferenceManager import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.RecyclerView +import com.evernote.android.state.State import com.xwray.groupie.GroupieAdapter import com.xwray.groupie.Item import com.xwray.groupie.OnItemClickListener import com.xwray.groupie.OnItemLongClickListener -import icepick.State import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.core.Single import io.reactivex.rxjava3.disposables.CompositeDisposable diff --git a/app/src/main/java/org/schabi/newpipe/local/history/StatisticsPlaylistFragment.java b/app/src/main/java/org/schabi/newpipe/local/history/StatisticsPlaylistFragment.java index 1fea7e155..fac358075 100644 --- a/app/src/main/java/org/schabi/newpipe/local/history/StatisticsPlaylistFragment.java +++ b/app/src/main/java/org/schabi/newpipe/local/history/StatisticsPlaylistFragment.java @@ -15,6 +15,7 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.viewbinding.ViewBinding; +import com.evernote.android.state.State; import com.google.android.material.snackbar.Snackbar; import org.reactivestreams.Subscriber; @@ -45,7 +46,6 @@ import java.util.Comparator; import java.util.List; import java.util.Objects; -import icepick.State; import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; import io.reactivex.rxjava3.disposables.CompositeDisposable; import io.reactivex.rxjava3.disposables.Disposable; @@ -368,6 +368,7 @@ public class StatisticsPlaylistFragment } } + @Override public PlayQueue getPlayQueue() { return getPlayQueue(0); } diff --git a/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistFragment.java b/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistFragment.java index d5ae431fa..c87d9cccc 100644 --- a/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistFragment.java +++ b/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistFragment.java @@ -26,6 +26,8 @@ import androidx.recyclerview.widget.ItemTouchHelper; import androidx.recyclerview.widget.RecyclerView; import androidx.viewbinding.ViewBinding; +import com.evernote.android.state.State; + import org.reactivestreams.Subscriber; import org.reactivestreams.Subscription; import org.schabi.newpipe.NewPipeDatabase; @@ -49,12 +51,12 @@ import org.schabi.newpipe.local.BaseLocalListFragment; import org.schabi.newpipe.local.history.HistoryRecordManager; import org.schabi.newpipe.player.playqueue.PlayQueue; import org.schabi.newpipe.player.playqueue.SinglePlayQueue; -import org.schabi.newpipe.util.debounce.DebounceSavable; -import org.schabi.newpipe.util.debounce.DebounceSaver; import org.schabi.newpipe.util.Localization; import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.OnClickGesture; import org.schabi.newpipe.util.PlayButtonHelper; +import org.schabi.newpipe.util.debounce.DebounceSavable; +import org.schabi.newpipe.util.debounce.DebounceSaver; import org.schabi.newpipe.util.external_communication.ShareUtils; import java.util.ArrayList; @@ -63,7 +65,6 @@ import java.util.List; import java.util.concurrent.atomic.AtomicBoolean; import java.util.stream.Collectors; -import icepick.State; import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; import io.reactivex.rxjava3.core.Single; import io.reactivex.rxjava3.disposables.CompositeDisposable; @@ -843,6 +844,7 @@ public class LocalPlaylistFragment extends BaseLocalListFragment) { diff --git a/app/src/main/java/org/schabi/newpipe/paging/CommentRepliesSource.kt b/app/src/main/java/org/schabi/newpipe/paging/CommentRepliesSource.kt new file mode 100644 index 000000000..efa189db9 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/paging/CommentRepliesSource.kt @@ -0,0 +1,27 @@ +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 + +class CommentRepliesSource( + private val commentInfo: CommentsInfoItem, +) : PagingSource() { + private val service = NewPipe.getService(commentInfo.serviceId) + + override suspend fun load(params: LoadParams): LoadResult { + // params.key is null the first time load() is called, and we need to return the first page + val repliesPage = params.key ?: commentInfo.replies + val info = withContext(Dispatchers.IO) { + CommentsInfo.getMoreItems(service, commentInfo.url, repliesPage) + } + return LoadResult.Page(info.items, null, info.nextPage) + } + + override fun getRefreshKey(state: PagingState) = null +} 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 000000000..669485e66 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/paging/CommentsSource.kt @@ -0,0 +1,30 @@ +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.ui.components.video.comment.CommentInfo + +class CommentsSource(private val commentInfo: CommentInfo) : PagingSource() { + private val service = NewPipe.getService(commentInfo.serviceId) + + override suspend fun load(params: LoadParams): LoadResult { + // params.key is null the first time the load() function is called, so we need to return the + // first batch of already-loaded comments + if (params.key == null) { + return LoadResult.Page(commentInfo.comments, null, commentInfo.nextPage) + } else { + val info = withContext(Dispatchers.IO) { + CommentsInfo.getMoreItems(service, commentInfo.url, params.key) + } + return LoadResult.Page(info.items, null, info.nextPage) + } + } + + override fun getRefreshKey(state: PagingState) = null +} diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/PlaybackParameterDialog.java b/app/src/main/java/org/schabi/newpipe/player/helper/PlaybackParameterDialog.java index dfb49a25b..7e74c3848 100644 --- a/app/src/main/java/org/schabi/newpipe/player/helper/PlaybackParameterDialog.java +++ b/app/src/main/java/org/schabi/newpipe/player/helper/PlaybackParameterDialog.java @@ -24,6 +24,9 @@ import androidx.core.math.MathUtils; import androidx.fragment.app.DialogFragment; import androidx.preference.PreferenceManager; +import com.evernote.android.state.State; +import com.livefront.bridge.Bridge; + import org.schabi.newpipe.R; import org.schabi.newpipe.databinding.DialogPlaybackParameterBinding; import org.schabi.newpipe.player.ui.VideoPlayerUi; @@ -37,9 +40,6 @@ import java.util.function.DoubleConsumer; import java.util.function.DoubleFunction; import java.util.function.DoubleSupplier; -import icepick.Icepick; -import icepick.State; - public class PlaybackParameterDialog extends DialogFragment { private static final String TAG = "PlaybackParameterDialog"; @@ -135,7 +135,7 @@ public class PlaybackParameterDialog extends DialogFragment { @Override public void onSaveInstanceState(@NonNull final Bundle outState) { super.onSaveInstanceState(outState); - Icepick.saveInstanceState(this, outState); + Bridge.saveInstanceState(this, outState); } /*////////////////////////////////////////////////////////////////////////// @@ -146,7 +146,7 @@ public class PlaybackParameterDialog extends DialogFragment { @Override public Dialog onCreateDialog(@Nullable final Bundle savedInstanceState) { assureCorrectAppLanguage(getContext()); - Icepick.restoreInstanceState(this, savedInstanceState); + Bridge.restoreInstanceState(this, savedInstanceState); binding = DialogPlaybackParameterBinding.inflate(getLayoutInflater()); initUI(); 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 76163b30a..ff7811af3 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,7 +29,6 @@ import org.schabi.newpipe.util.FilePickerActivityHelper; import java.io.File; import java.io.IOException; -import java.net.URI; public class DownloadSettingsFragment extends BasePreferenceFragment { public static final boolean IGNORE_RELEASE_ON_OLD_PATH = true; @@ -107,28 +105,15 @@ public class DownloadSettingsFragment extends BasePreferenceFragment { 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 IllegalArgumentException 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/SettingsActivity.java b/app/src/main/java/org/schabi/newpipe/settings/SettingsActivity.java index 529e53442..0d57ce174 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/SettingsActivity.java +++ b/app/src/main/java/org/schabi/newpipe/settings/SettingsActivity.java @@ -21,7 +21,9 @@ import androidx.fragment.app.FragmentManager; import androidx.preference.Preference; import androidx.preference.PreferenceFragmentCompat; +import com.evernote.android.state.State; import com.jakewharton.rxbinding4.widget.RxTextView; +import com.livefront.bridge.Bridge; import org.schabi.newpipe.MainActivity; import org.schabi.newpipe.R; @@ -41,9 +43,6 @@ import org.schabi.newpipe.views.FocusOverlayView; import java.util.concurrent.TimeUnit; -import icepick.Icepick; -import icepick.State; - /* * Created by Christian Schabesberger on 31.08.15. * @@ -93,7 +92,7 @@ public class SettingsActivity extends AppCompatActivity implements assureCorrectAppLanguage(this); super.onCreate(savedInstanceBundle); - Icepick.restoreInstanceState(this, savedInstanceBundle); + Bridge.restoreInstanceState(this, savedInstanceBundle); final boolean restored = savedInstanceBundle != null; final SettingsLayoutBinding settingsLayoutBinding = @@ -125,7 +124,7 @@ public class SettingsActivity extends AppCompatActivity implements @Override protected void onSaveInstanceState(@NonNull final Bundle outState) { super.onSaveInstanceState(outState); - Icepick.saveInstanceState(this, outState); + Bridge.saveInstanceState(this, outState); } @Override 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 000000000..40c5903c5 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/ui/components/common/DescriptionText.kt @@ -0,0 +1,45 @@ +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.SpanStyle +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, + style: TextStyle = LocalTextStyle.current +) { + Text( + modifier = modifier, + text = rememberParsedDescription(description), + maxLines = maxLines, + style = style, + overflow = overflow + ) +} + +@Composable +fun rememberParsedDescription(description: Description): AnnotatedString { + // TODO: Handle links and hashtags, Markdown. + return remember(description) { + if (description.type == Description.HTML) { + val styles = TextLinkStyles(SpanStyle(textDecoration = TextDecoration.Underline)) + AnnotatedString.fromHtml(description.content, styles) + } else { + AnnotatedString(description.content) + } + } +} 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 000000000..3bfe1dee4 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/ui/components/common/LoadingIndicator.kt @@ -0,0 +1,18 @@ +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/common/Scrollbar.kt b/app/src/main/java/org/schabi/newpipe/ui/components/common/Scrollbar.kt new file mode 100644 index 000000000..eb1595467 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/ui/components/common/Scrollbar.kt @@ -0,0 +1,30 @@ +package org.schabi.newpipe.ui.components.common + +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import my.nanihadesuka.compose.ScrollbarSettings + +@Composable +fun defaultThemedScrollbarSettings(): ScrollbarSettings = ScrollbarSettings.Default.copy( + thumbUnselectedColor = MaterialTheme.colorScheme.primary, + thumbSelectedColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.75f), +) + +@Composable +fun LazyColumnThemedScrollbar( + state: LazyListState, + modifier: Modifier = Modifier, + settings: ScrollbarSettings = defaultThemedScrollbarSettings(), + indicatorContent: (@Composable (index: Int, isThumbSelected: Boolean) -> Unit)? = null, + content: @Composable () -> Unit +) { + my.nanihadesuka.compose.LazyColumnScrollbar( + state = state, + modifier = modifier, + settings = settings, + indicatorContent = indicatorContent, + content = content, + ) +} diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/items/ItemList.kt b/app/src/main/java/org/schabi/newpipe/ui/components/items/ItemList.kt index 506687d12..4562e17af 100644 --- a/app/src/main/java/org/schabi/newpipe/ui/components/items/ItemList.kt +++ b/app/src/main/java/org/schabi/newpipe/ui/components/items/ItemList.kt @@ -14,15 +14,15 @@ import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.rememberNestedScrollInteropConnection import androidx.compose.ui.res.stringResource -import androidx.fragment.app.FragmentActivity import androidx.preference.PreferenceManager import androidx.window.core.layout.WindowWidthSizeClass -import my.nanihadesuka.compose.LazyColumnScrollbar import org.schabi.newpipe.R import org.schabi.newpipe.extractor.InfoItem import org.schabi.newpipe.extractor.playlist.PlaylistInfoItem import org.schabi.newpipe.extractor.stream.StreamInfoItem import org.schabi.newpipe.info_list.ItemViewMode +import org.schabi.newpipe.ktx.findFragmentActivity +import org.schabi.newpipe.ui.components.common.LazyColumnThemedScrollbar import org.schabi.newpipe.ui.components.items.playlist.PlaylistListItem import org.schabi.newpipe.ui.components.items.stream.StreamListItem import org.schabi.newpipe.util.DependentPreferenceHelper @@ -37,7 +37,7 @@ fun ItemList( val context = LocalContext.current val onClick = remember { { item: InfoItem -> - val fragmentManager = (context as FragmentActivity).supportFragmentManager + val fragmentManager = context.findFragmentActivity().supportFragmentManager if (item is StreamInfoItem) { NavigationHelper.openVideoDetailFragment( context, fragmentManager, item.serviceId, item.url, item.name, null, false @@ -72,7 +72,7 @@ fun ItemList( } else { val state = rememberLazyListState() - LazyColumnScrollbar(state = state) { + LazyColumnThemedScrollbar(state = state) { LazyColumn(modifier = nestedScrollModifier, state = state) { listHeader() diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/items/playlist/PlaylistThumbnail.kt b/app/src/main/java/org/schabi/newpipe/ui/components/items/playlist/PlaylistThumbnail.kt index 3fc139119..b1d4d200c 100644 --- a/app/src/main/java/org/schabi/newpipe/ui/components/items/playlist/PlaylistThumbnail.kt +++ b/app/src/main/java/org/schabi/newpipe/ui/components/items/playlist/PlaylistThumbnail.kt @@ -1,18 +1,19 @@ package org.schabi.newpipe.ui.components.items.playlist -import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.PlaylistPlay +import androidx.compose.material3.Icon 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.graphics.ColorFilter import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource @@ -46,10 +47,10 @@ fun PlaylistThumbnail( .padding(2.dp), verticalAlignment = Alignment.CenterVertically ) { - Image( - painter = painterResource(R.drawable.ic_playlist_play), + Icon( + imageVector = Icons.AutoMirrored.Default.PlaylistPlay, contentDescription = null, - colorFilter = ColorFilter.tint(Color.White), + tint = Color.White, modifier = Modifier.size(18.dp) ) diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/items/stream/StreamMenu.kt b/app/src/main/java/org/schabi/newpipe/ui/components/items/stream/StreamMenu.kt index 2bb143db8..2902aa660 100644 --- a/app/src/main/java/org/schabi/newpipe/ui/components/items/stream/StreamMenu.kt +++ b/app/src/main/java/org/schabi/newpipe/ui/components/items/stream/StreamMenu.kt @@ -8,12 +8,12 @@ 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 androidx.lifecycle.viewmodel.compose.viewModel import org.schabi.newpipe.R import org.schabi.newpipe.database.stream.model.StreamEntity import org.schabi.newpipe.download.DownloadDialog import org.schabi.newpipe.extractor.stream.StreamInfoItem +import org.schabi.newpipe.ktx.findFragmentActivity import org.schabi.newpipe.local.dialog.PlaylistAppendDialog import org.schabi.newpipe.local.dialog.PlaylistDialog import org.schabi.newpipe.player.helper.PlayerHolder @@ -84,7 +84,7 @@ fun StreamMenu( ) { info -> // TODO: Use an AlertDialog composable instead. val downloadDialog = DownloadDialog(context, info) - val fragmentManager = (context as FragmentActivity).supportFragmentManager + val fragmentManager = context.findFragmentActivity().supportFragmentManager downloadDialog.show(fragmentManager, "downloadDialog") } } @@ -97,7 +97,7 @@ fun StreamMenu( PlaylistDialog.createCorrespondingDialog(context, list) { dialog -> val tag = if (dialog is PlaylistAppendDialog) "append" else "create" dialog.show( - (context as FragmentActivity).supportFragmentManager, + context.findFragmentActivity().supportFragmentManager, "StreamDialogEntry@${tag}_playlist" ) } @@ -131,7 +131,8 @@ fun StreamMenu( SparseItemUtil.fetchUploaderUrlIfSparse( context, stream.serviceId, stream.url, stream.uploaderUrl ) { url -> - NavigationHelper.openChannelFragment(context as FragmentActivity, stream, url) + val activity = context.findFragmentActivity() + NavigationHelper.openChannelFragment(activity, stream, url) } } ) diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/video/comment/Comment.kt b/app/src/main/java/org/schabi/newpipe/ui/components/video/comment/Comment.kt new file mode 100644 index 000000000..7f29269d1 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/ui/components/video/comment/Comment.kt @@ -0,0 +1,278 @@ +package org.schabi.newpipe.ui.components.video.comment + +import android.content.res.Configuration +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +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.material.icons.Icons +import androidx.compose.material.icons.filled.Favorite +import androidx.compose.material.icons.filled.PushPin +import androidx.compose.material.icons.filled.ThumbUp +import androidx.compose.material3.Icon +import androidx.compose.material3.LocalMinimumInteractiveComponentSize +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.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +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.datasource.CollectionPreviewParameterProvider +import androidx.compose.ui.unit.dp +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.ui.components.common.rememberParsedDescription +import org.schabi.newpipe.ui.theme.AppTheme +import org.schabi.newpipe.util.Localization +import org.schabi.newpipe.util.NavigationHelper +import org.schabi.newpipe.util.external_communication.copyToClipboardCallback +import org.schabi.newpipe.util.image.ImageStrategy + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun Comment(comment: CommentsInfoItem, onCommentAuthorOpened: () -> Unit) { + val context = LocalContext.current + var isExpanded by rememberSaveable { mutableStateOf(false) } + var showReplies by rememberSaveable { mutableStateOf(false) } + val parsedDescription = rememberParsedDescription(comment.commentText) + + Row( + modifier = Modifier + .animateContentSize() + .combinedClickable( + onLongClick = copyToClipboardCallback { parsedDescription }, + onClick = { isExpanded = !isExpanded }, + ) + .padding(start = 8.dp, top = 10.dp, end = 8.dp, bottom = 4.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + AsyncImage( + model = ImageStrategy.choosePreferredImage(comment.uploaderAvatars), + contentDescription = null, + placeholder = painterResource(R.drawable.placeholder_person), + error = painterResource(R.drawable.placeholder_person), + modifier = Modifier + .padding(vertical = 4.dp) + .size(42.dp) + .clip(CircleShape) + .clickable { + NavigationHelper.openCommentAuthorIfPresent(context, comment) + onCommentAuthorOpened() + } + ) + + Column { + Row(verticalAlignment = Alignment.CenterVertically) { + if (comment.isPinned) { + Icon( + imageVector = Icons.Default.PushPin, + contentDescription = stringResource(R.string.detail_pinned_comment_view_description), + modifier = Modifier + .padding(end = 3.dp) + .size(20.dp) + ) + } + + val nameAndDate = remember(comment) { + val date = Localization.relativeTimeOrTextual( + context, comment.uploadDate, comment.textualUploadDate + ) + Localization.concatenateStrings(comment.uploaderName, date) + } + Text( + text = nameAndDate, + style = MaterialTheme.typography.titleSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + Text( + text = parsedDescription, + // 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, + modifier = Modifier.padding(top = 6.dp) + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(start = 1.dp, top = 6.dp, end = 4.dp, bottom = 6.dp) + ) { + // do not show anything if the like count is unknown + if (comment.likeCount >= 0) { + Icon( + imageVector = Icons.Default.ThumbUp, + contentDescription = stringResource(R.string.detail_likes_img_view_description), + modifier = Modifier + .padding(end = 4.dp) + .size(20.dp), + ) + Text( + text = Localization.likeCount(context, comment.likeCount), + maxLines = 1, + style = MaterialTheme.typography.labelMedium, + modifier = Modifier.padding(end = 8.dp) + ) + } + + if (comment.isHeartedByUploader) { + Icon( + imageVector = Icons.Default.Favorite, + contentDescription = stringResource(R.string.detail_heart_img_view_description), + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp), + ) + } + } + + if (comment.replies != null) { + // reduce LocalMinimumInteractiveComponentSize from 48dp to 44dp to slightly + // reduce the button margin (which is still clickable but not visible) + CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides 44.dp) { + TextButton( + onClick = { showReplies = true }, + modifier = Modifier.padding(end = 2.dp) + ) { + val text = pluralStringResource( + R.plurals.replies, comment.replyCount, comment.replyCount.toString() + ) + Text(text = text) + } + } + } + } + } + } + + if (showReplies) { + CommentRepliesDialog( + parentComment = comment, + onDismissRequest = { showReplies = false }, + onCommentAuthorOpened = onCommentAuthorOpened, + ) + } +} + +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 CommentPreviewProvider : CollectionPreviewParameterProvider( + listOf( + CommentsInfoItem( + commentText = Description("Hello world!\n\nThis line should be hidden by default.", Description.PLAIN_TEXT), + uploaderName = "Test", + likeCount = 100, + isPinned = false, + isHeartedByUploader = true, + replies = null, + replyCount = 0 + ), + CommentsInfoItem( + commentText = Description("Hello world, long long long text lorem ipsum dolor sit amet!

This line should be hidden by default.", Description.HTML), + uploaderName = "Test", + likeCount = 92847, + isPinned = true, + isHeartedByUploader = false, + replies = Page(""), + replyCount = 10 + ), + CommentsInfoItem( + commentText = Description("Hello world, long long long text lorem ipsum dolor sit amet!

This line should be hidden by default.", Description.HTML), + uploaderName = "Test really long long long long lorem ipsum dolor sit amet consectetur", + likeCount = 92847, + isPinned = true, + isHeartedByUploader = true, + replies = null, + replyCount = 0 + ), + CommentsInfoItem( + commentText = Description("Short comment", Description.HTML), + uploaderName = "Test really long long long long lorem ipsum dolor sit amet consectetur", + likeCount = 92847, + isPinned = false, + isHeartedByUploader = false, + replies = Page(""), + replyCount = 4283 + ), + ) +) + +@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(CommentPreviewProvider::class) commentsInfoItem: CommentsInfoItem +) { + AppTheme { + Surface(color = MaterialTheme.colorScheme.background) { + Comment(commentsInfoItem) {} + } + } +} + +@Preview +@Composable +private fun CommentListPreview() { + AppTheme { + Surface(color = MaterialTheme.colorScheme.background) { + Column { + for (comment in CommentPreviewProvider().values) { + Comment(comment) {} + } + } + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/video/comment/CommentInfo.kt b/app/src/main/java/org/schabi/newpipe/ui/components/video/comment/CommentInfo.kt new file mode 100644 index 000000000..2c62739e2 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/ui/components/video/comment/CommentInfo.kt @@ -0,0 +1,21 @@ +package org.schabi.newpipe.ui.components.video.comment + +import androidx.compose.runtime.Immutable +import org.schabi.newpipe.extractor.Page +import org.schabi.newpipe.extractor.comments.CommentsInfo +import org.schabi.newpipe.extractor.comments.CommentsInfoItem + +@Immutable +class CommentInfo( + val serviceId: Int, + val url: String, + val comments: List, + val nextPage: Page?, + val commentCount: Int, + val isCommentsDisabled: Boolean +) { + constructor(commentsInfo: CommentsInfo) : this( + commentsInfo.serviceId, commentsInfo.url, commentsInfo.relatedItems, commentsInfo.nextPage, + commentsInfo.commentsCount, commentsInfo.isCommentsDisabled + ) +} diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/video/comment/CommentRepliesDialog.kt b/app/src/main/java/org/schabi/newpipe/ui/components/video/comment/CommentRepliesDialog.kt new file mode 100644 index 000000000..17ea900a5 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/ui/components/video/comment/CommentRepliesDialog.kt @@ -0,0 +1,181 @@ +package org.schabi.newpipe.ui.components.video.comment + +import android.content.res.Configuration +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.contentColorFor +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.rememberNestedScrollInteropConnection +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.datasource.LoremIpsum +import androidx.compose.ui.unit.dp +import androidx.paging.LoadState +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.PagingData +import androidx.paging.cachedIn +import androidx.paging.compose.collectAsLazyPagingItems +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.launch +import org.schabi.newpipe.R +import org.schabi.newpipe.extractor.comments.CommentsInfoItem +import org.schabi.newpipe.extractor.stream.Description +import org.schabi.newpipe.paging.CommentRepliesSource +import org.schabi.newpipe.ui.components.common.LazyColumnThemedScrollbar +import org.schabi.newpipe.ui.components.common.LoadingIndicator +import org.schabi.newpipe.ui.components.common.NoItemsMessage +import org.schabi.newpipe.ui.theme.AppTheme + +@Composable +fun CommentRepliesDialog( + parentComment: CommentsInfoItem, + onDismissRequest: () -> Unit, + onCommentAuthorOpened: () -> Unit, +) { + val coroutineScope = rememberCoroutineScope() + val commentsFlow = remember { + Pager(PagingConfig(pageSize = 20, enablePlaceholders = false)) { + CommentRepliesSource(parentComment) + } + .flow + .cachedIn(coroutineScope) + } + + CommentRepliesDialog(parentComment, commentsFlow, onDismissRequest, onCommentAuthorOpened) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun CommentRepliesDialog( + parentComment: CommentsInfoItem, + commentsFlow: Flow>, + onDismissRequest: () -> Unit, + onCommentAuthorOpened: () -> Unit, +) { + val comments = commentsFlow.collectAsLazyPagingItems() + val nestedScrollInterop = rememberNestedScrollInteropConnection() + val listState = rememberLazyListState() + + val coroutineScope = rememberCoroutineScope() + val sheetState = rememberModalBottomSheetState() + val nestedOnCommentAuthorOpened: () -> Unit = { + // also partialExpand any parent dialog + onCommentAuthorOpened() + coroutineScope.launch { + sheetState.partialExpand() + } + } + + ModalBottomSheet( + sheetState = sheetState, + onDismissRequest = onDismissRequest, + ) { + CompositionLocalProvider( + // contentColorFor(MaterialTheme.colorScheme.containerColor), i.e. ModalBottomSheet's + // default background color, does not resolve correctly, so need to manually set the + // content color for MaterialTheme.colorScheme.background instead + LocalContentColor provides contentColorFor(MaterialTheme.colorScheme.background) + ) { + LazyColumnThemedScrollbar(state = listState) { + LazyColumn( + modifier = Modifier.nestedScroll(nestedScrollInterop), + state = listState + ) { + item { + CommentRepliesHeader( + comment = parentComment, + onCommentAuthorOpened = nestedOnCommentAuthorOpened, + ) + HorizontalDivider( + thickness = 1.dp, + modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 8.dp) + ) + } + + if (parentComment.replyCount >= 0) { + item { + Text( + modifier = Modifier.padding( + horizontal = 12.dp, + vertical = 4.dp + ), + text = pluralStringResource( + R.plurals.replies, + parentComment.replyCount, + parentComment.replyCount, + ), + maxLines = 1, + style = MaterialTheme.typography.titleMedium + ) + } + } + + if (comments.itemCount == 0) { + item { + val refresh = comments.loadState.refresh + if (refresh is LoadState.Loading) { + LoadingIndicator(modifier = Modifier.padding(top = 8.dp)) + } else { + val message = if (refresh is LoadState.Error) { + R.string.error_unable_to_load_comments + } else { + R.string.no_comments + } + NoItemsMessage(message) + } + } + } else { + items(comments.itemCount) { + Comment( + comment = comments[it]!!, + onCommentAuthorOpened = nestedOnCommentAuthorOpened, + ) + } + } + } + } + } + } +} + +@Preview(name = "Light mode", uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(name = "Dark mode", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun CommentRepliesDialogPreview() { + val comment = CommentsInfoItem( + commentText = Description("Hello world!", Description.PLAIN_TEXT), + uploaderName = "Test", + likeCount = 100, + isPinned = true, + isHeartedByUploader = true + ) + val replies = (1..10).map { i -> + CommentsInfoItem( + commentText = Description( + "Reply $i: ${LoremIpsum(i * i).values.first()}", + Description.PLAIN_TEXT, + ), + uploaderName = LoremIpsum(11 - i).values.first() + ) + } + val flow = flowOf(PagingData.from(replies)) + + AppTheme { + CommentRepliesDialog(comment, flow, onDismissRequest = {}, onCommentAuthorOpened = {}) + } +} diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/video/comment/CommentRepliesHeader.kt b/app/src/main/java/org/schabi/newpipe/ui/components/video/comment/CommentRepliesHeader.kt new file mode 100644 index 000000000..95542092e --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/ui/components/video/comment/CommentRepliesHeader.kt @@ -0,0 +1,150 @@ +package org.schabi.newpipe.ui.components.video.comment + +import android.content.res.Configuration +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.material.icons.Icons +import androidx.compose.material.icons.filled.Favorite +import androidx.compose.material.icons.filled.PushPin +import androidx.compose.material.icons.filled.ThumbUp +import androidx.compose.material3.Icon +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.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.datasource.LoremIpsum +import androidx.compose.ui.unit.dp +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, onCommentAuthorOpened: () -> Unit) { + val context = LocalContext.current + + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + modifier = Modifier + .padding(end = 12.dp) + .clip(CircleShape) + .clickable { + NavigationHelper.openCommentAuthorIfPresent(context, comment) + onCommentAuthorOpened() + } + .weight(1.0f, true), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + 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, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.titleSmall, + ) + + Text( + text = Localization.relativeTimeOrTextual( + context, comment.uploadDate, comment.textualUploadDate + ), + style = MaterialTheme.typography.bodySmall, + ) + } + } + + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // do not show anything if the like count is unknown + if (comment.likeCount >= 0) { + Icon( + imageVector = Icons.Default.ThumbUp, + contentDescription = stringResource(R.string.detail_likes_img_view_description), + ) + Text( + text = Localization.likeCount(context, comment.likeCount), + maxLines = 1, + ) + } + + if (comment.isHeartedByUploader) { + Icon( + imageVector = Icons.Default.Favorite, + contentDescription = stringResource(R.string.detail_heart_img_view_description), + tint = MaterialTheme.colorScheme.primary, + ) + } + + if (comment.isPinned) { + Icon( + imageVector = Icons.Default.PushPin, + 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(LoremIpsum(50).values.first(), Description.PLAIN_TEXT), + uploaderName = "Test really long lorem ipsum dolor sit", + 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/video/comment/CommentSection.kt b/app/src/main/java/org/schabi/newpipe/ui/components/video/comment/CommentSection.kt new file mode 100644 index 000000000..33c4e2139 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/ui/components/video/comment/CommentSection.kt @@ -0,0 +1,177 @@ +package org.schabi.newpipe.ui.components.video.comment + +import android.content.res.Configuration +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.rememberNestedScrollInteropConnection +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.paging.LoadState +import androidx.paging.PagingData +import androidx.paging.compose.collectAsLazyPagingItems +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +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.ui.components.common.LazyColumnThemedScrollbar +import org.schabi.newpipe.ui.components.common.LoadingIndicator +import org.schabi.newpipe.ui.components.common.NoItemsMessage +import org.schabi.newpipe.ui.theme.AppTheme +import org.schabi.newpipe.viewmodels.CommentsViewModel +import org.schabi.newpipe.viewmodels.util.Resource + +@Composable +fun CommentSection(commentsViewModel: CommentsViewModel = viewModel()) { + val state by commentsViewModel.uiState.collectAsStateWithLifecycle() + CommentSection(state, commentsViewModel.comments) +} + +@Composable +private fun CommentSection( + uiState: Resource, + commentsFlow: Flow> +) { + val comments = commentsFlow.collectAsLazyPagingItems() + val nestedScrollInterop = rememberNestedScrollInteropConnection() + val state = rememberLazyListState() + + LazyColumnThemedScrollbar(state = state) { + LazyColumn( + modifier = Modifier.nestedScroll(nestedScrollInterop), + state = state + ) { + when (uiState) { + is Resource.Loading -> { + item { + LoadingIndicator(modifier = Modifier.padding(top = 8.dp)) + } + } + + is Resource.Success -> { + val commentInfo = uiState.data + val count = commentInfo.commentCount + + if (commentInfo.isCommentsDisabled) { + item { + NoItemsMessage(R.string.comments_are_disabled) + } + } else if (count == 0) { + item { + NoItemsMessage(R.string.no_comments) + } + } else { + // do not show anything if the comment count is unknown + if (count >= 0) { + item { + Text( + modifier = Modifier + .padding(start = 12.dp, end = 12.dp, bottom = 4.dp), + text = pluralStringResource(R.plurals.comments, count, count), + maxLines = 1, + style = MaterialTheme.typography.titleMedium + ) + } + } + + when (comments.loadState.refresh) { + is LoadState.Loading -> { + item { + LoadingIndicator(modifier = Modifier.padding(top = 8.dp)) + } + } + + is LoadState.Error -> { + item { + NoItemsMessage(R.string.error_unable_to_load_comments) + } + } + + else -> { + items(comments.itemCount) { + Comment(comment = comments[it]!!) {} + } + } + } + } + } + + is Resource.Error -> { + item { + NoItemsMessage(R.string.error_unable_to_load_comments) + } + } + } + } + } +} + +@Preview(name = "Light mode", uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(name = "Dark mode", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun CommentSectionLoadingPreview() { + AppTheme { + Surface(color = MaterialTheme.colorScheme.background) { + CommentSection(uiState = Resource.Loading, commentsFlow = flowOf()) + } + } +} + +@Preview(name = "Light mode", uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(name = "Dark mode", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun CommentSectionSuccessPreview() { + val comments = listOf( + CommentsInfoItem( + commentText = Description( + "Comment 1\n\nThis line should be hidden by default.", + Description.PLAIN_TEXT + ), + uploaderName = "Test", + replies = Page(""), + replyCount = 10 + ) + ) + (2..10).map { + CommentsInfoItem( + commentText = Description("Comment $it", Description.PLAIN_TEXT), + uploaderName = "Test" + ) + } + + AppTheme { + Surface(color = MaterialTheme.colorScheme.background) { + CommentSection( + uiState = Resource.Success( + CommentInfo( + serviceId = 1, url = "", comments = comments, nextPage = null, + commentCount = 10, isCommentsDisabled = false + ) + ), + commentsFlow = flowOf(PagingData.from(comments)) + ) + } + } +} + +@Preview(name = "Light mode", uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(name = "Dark mode", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun CommentSectionErrorPreview() { + AppTheme { + Surface(color = MaterialTheme.colorScheme.background) { + CommentSection(uiState = Resource.Error(RuntimeException()), commentsFlow = flowOf()) + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/util/BridgeStateSaverInitializer.java b/app/src/main/java/org/schabi/newpipe/util/BridgeStateSaverInitializer.java new file mode 100644 index 000000000..aeda4717c --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/BridgeStateSaverInitializer.java @@ -0,0 +1,61 @@ +package org.schabi.newpipe.util; + +import android.content.Context; +import android.os.Bundle; +import android.os.Parcelable; +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.evernote.android.state.StateSaver; +import com.livefront.bridge.Bridge; +import com.livefront.bridge.SavedStateHandler; +import com.livefront.bridge.ViewSavedStateHandler; + +/** + * Configures Bridge's state saver. + */ +public final class BridgeStateSaverInitializer { + + public static void init(final Context context) { + Bridge.initialize( + context, + new SavedStateHandler() { + @Override + public void saveInstanceState( + @NonNull final Object target, + @NonNull final Bundle state) { + StateSaver.saveInstanceState(target, state); + } + + @Override + public void restoreInstanceState( + @NonNull final Object target, + @Nullable final Bundle state) { + StateSaver.restoreInstanceState(target, state); + } + }, + new ViewSavedStateHandler() { + @NonNull + @Override + public Parcelable saveInstanceState( + @NonNull final T target, + @Nullable final Parcelable parentState) { + return StateSaver.saveInstanceState(target, parentState); + } + + @Nullable + @Override + public Parcelable restoreInstanceState( + @NonNull final T target, + @Nullable final Parcelable state) { + return StateSaver.restoreInstanceState(target, state); + } + } + ); + } + + private BridgeStateSaverInitializer() { + } +} 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 066d5f570..83f2332ed 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.NewPipe; 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 final class ExtractorHelper { 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 bc113e8f8..097097d89 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 final class Localization { 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 219328875..e05142c7a 100644 --- a/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java +++ b/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java @@ -46,10 +46,10 @@ import org.schabi.newpipe.extractor.stream.VideoStream; 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; +import org.schabi.newpipe.ktx.ContextKt; import org.schabi.newpipe.local.bookmark.BookmarkFragment; import org.schabi.newpipe.local.feed.FeedFragment; import org.schabi.newpipe.local.history.StatisticsPlaylistFragment; @@ -486,31 +486,23 @@ public final class NavigationHelper { * Opens the comment author channel fragment, if the {@link CommentsInfoItem#getUploaderUrl()} * of {@code comment} is non-null. Shows a UI-error snackbar if something goes wrong. * - * @param activity the activity with the fragment manager and in which to show the snackbar + * @param context the context to use for opening the fragment * @param comment the comment whose uploader/author will be opened */ - public static void openCommentAuthorIfPresent(@NonNull final FragmentActivity activity, + public static void openCommentAuthorIfPresent(@NonNull final Context context, @NonNull final CommentsInfoItem comment) { if (isEmpty(comment.getUploaderUrl())) { return; } try { + final var activity = ContextKt.findFragmentActivity(context); openChannelFragment(activity.getSupportFragmentManager(), comment.getServiceId(), comment.getUploaderUrl(), comment.getUploaderName()); } catch (final Exception e) { - ErrorUtil.showUiErrorSnackbar(activity, "Opening channel fragment", e); + ErrorUtil.showUiErrorSnackbar(context, "Opening channel fragment", e); } } - 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) { diff --git a/app/src/main/java/org/schabi/newpipe/util/external_communication/ShareUtilsKt.kt b/app/src/main/java/org/schabi/newpipe/util/external_communication/ShareUtilsKt.kt new file mode 100644 index 000000000..fd60f348d --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/external_communication/ShareUtilsKt.kt @@ -0,0 +1,28 @@ +package org.schabi.newpipe.util.external_communication + +import android.content.Context +import android.os.Build +import android.widget.Toast +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.ClipboardManager +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.AnnotatedString +import org.schabi.newpipe.R + +fun ClipboardManager.setTextAndShowToast(context: Context, annotatedString: AnnotatedString) { + setText(annotatedString) + if (Build.VERSION.SDK_INT < 33) { + // Android 13 has its own "copied to clipboard" dialog + Toast.makeText(context, R.string.msg_copied, Toast.LENGTH_SHORT).show() + } +} + +@Composable +fun copyToClipboardCallback(annotatedString: () -> AnnotatedString): (() -> Unit) { + val clipboardManager = LocalClipboardManager.current + val context = LocalContext.current + return { + clipboardManager.setTextAndShowToast(context, annotatedString()) + } +} 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 000000000..007292498 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/viewmodels/CommentsViewModel.kt @@ -0,0 +1,44 @@ +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.filterIsInstance +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import org.schabi.newpipe.extractor.comments.CommentsInfo +import org.schabi.newpipe.paging.CommentsSource +import org.schabi.newpipe.ui.components.video.comment.CommentInfo +import org.schabi.newpipe.util.KEY_URL +import org.schabi.newpipe.viewmodels.util.Resource + +class CommentsViewModel(savedStateHandle: SavedStateHandle) : ViewModel() { + val uiState = savedStateHandle.getStateFlow(KEY_URL, "") + .map { + try { + Resource.Success(CommentInfo(CommentsInfo.getInfo(it))) + } catch (e: Exception) { + Resource.Error(e) + } + } + .flowOn(Dispatchers.IO) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), Resource.Loading) + + @OptIn(ExperimentalCoroutinesApi::class) + val comments = uiState + .filterIsInstance>() + .flatMapLatest { + Pager(PagingConfig(pageSize = 20, enablePlaceholders = false)) { + CommentsSource(it.data) + }.flow + } + .cachedIn(viewModelScope) +} diff --git a/app/src/main/java/org/schabi/newpipe/viewmodels/util/Resource.kt b/app/src/main/java/org/schabi/newpipe/viewmodels/util/Resource.kt new file mode 100644 index 000000000..38bc81391 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/viewmodels/util/Resource.kt @@ -0,0 +1,7 @@ +package org.schabi.newpipe.viewmodels.util + +sealed class Resource { + data object Loading : Resource() + class Success(val data: T) : Resource() + class Error(val throwable: Throwable) : Resource() +} diff --git a/app/src/main/java/org/schabi/newpipe/views/CollapsibleView.java b/app/src/main/java/org/schabi/newpipe/views/CollapsibleView.java index f79e1e3a3..91b5ebd07 100644 --- a/app/src/main/java/org/schabi/newpipe/views/CollapsibleView.java +++ b/app/src/main/java/org/schabi/newpipe/views/CollapsibleView.java @@ -19,6 +19,9 @@ package org.schabi.newpipe.views; +import static org.schabi.newpipe.MainActivity.DEBUG; +import static java.lang.annotation.RetentionPolicy.SOURCE; + import android.animation.ValueAnimator; import android.content.Context; import android.os.Parcelable; @@ -29,18 +32,15 @@ import android.widget.LinearLayout; import androidx.annotation.IntDef; import androidx.annotation.Nullable; +import com.evernote.android.state.State; +import com.livefront.bridge.Bridge; + import org.schabi.newpipe.ktx.ViewUtils; import java.lang.annotation.Retention; import java.util.ArrayList; import java.util.List; -import icepick.Icepick; -import icepick.State; - -import static java.lang.annotation.RetentionPolicy.SOURCE; -import static org.schabi.newpipe.MainActivity.DEBUG; - /** * A view that can be fully collapsed and expanded. */ @@ -207,12 +207,12 @@ public class CollapsibleView extends LinearLayout { @Nullable @Override public Parcelable onSaveInstanceState() { - return Icepick.saveInstanceState(this, super.onSaveInstanceState()); + return Bridge.saveInstanceState(this, super.onSaveInstanceState()); } @Override public void onRestoreInstanceState(final Parcelable state) { - super.onRestoreInstanceState(Icepick.restoreInstanceState(this, state)); + super.onRestoreInstanceState(Bridge.restoreInstanceState(this, state)); ready(); } diff --git a/app/src/main/res/layout-land/list_stream_card_item.xml b/app/src/main/res/layout-land/list_stream_card_item.xml deleted file mode 120000 index 70228ee1d..000000000 --- a/app/src/main/res/layout-land/list_stream_card_item.xml +++ /dev/null @@ -1 +0,0 @@ -../layout/list_stream_item.xml \ No newline at end of file diff --git a/app/src/main/res/layout-land/list_stream_card_item.xml b/app/src/main/res/layout-land/list_stream_card_item.xml new file mode 100644 index 000000000..793942568 --- /dev/null +++ b/app/src/main/res/layout-land/list_stream_card_item.xml @@ -0,0 +1,94 @@ + + + + + + + + + + + + + + + + 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 ed5ba1a10..000000000 --- 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 2a8c747cd..000000000 --- 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 631ab204b..000000000 --- a/app/src/main/res/layout/list_comment_item.xml +++ /dev/null @@ -1,104 +0,0 @@ - - - - - - - - - - - - - - - - - -