Merge pull request #11060 from Isira-Seneviratne/Comments-Compose

Migrate comment fragments to Jetpack Compose
This commit is contained in:
Stypox 2024-11-11 16:12:53 +01:00 committed by GitHub
commit 62ab9bd740
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
38 changed files with 1181 additions and 1110 deletions

1
.gitignore vendored
View File

@ -10,6 +10,7 @@ captures/
*.class
app/debug/
app/release/
.kotlin/
# vscode / eclipse files
*.classpath

View File

@ -223,7 +223,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'
@ -293,18 +293,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'

View File

@ -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 {
* </pre>
*/
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<FragmentContainerView> 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<FrameLayout> bottomSheetBehavior =
BottomSheetBehavior.from(mainBinding.fragmentPlayerHolder);

View File

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

View File

@ -74,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;
@ -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
//////////////////////////////////////////////////////////////////////////*/

View File

@ -1,171 +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 com.evernote.android.state.State;
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 io.reactivex.rxjava3.core.Single;
import io.reactivex.rxjava3.disposables.CompositeDisposable;
public final class CommentRepliesFragment
extends BaseListInfoFragment<CommentsInfoItem, CommentRepliesInfo> {
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<View> 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<Object> objectsToSave) {
super.writeTo(objectsToSave);
objectsToSave.add(commentsInfoItem);
}
@Override
public void readFrom(@NonNull final Queue<Object> savedObjects) throws Exception {
super.readFrom(savedObjects);
commentsInfoItem = (CommentsInfoItem) savedObjects.poll();
}
/*//////////////////////////////////////////////////////////////////////////
// Data loading
//////////////////////////////////////////////////////////////////////////*/
@Override
protected Single<CommentRepliesInfo> 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<ListExtractor.InfoItemsPage<CommentsInfoItem>> loadMoreItemsLogic() {
// commentsInfoItem.getUrl() should contain the url of the original
// ListInfo<CommentsInfoItem>, 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;
}
}

View File

@ -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<CommentsInfoItem> {
/**
* 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
}
}

View File

@ -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<CommentsInfoItem, CommentsInfo> {
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<ListExtractor.InfoItemsPage<CommentsInfoItem>> loadMoreItemsLogic() {
return ExtractorHelper.getMoreCommentItems(serviceId, currentInfo, currentNextPage);
}
@Override
protected Single<CommentsInfo> 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;
}
}

View File

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

View File

@ -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<StreamInfo>(KEY_INFO)!!)
}
}
) = content {
AppTheme {
Surface(color = MaterialTheme.colorScheme.background) {
RelatedItems(requireArguments().serializable<StreamInfo>(KEY_INFO)!!)
}
}
}

View File

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

View File

@ -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<RecyclerView.ViewHolde
Log.d(TAG, "onCreateViewHolder() called with: "
+ "parent = [" + parent + "], type = [" + type + "]");
}
switch (type) {
return switch (type) {
// #4475 and #3368
// Always create a new instance otherwise the same instance
// is sometimes reused which causes a crash
case HEADER_TYPE:
return new HFHolder(headerSupplier.get());
case FOOTER_TYPE:
return new HFHolder(PignateFooterBinding
.inflate(layoutInflater, parent, false)
.getRoot()
);
case MINI_STREAM_HOLDER_TYPE:
return new StreamMiniInfoItemHolder(infoItemBuilder, parent);
case STREAM_HOLDER_TYPE:
return new StreamInfoItemHolder(infoItemBuilder, parent);
case GRID_STREAM_HOLDER_TYPE:
return new StreamGridInfoItemHolder(infoItemBuilder, parent);
case CARD_STREAM_HOLDER_TYPE:
return new StreamCardInfoItemHolder(infoItemBuilder, parent);
case MINI_CHANNEL_HOLDER_TYPE:
return new ChannelMiniInfoItemHolder(infoItemBuilder, parent);
case CHANNEL_HOLDER_TYPE:
return new ChannelInfoItemHolder(infoItemBuilder, parent);
case CARD_CHANNEL_HOLDER_TYPE:
return new ChannelCardInfoItemHolder(infoItemBuilder, parent);
case GRID_CHANNEL_HOLDER_TYPE:
return new ChannelGridInfoItemHolder(infoItemBuilder, parent);
case MINI_PLAYLIST_HOLDER_TYPE:
return new PlaylistMiniInfoItemHolder(infoItemBuilder, parent);
case PLAYLIST_HOLDER_TYPE:
return new PlaylistInfoItemHolder(infoItemBuilder, parent);
case GRID_PLAYLIST_HOLDER_TYPE:
return new PlaylistGridInfoItemHolder(infoItemBuilder, parent);
case CARD_PLAYLIST_HOLDER_TYPE:
return new PlaylistCardInfoItemHolder(infoItemBuilder, parent);
case COMMENT_HOLDER_TYPE:
return new CommentInfoItemHolder(infoItemBuilder, parent);
default:
return new FallbackViewHolder(new View(parent.getContext()));
}
case HEADER_TYPE -> new HFHolder(headerSupplier.get());
case FOOTER_TYPE -> new HFHolder(PignateFooterBinding
.inflate(layoutInflater, parent, false)
.getRoot()
);
case MINI_STREAM_HOLDER_TYPE -> new StreamMiniInfoItemHolder(infoItemBuilder, parent);
case STREAM_HOLDER_TYPE -> new StreamInfoItemHolder(infoItemBuilder, parent);
case GRID_STREAM_HOLDER_TYPE -> new StreamGridInfoItemHolder(infoItemBuilder, parent);
case CARD_STREAM_HOLDER_TYPE -> new StreamCardInfoItemHolder(infoItemBuilder, parent);
case MINI_CHANNEL_HOLDER_TYPE -> new ChannelMiniInfoItemHolder(infoItemBuilder, parent);
case CHANNEL_HOLDER_TYPE -> new ChannelInfoItemHolder(infoItemBuilder, parent);
case CARD_CHANNEL_HOLDER_TYPE -> new ChannelCardInfoItemHolder(infoItemBuilder, parent);
case GRID_CHANNEL_HOLDER_TYPE -> new ChannelGridInfoItemHolder(infoItemBuilder, parent);
case MINI_PLAYLIST_HOLDER_TYPE ->
new PlaylistMiniInfoItemHolder(infoItemBuilder, parent);
case PLAYLIST_HOLDER_TYPE -> new PlaylistInfoItemHolder(infoItemBuilder, parent);
case GRID_PLAYLIST_HOLDER_TYPE ->
new PlaylistGridInfoItemHolder(infoItemBuilder, parent);
case CARD_PLAYLIST_HOLDER_TYPE ->
new PlaylistCardInfoItemHolder(infoItemBuilder, parent);
default -> new FallbackViewHolder(new View(parent.getContext()));
};
}
@Override

View File

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

View File

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

View File

@ -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<Page, CommentsInfoItem>() {
private val service = NewPipe.getService(commentInfo.serviceId)
override suspend fun load(params: LoadParams<Page>): LoadResult<Page, CommentsInfoItem> {
// 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<Page, CommentsInfoItem>) = null
}

View File

@ -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<Page, CommentsInfoItem>() {
private val service = NewPipe.getService(commentInfo.serviceId)
override suspend fun load(params: LoadParams<Page>): LoadResult<Page, CommentsInfoItem> {
// 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<Page, CommentsInfoItem>) = null
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<CommentsInfoItem>(
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!<br><br>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!<br><br>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) {}
}
}
}
}
}

View File

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

View File

@ -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<PagingData<CommentsInfoItem>>,
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 = {})
}
}

View File

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

View File

@ -0,0 +1,179 @@
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<CommentInfo>,
commentsFlow: Flow<PagingData<CommentsInfoItem>>
) {
val comments = commentsFlow.collectAsLazyPagingItems()
val nestedScrollInterop = rememberNestedScrollInteropConnection()
val state = rememberLazyListState()
Surface(color = MaterialTheme.colorScheme.background) {
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())
}
}
}

View File

@ -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<CommentsInfo> 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<InfoItemsPage<CommentsInfoItem>> 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<InfoItemsPage<CommentsInfoItem>> 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<PlaylistInfo> getPlaylistInfo(final int serviceId,
final String url,
final boolean forceLoad) {

View File

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

View File

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

View File

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

View File

@ -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<Resource.Success<CommentInfo>>()
.flatMapLatest {
Pager(PagingConfig(pageSize = 20, enablePlaceholders = false)) {
CommentsSource(it.data)
}.flow
}
.cachedIn(viewModelScope)
}

View File

@ -0,0 +1,7 @@
package org.schabi.newpipe.viewmodels.util
sealed class Resource<out T> {
data object Loading : Resource<Nothing>()
class Success<T>(val data: T) : Resource<T>()
class Error(val throwable: Throwable) : Resource<Nothing>()
}

View File

@ -1,137 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/authorAvatar"
android:layout_width="42dp"
android:layout_height="42dp"
android:layout_marginStart="16dp"
android:layout_marginTop="16dp"
android:focusable="false"
android:src="@drawable/placeholder_person"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:shapeAppearance="@style/CircularImageView"
tools:ignore="RtlHardcoded" />
<org.schabi.newpipe.views.NewPipeTextView
android:id="@+id/authorName"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginEnd="8dp"
android:ellipsize="end"
android:lines="1"
android:textAppearance="?android:attr/textAppearanceLarge"
android:textSize="16sp"
app:layout_constraintBottom_toTopOf="@+id/uploadDate"
app:layout_constraintEnd_toStartOf="@+id/thumbsUpImage"
app:layout_constraintStart_toEndOf="@+id/authorAvatar"
app:layout_constraintTop_toTopOf="@+id/authorAvatar"
tools:text="@tools:sample/lorem/random" />
<org.schabi.newpipe.views.NewPipeTextView
android:id="@+id/uploadDate"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginEnd="8dp"
android:ellipsize="end"
android:lines="1"
android:textAppearance="?android:attr/textAppearanceSmall"
android:textSize="14sp"
app:layout_constraintBottom_toBottomOf="@+id/authorAvatar"
app:layout_constraintEnd_toStartOf="@+id/thumbsUpImage"
app:layout_constraintStart_toEndOf="@+id/authorAvatar"
app:layout_constraintTop_toBottomOf="@+id/authorName"
tools:text="5 months ago" />
<ImageView
android:id="@+id/thumbsUpImage"
android:layout_width="21sp"
android:layout_height="21sp"
android:layout_marginEnd="@dimen/video_item_detail_like_margin"
android:contentDescription="@string/detail_likes_img_view_description"
android:src="@drawable/ic_thumb_up"
app:layout_constraintBottom_toBottomOf="@+id/authorAvatar"
app:layout_constraintEnd_toStartOf="@+id/thumbsUpCount"
app:layout_constraintTop_toTopOf="@+id/authorAvatar" />
<org.schabi.newpipe.views.NewPipeTextView
android:id="@+id/thumbsUpCount"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:gravity="center"
android:lines="1"
android:textAppearance="?android:attr/textAppearanceMedium"
android:textSize="14sp"
app:layout_constraintBottom_toBottomOf="@+id/authorAvatar"
app:layout_constraintEnd_toStartOf="@+id/heartImage"
app:layout_constraintTop_toTopOf="@+id/authorAvatar"
tools:text="12M" />
<ImageView
android:id="@+id/heartImage"
android:layout_width="21sp"
android:layout_height="21sp"
android:layout_marginEnd="4dp"
android:contentDescription="@string/detail_heart_img_view_description"
android:src="@drawable/ic_heart"
app:layout_constraintBottom_toBottomOf="@+id/authorAvatar"
app:layout_constraintEnd_toStartOf="@+id/pinnedImage"
app:layout_constraintTop_toTopOf="@+id/authorAvatar"
app:layout_goneMarginEnd="16dp" />
<View
android:id="@+id/authorTouchArea"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_margin="8dp"
android:background="?attr/selectableItemBackground"
app:layout_constraintBottom_toTopOf="@+id/commentContent"
app:layout_constraintEnd_toStartOf="@+id/thumbsUpImage"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<ImageView
android:id="@+id/pinnedImage"
android:layout_width="21sp"
android:layout_height="21sp"
android:layout_marginEnd="16dp"
android:contentDescription="@string/detail_pinned_comment_view_description"
android:src="@drawable/ic_pin"
app:layout_constraintBottom_toBottomOf="@+id/authorAvatar"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@+id/authorAvatar" />
<org.schabi.newpipe.views.NewPipeTextView
android:id="@+id/commentContent"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="16dp"
android:layout_marginEnd="16dp"
android:textAppearance="?android:attr/textAppearanceLarge"
android:textSize="14sp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/authorAvatar"
tools:text="@tools:sample/lorem/random[10]" />
<View
android:layout_width="match_parent"
android:layout_height="1px"
android:layout_marginTop="16dp"
android:layout_marginBottom="8dp"
android:background="?attr/separator_color"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/commentContent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -1,71 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<org.schabi.newpipe.views.NewPipeRecyclerView
android:id="@+id/items_list"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scrollbars="vertical"
tools:listitem="@layout/list_comment_item" />
<ProgressBar
android:id="@+id/loading_progress_bar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerInParent="true"
android:indeterminate="true"
android:visibility="gone"
tools:visibility="visible" />
<LinearLayout
android:id="@+id/empty_state_view"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerHorizontal="true"
android:orientation="vertical"
android:paddingTop="85dp"
android:visibility="gone"
tools:visibility="visible">
<org.schabi.newpipe.views.NewPipeTextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_marginBottom="10dp"
android:fontFamily="monospace"
android:text="(╯°-°)╯"
android:textSize="35sp"
tools:ignore="HardcodedText,UnusedAttribute" />
<org.schabi.newpipe.views.NewPipeTextView
android:id="@+id/empty_state_desc"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:text="@string/empty_view_no_comments"
android:textSize="24sp" />
</LinearLayout>
<!--ERROR PANEL-->
<include
android:id="@+id/error_panel"
layout="@layout/error_panel"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerHorizontal="true"
android:layout_marginTop="16dp"
android:visibility="gone"
tools:visibility="visible" />
<View
android:layout_width="match_parent"
android:layout_height="4dp"
android:layout_alignParentTop="true"
android:background="?attr/toolbar_shadow"
android:visibility="gone" />
</RelativeLayout>

View File

@ -1,104 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/itemRoot"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackground"
android:clickable="true"
android:focusable="true"
android:padding="@dimen/comments_vertical_padding">
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/itemThumbnailView"
android:layout_width="42dp"
android:layout_height="42dp"
android:layout_alignParentStart="true"
android:layout_alignParentTop="true"
android:layout_marginEnd="@dimen/comment_item_avatar_right_margin"
android:focusable="false"
android:src="@drawable/placeholder_person"
app:shapeAppearance="@style/CircularImageView"
tools:ignore="RtlHardcoded" />
<ImageView
android:id="@+id/detail_pinned_view"
android:layout_width="@dimen/video_item_detail_pinned_image_width"
android:layout_height="@dimen/video_item_detail_pinned_image_height"
android:layout_alignParentTop="true"
android:layout_marginEnd="@dimen/video_item_detail_pinned_right_margin"
android:layout_toEndOf="@+id/itemThumbnailView"
android:contentDescription="@string/detail_pinned_comment_view_description"
android:src="@drawable/ic_pin" />
<org.schabi.newpipe.views.NewPipeTextView
android:id="@+id/itemTitleView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_alignParentTop="true"
android:layout_toEndOf="@+id/detail_pinned_view"
android:ellipsize="end"
android:lines="1"
android:textAppearance="?android:attr/textAppearanceSmall"
android:textSize="@dimen/comment_item_title_text_size"
tools:text="Author Name, Lorem ipsum • 5 months ago" />
<org.schabi.newpipe.views.NewPipeTextView
android:id="@+id/itemCommentContentView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_below="@id/itemTitleView"
android:layout_marginTop="6dp"
android:layout_toEndOf="@+id/itemThumbnailView"
android:textAppearance="?android:attr/textAppearanceLarge"
android:textSize="@dimen/comment_item_content_text_size"
tools:text="@tools:sample/lorem/random[1]" />
<ImageView
android:id="@+id/detail_thumbs_up_img_view"
android:layout_width="@dimen/video_item_detail_like_image_width"
android:layout_height="@dimen/video_item_detail_like_image_height"
android:layout_below="@id/itemCommentContentView"
android:layout_alignBottom="@+id/replies_button"
android:layout_toEndOf="@+id/itemThumbnailView"
android:contentDescription="@string/detail_likes_img_view_description"
android:src="@drawable/ic_thumb_up" />
<org.schabi.newpipe.views.NewPipeTextView
android:id="@+id/detail_thumbs_up_count_view"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignTop="@id/detail_thumbs_up_img_view"
android:layout_alignBottom="@id/detail_thumbs_up_img_view"
android:layout_marginStart="@dimen/video_item_detail_like_margin"
android:layout_toEndOf="@id/detail_thumbs_up_img_view"
android:gravity="center"
android:lines="1"
android:textAppearance="?android:attr/textAppearanceMedium"
android:textSize="@dimen/video_item_detail_likes_text_size"
tools:text="12M" />
<ImageView
android:id="@+id/detail_heart_image_view"
android:layout_width="@dimen/video_item_detail_heart_image_size"
android:layout_height="@dimen/video_item_detail_heart_image_size"
android:layout_alignTop="@id/detail_thumbs_up_img_view"
android:layout_alignBottom="@id/detail_thumbs_up_img_view"
android:layout_marginStart="@dimen/video_item_detail_heart_margin"
android:layout_toEndOf="@+id/detail_thumbs_up_count_view"
android:contentDescription="@string/detail_heart_img_view_description"
android:src="@drawable/ic_heart" />
<Button
android:id="@+id/replies_button"
style="?android:attr/borderlessButtonStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@id/itemCommentContentView"
android:layout_alignParentEnd="true"
android:layout_marginStart="@dimen/video_item_detail_heart_margin"
android:minHeight="0dp"
tools:text="543 replies" />
</RelativeLayout>

View File

@ -857,4 +857,8 @@
<string name="show_less">Show less</string>
<string name="import_settings_vulnerable_format">The settings in the export being imported use a vulnerable format that was deprecated since NewPipe 0.27.0. Make sure the export being imported is from a trusted source, and prefer using only exports obtained from NewPipe 0.27.0 or newer in the future. Support for importing settings in this vulnerable format will soon be removed completely, and then old versions of NewPipe will not be able to import settings of exports from new versions anymore.</string>
<string name="auto_queue_description">Next</string>
<plurals name="comments">
<item quantity="one">%d comment</item>
<item quantity="other">%d comments</item>
</plurals>
</resources>