diff --git a/app/src/main/java/org/schabi/newpipe/MainActivity.java b/app/src/main/java/org/schabi/newpipe/MainActivity.java index ea047ce61..a096e7eaf 100644 --- a/app/src/main/java/org/schabi/newpipe/MainActivity.java +++ b/app/src/main/java/org/schabi/newpipe/MainActivity.java @@ -44,6 +44,7 @@ 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; @@ -51,6 +52,7 @@ 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; @@ -64,11 +66,13 @@ 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; @@ -546,14 +550,21 @@ public class MainActivity extends AppCompatActivity { // interacts with a fragment inside fragment_holder so all back presses should be // handled by it if (bottomSheetHiddenOrCollapsed()) { - final Fragment fragment = getSupportFragmentManager() - .findFragmentById(R.id.fragment_holder); + 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); } } else { @@ -629,10 +640,17 @@ public class MainActivity extends AppCompatActivity { * */ private void onHomeButtonPressed() { - // If search fragment wasn't found in the backstack... - if (!NavigationHelper.tryGotoSearchFragment(getSupportFragmentManager())) { - // ...go to the main fragment - NavigationHelper.gotoMainFragment(getSupportFragmentManager()); + final FragmentManager fm = getSupportFragmentManager(); + final Fragment fragment = fm.findFragmentById(R.id.fragment_holder); + + if (fragment instanceof CommentRepliesFragment) { + // Expand DetailsFragment if CommentRepliesFragment was opened + // and no other CommentRepliesFragments are on top of the back stack + // to show the top level comments again. + openDetailFragmentFromCommentReplies(fm, true); + } else if (!NavigationHelper.tryGotoSearchFragment(fm)) { + // If search fragment wasn't found in the backstack go to the main fragment + NavigationHelper.gotoMainFragment(fm); } } @@ -828,6 +846,68 @@ 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/error/UserAction.java b/app/src/main/java/org/schabi/newpipe/error/UserAction.java index 976173373..c8701cd77 100644 --- a/app/src/main/java/org/schabi/newpipe/error/UserAction.java +++ b/app/src/main/java/org/schabi/newpipe/error/UserAction.java @@ -19,6 +19,7 @@ public enum UserAction { REQUESTED_PLAYLIST("requested playlist"), REQUESTED_KIOSK("requested kiosk"), REQUESTED_COMMENTS("requested comments"), + REQUESTED_COMMENT_REPLIES("requested comment replies"), REQUESTED_FEED("requested feed"), REQUESTED_BOOKMARK("bookmark"), DELETE_FROM_HISTORY("delete from history"), 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 7db5e0251..4da0a561e 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 @@ -74,6 +74,7 @@ import org.schabi.newpipe.error.UserAction; import org.schabi.newpipe.extractor.Image; import org.schabi.newpipe.extractor.InfoItem; 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; @@ -1012,6 +1013,20 @@ 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 e7e9f5aad..dd5eb6c8a 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 @@ -231,6 +231,8 @@ public abstract class BaseListInfoFragment { + + public static final String TAG = CommentRepliesFragment.class.getSimpleName(); + + private CommentsInfoItem commentsInfoItem; // the comment to show replies of + private final CompositeDisposable disposables = new CompositeDisposable(); + + + /*////////////////////////////////////////////////////////////////////////// + // Constructors and lifecycle + //////////////////////////////////////////////////////////////////////////*/ + + // only called by the Android framework, after which readFrom is called and restores all data + public CommentRepliesFragment() { + super(UserAction.REQUESTED_COMMENT_REPLIES); + } + + public CommentRepliesFragment(@NonNull final CommentsInfoItem commentsInfoItem) { + this(); + this.commentsInfoItem = commentsInfoItem; + // setting "" as title since the title will be properly set right after + setInitialData(commentsInfoItem.getServiceId(), commentsInfoItem.getUrl(), ""); + } + + @Nullable + @Override + public View onCreateView(@NonNull final LayoutInflater inflater, + @Nullable final ViewGroup container, + @Nullable final Bundle savedInstanceState) { + return inflater.inflate(R.layout.fragment_comments, container, false); + } + + @Override + public void onDestroyView() { + disposables.clear(); + super.onDestroyView(); + } + + @Override + protected Supplier getListHeaderSupplier() { + return () -> { + final CommentRepliesHeaderBinding binding = CommentRepliesHeaderBinding + .inflate(activity.getLayoutInflater(), itemsList, false); + final CommentsInfoItem item = commentsInfoItem; + + // load the author avatar + PicassoHelper.loadAvatar(item.getUploaderAvatars()).into(binding.authorAvatar); + binding.authorAvatar.setVisibility(ImageStrategy.shouldLoadImages() + ? View.VISIBLE : View.GONE); + + // setup author name and comment date + binding.authorName.setText(item.getUploaderName()); + binding.uploadDate.setText(Localization.relativeTimeOrTextual( + getContext(), item.getUploadDate(), item.getTextualUploadDate())); + binding.authorTouchArea.setOnClickListener( + v -> NavigationHelper.openCommentAuthorIfPresent(requireActivity(), item)); + + // setup like count, hearted and pinned + binding.thumbsUpCount.setText( + Localization.likeCount(requireContext(), item.getLikeCount())); + // for heartImage goneMarginEnd was used, but there is no way to tell ConstraintLayout + // not to use a different margin only when both the next two views are gone + ((ConstraintLayout.LayoutParams) binding.thumbsUpCount.getLayoutParams()) + .setMarginEnd(DeviceUtils.dpToPx( + (item.isHeartedByUploader() || item.isPinned() ? 8 : 16), + requireContext())); + binding.heartImage.setVisibility(item.isHeartedByUploader() ? View.VISIBLE : View.GONE); + binding.pinnedImage.setVisibility(item.isPinned() ? View.VISIBLE : View.GONE); + + // setup comment content + TextLinkifier.fromDescription(binding.commentContent, item.getCommentText(), + HtmlCompat.FROM_HTML_MODE_LEGACY, getServiceById(item.getServiceId()), + item.getUrl(), disposables, null); + + return binding.getRoot(); + }; + } + + + /*////////////////////////////////////////////////////////////////////////// + // State saving + //////////////////////////////////////////////////////////////////////////*/ + + @Override + public void writeTo(final Queue objectsToSave) { + super.writeTo(objectsToSave); + objectsToSave.add(commentsInfoItem); + } + + @Override + public void readFrom(@NonNull final Queue savedObjects) throws Exception { + super.readFrom(savedObjects); + commentsInfoItem = (CommentsInfoItem) savedObjects.poll(); + } + + + /*////////////////////////////////////////////////////////////////////////// + // Data loading + //////////////////////////////////////////////////////////////////////////*/ + + @Override + protected Single loadResult(final boolean forceLoad) { + return Single.fromCallable(() -> new CommentRepliesInfo(commentsInfoItem, + // the reply count string will be shown as the activity title + Localization.replyCount(requireContext(), commentsInfoItem.getReplyCount()))); + } + + @Override + protected Single> loadMoreItemsLogic() { + // commentsInfoItem.getUrl() should contain the url of the original + // ListInfo, which should be the stream url + return ExtractorHelper.getMoreCommentItems( + serviceId, commentsInfoItem.getUrl(), currentNextPage); + } + + + /*////////////////////////////////////////////////////////////////////////// + // Utils + //////////////////////////////////////////////////////////////////////////*/ + + @Override + protected ItemViewMode getItemViewMode() { + return ItemViewMode.LIST; + } + + /** + * @return the comment to which the replies are shown + */ + public CommentsInfoItem getCommentsInfoItem() { + return commentsInfoItem; + } +} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/comments/CommentRepliesInfo.java b/app/src/main/java/org/schabi/newpipe/fragments/list/comments/CommentRepliesInfo.java new file mode 100644 index 000000000..cc160c395 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/comments/CommentRepliesInfo.java @@ -0,0 +1,22 @@ +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 index 5a5f84968..e25e02794 100644 --- 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 @@ -110,4 +110,14 @@ public class CommentsFragment extends BaseListInfoFragment +public class RelatedItemsFragment extends BaseListInfoFragment implements SharedPreferences.OnSharedPreferenceChangeListener { private static final String INFO_KEY = "related_info_key"; - private RelatedItemInfo relatedItemInfo; + private RelatedItemsInfo relatedItemsInfo; /*////////////////////////////////////////////////////////////////////////// // Views @@ -69,7 +68,7 @@ public class RelatedItemsFragment extends BaseListInfoFragment getListHeaderSupplier() { - if (relatedItemInfo == null || relatedItemInfo.getRelatedItems() == null) { + if (relatedItemsInfo == null || relatedItemsInfo.getRelatedItems() == null) { return null; } @@ -97,8 +96,8 @@ public class RelatedItemsFragment extends BaseListInfoFragment loadResult(final boolean forceLoad) { - return Single.fromCallable(() -> relatedItemInfo); + protected Single loadResult(final boolean forceLoad) { + return Single.fromCallable(() -> relatedItemsInfo); } @Override @@ -110,7 +109,7 @@ public class RelatedItemsFragment extends BaseListInfoFragment { + /** + * This class is used to wrap the related items of a StreamInfo into a ListInfo object. + * + * @param info the stream info from which to get related items + */ + public RelatedItemsInfo(final StreamInfo info) { + super(info.getServiceId(), new ListLinkHandler(info.getOriginalUrl(), info.getUrl(), + info.getId(), Collections.emptyList(), null), info.getName()); + setRelatedItems(new ArrayList<>(info.getRelatedItems())); + } +} 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 68f19ee97..d959c6327 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,8 +13,7 @@ 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.CommentsInfoItemHolder; -import org.schabi.newpipe.info_list.holder.CommentsMiniInfoItemHolder; +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; @@ -87,8 +86,7 @@ public class InfoItemBuilder { return useMiniVariant ? new PlaylistMiniInfoItemHolder(this, parent) : new PlaylistInfoItemHolder(this, parent); case COMMENT: - return useMiniVariant ? new CommentsMiniInfoItemHolder(this, parent) - : new CommentsInfoItemHolder(this, parent); + return new CommentInfoItemHolder(this, parent); default: throw new RuntimeException("InfoType not expected = " + infoType.name()); } 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 a13f0e5aa..575568c00 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,8 +21,7 @@ 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.CommentsInfoItemHolder; -import org.schabi.newpipe.info_list.holder.CommentsMiniInfoItemHolder; +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; @@ -79,8 +78,7 @@ public class InfoListAdapter extends RecyclerView.Adapter openCommentAuthor(item)); - try { - streamService = NewPipe.getService(item.getServiceId()); - } catch (final ExtractionException e) { - // should never happen - ErrorUtil.showUiErrorSnackbar(itemBuilder.getContext(), "Getting StreamingService", e); - Log.w(TAG, "Cannot obtain service from comment service id, defaulting to YouTube", e); - streamService = ServiceList.YouTube; - } + + // 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 + streamService = getServiceById(item.getServiceId()); streamUrl = item.getUrl(); commentText = item.getCommentText(); ellipsize(); @@ -127,22 +147,6 @@ public class CommentsMiniInfoItemHolder extends InfoItemHolder { //noinspection ClickableViewAccessibility itemContentView.setOnTouchListener(CommentTextOnTouchListener.INSTANCE); - if (item.getLikeCount() >= 0) { - itemLikesCountView.setText( - Localization.shortCount( - itemBuilder.getContext(), - item.getLikeCount())); - } else { - itemLikesCountView.setText("-"); - } - - if (item.getUploadDate() != null) { - itemPublishedTime.setText(Localization.relativeTime(item.getUploadDate() - .offsetDateTime())); - } else { - itemPublishedTime.setText(item.getTextualUploadDate()); - } - itemView.setOnClickListener(view -> { toggleEllipsize(); if (itemBuilder.getOnCommentsSelectedListener() != null) { @@ -150,7 +154,6 @@ public class CommentsMiniInfoItemHolder extends InfoItemHolder { } }); - itemView.setOnLongClickListener(view -> { if (DeviceUtils.isTv(itemBuilder.getContext())) { openCommentAuthor(item); @@ -164,20 +167,14 @@ public class CommentsMiniInfoItemHolder extends InfoItemHolder { }); } - private void openCommentAuthor(final CommentsInfoItem item) { - if (isEmpty(item.getUploaderUrl())) { - return; - } - final AppCompatActivity activity = (AppCompatActivity) itemBuilder.getContext(); - try { - NavigationHelper.openChannelFragment( - activity.getSupportFragmentManager(), - item.getServiceId(), - item.getUploaderUrl(), - item.getUploaderName()); - } catch (final Exception e) { - ErrorUtil.showUiErrorSnackbar(activity, "Opening channel fragment", e); - } + 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() { diff --git a/app/src/main/java/org/schabi/newpipe/info_list/holder/CommentsInfoItemHolder.java b/app/src/main/java/org/schabi/newpipe/info_list/holder/CommentsInfoItemHolder.java deleted file mode 100644 index 4fc2d9f84..000000000 --- a/app/src/main/java/org/schabi/newpipe/info_list/holder/CommentsInfoItemHolder.java +++ /dev/null @@ -1,63 +0,0 @@ -package org.schabi.newpipe.info_list.holder; - -import android.view.View; -import android.view.ViewGroup; -import android.widget.ImageView; -import android.widget.TextView; - -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; - -/* - * Created by Christian Schabesberger on 12.02.17. - * - * Copyright (C) Christian Schabesberger 2016 - * ChannelInfoItemHolder .java is part of NewPipe. - * - * NewPipe is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * NewPipe is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with NewPipe. If not, see . - */ - -public class CommentsInfoItemHolder extends CommentsMiniInfoItemHolder { - public final TextView itemTitleView; - private final ImageView itemHeartView; - private final ImageView itemPinnedView; - - public CommentsInfoItemHolder(final InfoItemBuilder infoItemBuilder, final ViewGroup parent) { - super(infoItemBuilder, R.layout.list_comments_item, parent); - - itemTitleView = itemView.findViewById(R.id.itemTitleView); - itemHeartView = itemView.findViewById(R.id.detail_heart_image_view); - itemPinnedView = itemView.findViewById(R.id.detail_pinned_view); - } - - @Override - public void updateFromItem(final InfoItem infoItem, - final HistoryRecordManager historyRecordManager) { - super.updateFromItem(infoItem, historyRecordManager); - - if (!(infoItem instanceof CommentsInfoItem)) { - return; - } - final CommentsInfoItem item = (CommentsInfoItem) infoItem; - - itemTitleView.setText(item.getUploaderName()); - - itemHeartView.setVisibility(item.isHeartedByUploader() ? View.VISIBLE : View.GONE); - - itemPinnedView.setVisibility(item.isPinned() ? View.VISIBLE : View.GONE); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/info_list/holder/StreamInfoItemHolder.java b/app/src/main/java/org/schabi/newpipe/info_list/holder/StreamInfoItemHolder.java index a84c98404..80f62eed3 100644 --- a/app/src/main/java/org/schabi/newpipe/info_list/holder/StreamInfoItemHolder.java +++ b/app/src/main/java/org/schabi/newpipe/info_list/holder/StreamInfoItemHolder.java @@ -12,10 +12,6 @@ import org.schabi.newpipe.info_list.InfoItemBuilder; import org.schabi.newpipe.local.history.HistoryRecordManager; import org.schabi.newpipe.util.Localization; -import androidx.preference.PreferenceManager; - -import static org.schabi.newpipe.MainActivity.DEBUG; - /* * Created by Christian Schabesberger on 01.08.16. *

@@ -81,7 +77,9 @@ public class StreamInfoItemHolder extends StreamMiniInfoItemHolder { } } - final String uploadDate = getFormattedRelativeUploadDate(infoItem); + final String uploadDate = Localization.relativeTimeOrTextual(itemBuilder.getContext(), + infoItem.getUploadDate(), + infoItem.getTextualUploadDate()); if (!TextUtils.isEmpty(uploadDate)) { if (viewsAndDate.isEmpty()) { return uploadDate; @@ -92,20 +90,4 @@ public class StreamInfoItemHolder extends StreamMiniInfoItemHolder { return viewsAndDate; } - - private String getFormattedRelativeUploadDate(final StreamInfoItem infoItem) { - if (infoItem.getUploadDate() != null) { - String formattedRelativeTime = Localization - .relativeTime(infoItem.getUploadDate().offsetDateTime()); - - if (DEBUG && PreferenceManager.getDefaultSharedPreferences(itemBuilder.getContext()) - .getBoolean(itemBuilder.getContext() - .getString(R.string.show_original_time_ago_key), false)) { - formattedRelativeTime += " (" + infoItem.getTextualUploadDate() + ")"; - } - return formattedRelativeTime; - } else { - return infoItem.getTextualUploadDate(); - } - } } 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 07d0f516d..c2748f725 100644 --- a/app/src/main/java/org/schabi/newpipe/util/ExtractorHelper.java +++ b/app/src/main/java/org/schabi/newpipe/util/ExtractorHelper.java @@ -162,6 +162,15 @@ public final class ExtractorHelper { 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 c4034252d..0485413cc 100644 --- a/app/src/main/java/org/schabi/newpipe/util/Localization.java +++ b/app/src/main/java/org/schabi/newpipe/util/Localization.java @@ -1,5 +1,7 @@ package org.schabi.newpipe.util; +import static org.schabi.newpipe.MainActivity.DEBUG; + import android.annotation.SuppressLint; import android.content.Context; import android.content.SharedPreferences; @@ -22,6 +24,7 @@ import org.ocpsoft.prettytime.units.Decade; import org.schabi.newpipe.R; import org.schabi.newpipe.extractor.ListExtractor; import org.schabi.newpipe.extractor.localization.ContentCountry; +import org.schabi.newpipe.extractor.localization.DateWrapper; import org.schabi.newpipe.extractor.stream.AudioStream; import org.schabi.newpipe.extractor.stream.AudioTrackType; @@ -82,7 +85,7 @@ public final class Localization { .fromLocale(getPreferredLocale(context)); } - public static ContentCountry getPreferredContentCountry(final Context context) { + public static ContentCountry getPreferredContentCountry(@NonNull final Context context) { final String contentCountry = PreferenceManager.getDefaultSharedPreferences(context) .getString(context.getString(R.string.content_country_key), context.getString(R.string.default_localization_key)); @@ -92,41 +95,43 @@ public final class Localization { return new ContentCountry(contentCountry); } - public static Locale getPreferredLocale(final Context context) { + public static Locale getPreferredLocale(@NonNull final Context context) { return getLocaleFromPrefs(context, R.string.content_language_key); } - public static Locale getAppLocale(final Context context) { + public static Locale getAppLocale(@NonNull final Context context) { return getLocaleFromPrefs(context, R.string.app_language_key); } - public static String localizeNumber(final Context context, final long number) { + public static String localizeNumber(@NonNull final Context context, final long number) { return localizeNumber(context, (double) number); } - public static String localizeNumber(final Context context, final double number) { + public static String localizeNumber(@NonNull final Context context, final double number) { final NumberFormat nf = NumberFormat.getInstance(getAppLocale(context)); return nf.format(number); } - public static String formatDate(final OffsetDateTime offsetDateTime, final Context context) { + public static String formatDate(@NonNull final Context context, + @NonNull final OffsetDateTime offsetDateTime) { return DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM) .withLocale(getAppLocale(context)).format(offsetDateTime .atZoneSameInstant(ZoneId.systemDefault())); } @SuppressLint("StringFormatInvalid") - public static String localizeUploadDate(final Context context, - final OffsetDateTime offsetDateTime) { - return context.getString(R.string.upload_date_text, formatDate(offsetDateTime, context)); + public static String localizeUploadDate(@NonNull final Context context, + @NonNull final OffsetDateTime offsetDateTime) { + return context.getString(R.string.upload_date_text, formatDate(context, offsetDateTime)); } - public static String localizeViewCount(final Context context, final long viewCount) { + public static String localizeViewCount(@NonNull final Context context, final long viewCount) { return getQuantity(context, R.plurals.views, R.string.no_views, viewCount, localizeNumber(context, viewCount)); } - public static String localizeStreamCount(final Context context, final long streamCount) { + public static String localizeStreamCount(@NonNull final Context context, + final long streamCount) { switch ((int) streamCount) { case (int) ListExtractor.ITEM_COUNT_UNKNOWN: return ""; @@ -140,7 +145,8 @@ public final class Localization { } } - public static String localizeStreamCountMini(final Context context, final long streamCount) { + public static String localizeStreamCountMini(@NonNull final Context context, + final long streamCount) { switch ((int) streamCount) { case (int) ListExtractor.ITEM_COUNT_UNKNOWN: return ""; @@ -153,12 +159,13 @@ public final class Localization { } } - public static String localizeWatchingCount(final Context context, final long watchingCount) { + public static String localizeWatchingCount(@NonNull final Context context, + final long watchingCount) { return getQuantity(context, R.plurals.watching, R.string.no_one_watching, watchingCount, localizeNumber(context, watchingCount)); } - public static String shortCount(final Context context, final long count) { + public static String shortCount(@NonNull final Context context, final long count) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { return CompactDecimalFormat.getInstance(getAppLocale(context), CompactDecimalFormat.CompactStyle.SHORT).format(count); @@ -179,36 +186,58 @@ public final class Localization { } } - public static String listeningCount(final Context context, final long listeningCount) { + public static String listeningCount(@NonNull final Context context, final long listeningCount) { return getQuantity(context, R.plurals.listening, R.string.no_one_listening, listeningCount, shortCount(context, listeningCount)); } - public static String shortWatchingCount(final Context context, final long watchingCount) { + public static String shortWatchingCount(@NonNull final Context context, + final long watchingCount) { return getQuantity(context, R.plurals.watching, R.string.no_one_watching, watchingCount, shortCount(context, watchingCount)); } - public static String shortViewCount(final Context context, final long viewCount) { + public static String shortViewCount(@NonNull final Context context, final long viewCount) { return getQuantity(context, R.plurals.views, R.string.no_views, viewCount, shortCount(context, viewCount)); } - public static String shortSubscriberCount(final Context context, final long subscriberCount) { + public static String shortSubscriberCount(@NonNull final Context context, + final long subscriberCount) { return getQuantity(context, R.plurals.subscribers, R.string.no_subscribers, subscriberCount, shortCount(context, subscriberCount)); } - public static String downloadCount(final Context context, final int downloadCount) { + public static String downloadCount(@NonNull final Context context, final int downloadCount) { return getQuantity(context, R.plurals.download_finished_notification, 0, downloadCount, shortCount(context, downloadCount)); } - public static String deletedDownloadCount(final Context context, final int deletedCount) { + public static String deletedDownloadCount(@NonNull final Context context, + final int deletedCount) { return getQuantity(context, R.plurals.deleted_downloads_toast, 0, 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 + * @return if {@code likeCount} is smaller than {@code 0}, the string {@code "-"}, otherwise + * the result of calling {@link #shortCount(Context, long)} on the like count + */ + public static String likeCount(@NonNull final Context context, final int likeCount) { + if (likeCount < 0) { + return "-"; + } else { + return shortCount(context, likeCount); + } + } + public static String getDurationString(final long duration) { final String output; @@ -241,7 +270,8 @@ public final class Localization { * @return duration in a human readable string. */ @NonNull - public static String localizeDuration(final Context context, final int durationInSecs) { + public static String localizeDuration(@NonNull final Context context, + final int durationInSecs) { if (durationInSecs < 0) { throw new IllegalArgumentException("duration can not be negative"); } @@ -278,7 +308,7 @@ public final class Localization { * @param track an {@link AudioStream} of the track * @return the localized name of the audio track */ - public static String audioTrackName(final Context context, final AudioStream track) { + public static String audioTrackName(@NonNull final Context context, final AudioStream track) { final String name; if (track.getAudioLocale() != null) { name = track.getAudioLocale().getDisplayLanguage(getAppLocale(context)); @@ -298,7 +328,8 @@ public final class Localization { } @Nullable - private static String audioTrackType(final Context context, final AudioTrackType trackType) { + private static String audioTrackType(@NonNull final Context context, + final AudioTrackType trackType) { switch (trackType) { case ORIGINAL: return context.getString(R.string.audio_track_type_original); @@ -314,20 +345,45 @@ public final class Localization { // Pretty Time //////////////////////////////////////////////////////////////////////////*/ - public static void initPrettyTime(final PrettyTime time) { + public static void initPrettyTime(@NonNull final PrettyTime time) { prettyTime = time; // Do not use decades as YouTube doesn't either. prettyTime.removeUnit(Decade.class); } - public static PrettyTime resolvePrettyTime(final Context context) { + public static PrettyTime resolvePrettyTime(@NonNull final Context context) { return new PrettyTime(getAppLocale(context)); } - public static String relativeTime(final OffsetDateTime offsetDateTime) { + public static String relativeTime(@NonNull final OffsetDateTime offsetDateTime) { return prettyTime.formatUnrounded(offsetDateTime); } + /** + * @param context the Android context; if {@code null} then even if in debug mode and the + * setting is enabled, {@code textual} will not be shown next to {@code parsed} + * @param parsed the textual date or time ago parsed by NewPipeExtractor, or {@code null} if + * the extractor could not parse it + * @param textual the original textual date or time ago string as provided by services + * @return {@link #relativeTime(OffsetDateTime)} is used if {@code parsed != null}, otherwise + * {@code textual} is returned. If in debug mode, {@code context != null}, + * {@code parsed != null} and the relevant setting is enabled, {@code textual} will + * be appended to the returned string for debugging purposes. + */ + public static String relativeTimeOrTextual(@Nullable final Context context, + @Nullable final DateWrapper parsed, + final String textual) { + if (parsed == null) { + return textual; + } else if (DEBUG && context != null && PreferenceManager + .getDefaultSharedPreferences(context) + .getBoolean(context.getString(R.string.show_original_time_ago_key), false)) { + return relativeTime(parsed.offsetDateTime()) + " (" + textual + ")"; + } else { + return relativeTime(parsed.offsetDateTime()); + } + } + public static void assureCorrectAppLanguage(final Context c) { final Resources res = c.getResources(); final DisplayMetrics dm = res.getDisplayMetrics(); @@ -336,7 +392,8 @@ public final class Localization { res.updateConfiguration(conf, dm); } - private static Locale getLocaleFromPrefs(final Context context, @StringRes final int prefKey) { + private static Locale getLocaleFromPrefs(@NonNull final Context context, + @StringRes final int prefKey) { final SharedPreferences sp = PreferenceManager.getDefaultSharedPreferences(context); final String defaultKey = context.getString(R.string.default_localization_key); final String languageCode = sp.getString(context.getString(prefKey), defaultKey); @@ -352,8 +409,10 @@ public final class Localization { return new BigDecimal(value).setScale(1, RoundingMode.HALF_UP).doubleValue(); } - private static String getQuantity(final Context context, @PluralsRes final int pluralId, - @StringRes final int zeroCaseStringId, final long count, + private static String getQuantity(@NonNull final Context context, + @PluralsRes final int pluralId, + @StringRes final int zeroCaseStringId, + final long count, final String formattedCount) { if (count == 0) { return context.getString(zeroCaseStringId); 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 b0d7dcf73..5dee32371 100644 --- a/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java +++ b/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java @@ -1,5 +1,6 @@ package org.schabi.newpipe.util; +import static android.text.TextUtils.isEmpty; import static org.schabi.newpipe.util.ListHelper.getUrlAndNonTorrentStreams; import android.annotation.SuppressLint; @@ -17,6 +18,7 @@ import androidx.appcompat.app.AlertDialog; import androidx.appcompat.app.AppCompatActivity; import androidx.core.content.ContextCompat; import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentActivity; import androidx.fragment.app.FragmentManager; import androidx.fragment.app.FragmentTransaction; @@ -29,8 +31,10 @@ import org.schabi.newpipe.RouterActivity; import org.schabi.newpipe.about.AboutActivity; import org.schabi.newpipe.database.feed.model.FeedGroupEntity; import org.schabi.newpipe.download.DownloadActivity; +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.stream.AudioStream; import org.schabi.newpipe.extractor.stream.DeliveryMethod; @@ -41,6 +45,7 @@ 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; @@ -476,6 +481,35 @@ public final class NavigationHelper { item.getServiceId(), uploaderUrl, item.getUploaderName()); } + /** + * 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 comment the comment whose uploader/author will be opened + */ + public static void openCommentAuthorIfPresent(@NonNull final FragmentActivity activity, + @NonNull final CommentsInfoItem comment) { + if (isEmpty(comment.getUploaderUrl())) { + return; + } + try { + openChannelFragment(activity.getSupportFragmentManager(), comment.getServiceId(), + comment.getUploaderUrl(), comment.getUploaderName()); + } catch (final Exception e) { + ErrorUtil.showUiErrorSnackbar(activity, "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/RelatedItemInfo.java b/app/src/main/java/org/schabi/newpipe/util/RelatedItemInfo.java deleted file mode 100644 index f96bb0d54..000000000 --- a/app/src/main/java/org/schabi/newpipe/util/RelatedItemInfo.java +++ /dev/null @@ -1,27 +0,0 @@ -package org.schabi.newpipe.util; - -import org.schabi.newpipe.extractor.InfoItem; -import org.schabi.newpipe.extractor.ListInfo; -import org.schabi.newpipe.extractor.linkhandler.ListLinkHandler; -import org.schabi.newpipe.extractor.stream.StreamInfo; - -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; - -public class RelatedItemInfo extends ListInfo { - public RelatedItemInfo(final int serviceId, final ListLinkHandler listUrlIdHandler, - final String name) { - super(serviceId, listUrlIdHandler, name); - } - - public static RelatedItemInfo getInfo(final StreamInfo info) { - final ListLinkHandler handler = new ListLinkHandler( - info.getOriginalUrl(), info.getUrl(), info.getId(), Collections.emptyList(), null); - final RelatedItemInfo relatedItemInfo = new RelatedItemInfo( - info.getServiceId(), handler, info.getName()); - final List relatedItems = new ArrayList<>(info.getRelatedItems()); - relatedItemInfo.setRelatedItems(relatedItems); - return relatedItemInfo; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/util/ServiceHelper.java b/app/src/main/java/org/schabi/newpipe/util/ServiceHelper.java index acd019ba0..c712157b3 100644 --- a/app/src/main/java/org/schabi/newpipe/util/ServiceHelper.java +++ b/app/src/main/java/org/schabi/newpipe/util/ServiceHelper.java @@ -144,6 +144,19 @@ public final class ServiceHelper { .orElse(""); } + /** + * @param serviceId the id of the service + * @return the service corresponding to the provided id + * @throws java.util.NoSuchElementException if there is no service with the provided id + */ + @NonNull + public static StreamingService getServiceById(final int serviceId) { + return ServiceList.all().stream() + .filter(s -> s.getServiceId() == serviceId) + .findFirst() + .orElseThrow(); + } + public static void setSelectedServiceId(final Context context, final int serviceId) { String serviceName; try { diff --git a/app/src/main/res/layout/comment_replies_header.xml b/app/src/main/res/layout/comment_replies_header.xml new file mode 100644 index 000000000..ed5ba1a10 --- /dev/null +++ b/app/src/main/res/layout/comment_replies_header.xml @@ -0,0 +1,137 @@ + + + + + + + + + + + + + + + + + + + + + + + + \ 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 index b1b644d8c..2a8c747cd 100644 --- a/app/src/main/res/layout/fragment_comments.xml +++ b/app/src/main/res/layout/fragment_comments.xml @@ -9,7 +9,7 @@ android:layout_width="match_parent" android:layout_height="match_parent" android:scrollbars="vertical" - tools:listitem="@layout/list_comments_item" /> + tools:listitem="@layout/list_comment_item" /> + android:src="@drawable/ic_pin" /> + tools:text="Author Name, Lorem ipsum • 5 months ago" /> - + tools:text="@tools:sample/lorem/random[1]" /> + android:src="@drawable/ic_heart" /> - + android:layout_alignParentEnd="true" + android:layout_marginStart="@dimen/video_item_detail_heart_margin" + android:minHeight="0dp" + tools:text="543 replies" /> diff --git a/app/src/main/res/layout/list_comments_mini_item.xml b/app/src/main/res/layout/list_comments_mini_item.xml deleted file mode 100644 index 606a237c5..000000000 --- a/app/src/main/res/layout/list_comments_mini_item.xml +++ /dev/null @@ -1,69 +0,0 @@ - - - - - - - - - - - - - - - diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 435e9a382..d94abfe70 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -838,4 +838,8 @@ Share URL list - %1$s: %2$s %1$s\n%2$s + + %s reply + %s replies +