From 44d0a3f91a22e630252aa26078f350f4588570ad Mon Sep 17 00:00:00 2001 From: TobiGr Date: Tue, 20 Jun 2023 15:56:43 +0200 Subject: [PATCH] Ellipsize playlist description if it is longer than 5 lines The description can be expanded / collapsed via a "show more" / "show less" button. --- .../list/playlist/PlaylistFragment.java | 35 +++- .../holder/CommentInfoItemHolder.java | 123 ++--------- .../newpipe/util/text/TextEllipsizer.java | 195 ++++++++++++++++++ app/src/main/res/layout/playlist_header.xml | 17 +- app/src/main/res/values/strings.xml | 2 + 5 files changed, 256 insertions(+), 116 deletions(-) create mode 100644 app/src/main/java/org/schabi/newpipe/util/text/TextEllipsizer.java diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/playlist/PlaylistFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/playlist/PlaylistFragment.java index 95cead38997..8ef97f79b12 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/playlist/PlaylistFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/playlist/PlaylistFragment.java @@ -3,7 +3,6 @@ import static org.schabi.newpipe.extractor.utils.Utils.isBlank; import static org.schabi.newpipe.ktx.ViewUtils.animate; import static org.schabi.newpipe.ktx.ViewUtils.animateHideRecyclerViewAllowingScrolling; -import static org.schabi.newpipe.util.text.TextLinkifier.SET_LINK_MOVEMENT_METHOD; import android.content.Context; import android.os.Bundle; @@ -19,7 +18,6 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.content.res.AppCompatResources; -import androidx.core.text.HtmlCompat; import com.google.android.material.shape.CornerFamily; import com.google.android.material.shape.ShapeAppearanceModel; @@ -37,7 +35,10 @@ import org.schabi.newpipe.error.UserAction; import org.schabi.newpipe.extractor.InfoItem; import org.schabi.newpipe.extractor.ListExtractor; +import org.schabi.newpipe.extractor.NewPipe; import org.schabi.newpipe.extractor.ServiceList; +import org.schabi.newpipe.extractor.StreamingService; +import org.schabi.newpipe.extractor.exceptions.ExtractionException; import org.schabi.newpipe.extractor.playlist.PlaylistInfo; import org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper; import org.schabi.newpipe.extractor.stream.Description; @@ -55,7 +56,7 @@ import org.schabi.newpipe.util.image.PicassoHelper; import org.schabi.newpipe.util.external_communication.ShareUtils; import org.schabi.newpipe.util.PlayButtonHelper; -import org.schabi.newpipe.util.text.TextLinkifier; +import org.schabi.newpipe.util.text.TextEllipsizer; import java.util.ArrayList; import java.util.List; @@ -326,16 +327,34 @@ public void handleResult(@NonNull final PlaylistInfo result) { headerBinding.playlistStreamCount.setText(Localization .localizeStreamCount(getContext(), result.getStreamCount())); + StreamingService service; + try { + service = NewPipe.getService(result.getServiceId()); + } catch (final ExtractionException e) { + service = ServiceList.YouTube; + } + final Description description = result.getDescription(); if (description != null && description != Description.EMPTY_DESCRIPTION && !isBlank(description.getContent())) { - TextLinkifier.fromDescription(headerBinding.playlistDescription, - description, HtmlCompat.FROM_HTML_MODE_LEGACY, - result.getService(), result.getUrl(), - disposables, SET_LINK_MOVEMENT_METHOD); - headerBinding.playlistDescription.setVisibility(View.VISIBLE); + final TextEllipsizer ellipsizer = new TextEllipsizer( + headerBinding.playlistDescription, 5, service); + ellipsizer.setStateChangeListener(isEllipsized -> + headerBinding.playlistDescriptionReadMore.setText( + Boolean.TRUE.equals(isEllipsized) ? R.string.show_more : R.string.show_less + )); + ellipsizer.setOnContentChanged(canBeEllipsized -> { + headerBinding.playlistDescriptionReadMore.setVisibility( + Boolean.TRUE.equals(canBeEllipsized) ? View.VISIBLE : View.GONE); + if (Boolean.TRUE.equals(canBeEllipsized)) { + ellipsizer.ellipsize(); + } + }); + ellipsizer.setContent(description); + headerBinding.playlistDescriptionReadMore.setOnClickListener(v -> ellipsizer.toggle()); } else { headerBinding.playlistDescription.setVisibility(View.GONE); + headerBinding.playlistDescriptionReadMore.setVisibility(View.GONE); } if (!result.getErrors().isEmpty()) { diff --git a/app/src/main/java/org/schabi/newpipe/info_list/holder/CommentInfoItemHolder.java b/app/src/main/java/org/schabi/newpipe/info_list/holder/CommentInfoItemHolder.java index 8327b398ba3..a3f0384ad40 100644 --- a/app/src/main/java/org/schabi/newpipe/info_list/holder/CommentInfoItemHolder.java +++ b/app/src/main/java/org/schabi/newpipe/info_list/holder/CommentInfoItemHolder.java @@ -1,10 +1,7 @@ package org.schabi.newpipe.info_list.holder; -import static android.text.TextUtils.isEmpty; import static org.schabi.newpipe.util.ServiceHelper.getServiceById; -import android.graphics.Paint; -import android.text.Layout; import android.text.method.LinkMovementMethod; import android.text.style.URLSpan; import android.view.View; @@ -15,42 +12,28 @@ import android.widget.TextView; import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.core.text.HtmlCompat; import androidx.fragment.app.FragmentActivity; import org.schabi.newpipe.R; import org.schabi.newpipe.extractor.InfoItem; -import org.schabi.newpipe.extractor.StreamingService; import org.schabi.newpipe.extractor.comments.CommentsInfoItem; -import org.schabi.newpipe.extractor.stream.Description; import org.schabi.newpipe.info_list.InfoItemBuilder; import org.schabi.newpipe.local.history.HistoryRecordManager; import org.schabi.newpipe.util.DeviceUtils; import org.schabi.newpipe.util.Localization; import org.schabi.newpipe.util.NavigationHelper; +import org.schabi.newpipe.util.external_communication.ShareUtils; import org.schabi.newpipe.util.image.ImageStrategy; import org.schabi.newpipe.util.image.PicassoHelper; -import org.schabi.newpipe.util.external_communication.ShareUtils; import org.schabi.newpipe.util.text.CommentTextOnTouchListener; -import org.schabi.newpipe.util.text.TextLinkifier; - -import java.util.function.Consumer; - -import io.reactivex.rxjava3.disposables.CompositeDisposable; +import org.schabi.newpipe.util.text.TextEllipsizer; public class CommentInfoItemHolder extends InfoItemHolder { - private static final String ELLIPSIS = "…"; private static final int COMMENT_DEFAULT_LINES = 2; - private static final int COMMENT_EXPANDED_LINES = 1000; - private final int commentHorizontalPadding; private final int commentVerticalPadding; - private final Paint paintAtContentSize; - private final float ellipsisWidthPx; - private final RelativeLayout itemRoot; private final ImageView itemThumbnailView; private final TextView itemContentView; @@ -61,13 +44,8 @@ public class CommentInfoItemHolder extends InfoItemHolder { private final ImageView itemPinnedView; private final Button repliesButton; - private final CompositeDisposable disposables = new CompositeDisposable(); - @Nullable - private Description commentText; - @Nullable - private StreamingService streamService; - @Nullable - private String streamUrl; + @NonNull + private final TextEllipsizer textEllipsizer; public CommentInfoItemHolder(final InfoItemBuilder infoItemBuilder, final ViewGroup parent) { @@ -88,9 +66,14 @@ public CommentInfoItemHolder(final InfoItemBuilder infoItemBuilder, commentVerticalPadding = (int) infoItemBuilder.getContext() .getResources().getDimension(R.dimen.comments_vertical_padding); - paintAtContentSize = new Paint(); - paintAtContentSize.setTextSize(itemContentView.getTextSize()); - ellipsisWidthPx = paintAtContentSize.measureText(ELLIPSIS); + textEllipsizer = new TextEllipsizer(itemContentView, COMMENT_DEFAULT_LINES, null); + textEllipsizer.setStateChangeListener(isEllipsized -> { + if (Boolean.TRUE.equals(isEllipsized)) { + denyLinkFocus(); + } else { + determineMovementMethod(); + } + }); } @Override @@ -139,16 +122,16 @@ public void updateFromItem(final InfoItem infoItem, // setup comment content and click listeners to expand/ellipsize it - streamService = getServiceById(item.getServiceId()); - streamUrl = item.getUrl(); - commentText = item.getCommentText(); - ellipsize(); + textEllipsizer.setStreamingService(getServiceById(item.getServiceId())); + textEllipsizer.setStreamUrl(item.getUrl()); + textEllipsizer.setContent(item.getCommentText()); + textEllipsizer.ellipsize(); //noinspection ClickableViewAccessibility itemContentView.setOnTouchListener(CommentTextOnTouchListener.INSTANCE); itemView.setOnClickListener(view -> { - toggleEllipsize(); + textEllipsizer.toggle(); if (itemBuilder.getOnCommentsSelectedListener() != null) { itemBuilder.getOnCommentsSelectedListener().selected(item); } @@ -202,76 +185,4 @@ private void determineMovementMethod() { denyLinkFocus(); } } - - private void ellipsize() { - itemContentView.setMaxLines(COMMENT_EXPANDED_LINES); - linkifyCommentContentView(v -> { - boolean hasEllipsis = false; - - final CharSequence charSeqText = itemContentView.getText(); - if (charSeqText != null && itemContentView.getLineCount() > COMMENT_DEFAULT_LINES) { - // Note that converting to String removes spans (i.e. links), but that's something - // we actually want since when the text is ellipsized we want all clicks on the - // comment to expand the comment, not to open links. - final String text = charSeqText.toString(); - - final Layout layout = itemContentView.getLayout(); - final float lineWidth = layout.getLineWidth(COMMENT_DEFAULT_LINES - 1); - final float layoutWidth = layout.getWidth(); - final int lineStart = layout.getLineStart(COMMENT_DEFAULT_LINES - 1); - final int lineEnd = layout.getLineEnd(COMMENT_DEFAULT_LINES - 1); - - // remove characters up until there is enough space for the ellipsis - // (also summing 2 more pixels, just to be sure to avoid float rounding errors) - int end = lineEnd; - float removedCharactersWidth = 0.0f; - while (lineWidth - removedCharactersWidth + ellipsisWidthPx + 2.0f > layoutWidth - && end >= lineStart) { - end -= 1; - // recalculate each time to account for ligatures or other similar things - removedCharactersWidth = paintAtContentSize.measureText( - text.substring(end, lineEnd)); - } - - // remove trailing spaces and newlines - while (end > 0 && Character.isWhitespace(text.charAt(end - 1))) { - end -= 1; - } - - final String newVal = text.substring(0, end) + ELLIPSIS; - itemContentView.setText(newVal); - hasEllipsis = true; - } - - itemContentView.setMaxLines(COMMENT_DEFAULT_LINES); - if (hasEllipsis) { - denyLinkFocus(); - } else { - determineMovementMethod(); - } - }); - } - - private void toggleEllipsize() { - final CharSequence text = itemContentView.getText(); - if (!isEmpty(text) && text.charAt(text.length() - 1) == ELLIPSIS.charAt(0)) { - expand(); - } else if (itemContentView.getLineCount() > COMMENT_DEFAULT_LINES) { - ellipsize(); - } - } - - private void expand() { - itemContentView.setMaxLines(COMMENT_EXPANDED_LINES); - linkifyCommentContentView(v -> determineMovementMethod()); - } - - private void linkifyCommentContentView(@Nullable final Consumer onCompletion) { - disposables.clear(); - if (commentText != null) { - TextLinkifier.fromDescription(itemContentView, commentText, - HtmlCompat.FROM_HTML_MODE_LEGACY, streamService, streamUrl, disposables, - onCompletion); - } - } } diff --git a/app/src/main/java/org/schabi/newpipe/util/text/TextEllipsizer.java b/app/src/main/java/org/schabi/newpipe/util/text/TextEllipsizer.java new file mode 100644 index 00000000000..41084926b12 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/text/TextEllipsizer.java @@ -0,0 +1,195 @@ +package org.schabi.newpipe.util.text; + +import android.graphics.Paint; +import android.text.Layout; +import android.view.View; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.text.HtmlCompat; + +import org.schabi.newpipe.extractor.StreamingService; +import org.schabi.newpipe.extractor.stream.Description; + +import java.util.function.Consumer; + + +import io.reactivex.rxjava3.disposables.CompositeDisposable; + +/** + *

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

+ * This class provides all utils to automatically ellipsize and expand a text + */ +public final class TextEllipsizer { + private static final int EXPANDED_LINES = Integer.MAX_VALUE; + private static final String ELLIPSIS = "…"; + + @NonNull private final CompositeDisposable disposable = new CompositeDisposable(); + + @NonNull private final TextView view; + private final int maxLines; + @NonNull private Description content; + @Nullable private StreamingService streamingService; + @Nullable private String streamUrl; + private boolean isEllipsized = false; + @Nullable private Boolean caBeEllipsized = null; + + @NonNull private final Paint paintAtContentSize = new Paint(); + private final float ellipsisWidthPx; + @Nullable private Consumer stateChangeListener = null; + @Nullable private Consumer onContentChanged; + + public TextEllipsizer(@NonNull final TextView view, + final int maxLines, + @Nullable final StreamingService streamingService) { + this.view = view; + this.maxLines = maxLines; + this.streamingService = streamingService; + + paintAtContentSize.setTextSize(view.getTextSize()); + ellipsisWidthPx = paintAtContentSize.measureText(ELLIPSIS); + } + + public void setOnContentChanged(@Nullable final Consumer onContentChanged) { + this.onContentChanged = onContentChanged; + } + + public void setContent(@NonNull final Description content) { + this.content = content; + caBeEllipsized = null; + linkifyContentView(v -> { + final int currentMaxLines = view.getMaxLines(); + view.setMaxLines(EXPANDED_LINES); + caBeEllipsized = view.getLineCount() > maxLines; + view.setMaxLines(currentMaxLines); + if (onContentChanged != null) { + onContentChanged.accept(caBeEllipsized); + } + }); + } + + public void setStreamUrl(@Nullable final String streamUrl) { + this.streamUrl = streamUrl; + } + + public void setStreamingService(@NonNull final StreamingService streamingService) { + this.streamingService = streamingService; + } + + /** + * Expand the {@link TextEllipsizer#content} to its full length. + */ + public void expand() { + view.setMaxLines(EXPANDED_LINES); + linkifyContentView(v -> isEllipsized = false); + } + + /** + * Shorten the {@link TextEllipsizer#content} to the given number of + * {@link TextEllipsizer#maxLines maximum lines} and add trailing '{@code …}' + * if the text was shorted. + */ + public void ellipsize() { + // expand text to see whether it is necessary to ellipsize the text + view.setMaxLines(EXPANDED_LINES); + linkifyContentView(v -> { + final CharSequence charSeqText = view.getText(); + if (charSeqText != null && view.getLineCount() > maxLines) { + // Note that converting to String removes spans (i.e. links), but that's something + // we actually want since when the text is ellipsized we want all clicks on the + // comment to expand the comment, not to open links. + final String text = charSeqText.toString(); + + final Layout layout = view.getLayout(); + final float lineWidth = layout.getLineWidth(maxLines - 1); + final float layoutWidth = layout.getWidth(); + final int lineStart = layout.getLineStart(maxLines - 1); + final int lineEnd = layout.getLineEnd(maxLines - 1); + + // remove characters up until there is enough space for the ellipsis + // (also summing 2 more pixels, just to be sure to avoid float rounding errors) + int end = lineEnd; + float removedCharactersWidth = 0.0f; + while (lineWidth - removedCharactersWidth + ellipsisWidthPx + 2.0f > layoutWidth + && end >= lineStart) { + end -= 1; + // recalculate each time to account for ligatures or other similar things + removedCharactersWidth = paintAtContentSize.measureText( + text.substring(end, lineEnd)); + } + + // remove trailing spaces and newlines + while (end > 0 && Character.isWhitespace(text.charAt(end - 1))) { + end -= 1; + } + + final String newVal = text.substring(0, end) + ELLIPSIS; + view.setText(newVal); + isEllipsized = true; + } else { + isEllipsized = false; + } + view.setMaxLines(maxLines); + }); + } + + /** + * Toggle the view between the ellipsed and expanded state. + */ + public void toggle() { + if (isEllipsized) { + expand(); + } else { + ellipsize(); + } + } + + /** + * Whether the {@link view} can be ellipsized. + * This is only the case when the {@link content} has more lines + * than allowed via {@link maxLines}. + * @return {@code true} if the {@link content} has more lines than allowed via {@link maxLines} + * and thus can be shortened, {@code false} if the {@code content} fits into the {@link view} + * without being shortened and {@code null} if the initialization is not completed yet. + */ + @Nullable + public Boolean canBeEllipsized() { + return caBeEllipsized; + } + + private void linkifyContentView(final Consumer consumer) { + final boolean oldState = isEllipsized; + disposable.clear(); + TextLinkifier.fromDescription(view, content, + HtmlCompat.FROM_HTML_MODE_LEGACY, streamingService, streamUrl, disposable, + v -> { + consumer.accept(v); + notifyStateChangeListener(oldState); + }); + + } + + /** + * Add a listener which is called when the given content is changed, + * either from ellipsized to full or vice versa. + * @param listener The listener to be called. + * The Boolean parameter is the new state. + * Ellipsized content is represented as {@code true}, + * normal or full content by {@code false}. + */ + public void setStateChangeListener(final Consumer listener) { + this.stateChangeListener = listener; + } + + public void removeStateChangeListener() { + this.stateChangeListener = null; + } + + private void notifyStateChangeListener(final boolean oldState) { + if (oldState != isEllipsized && stateChangeListener != null) { + stateChangeListener.accept(isEllipsized); + } + } + +} diff --git a/app/src/main/res/layout/playlist_header.xml b/app/src/main/res/layout/playlist_header.xml index 2d6f3067684..c761240d978 100644 --- a/app/src/main/res/layout/playlist_header.xml +++ b/app/src/main/res/layout/playlist_header.xml @@ -87,12 +87,25 @@ android:layout_below="@id/playlist_meta" android:paddingHorizontal="@dimen/video_item_search_padding" android:paddingTop="6dp" - tools:text="This is a multiline playlist description. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nunc tristique vitae sem vitae blandit" /> + android:maxLines="5" + tools:text="This is a multiline playlist description. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nunc tristique vitae sem vitae blandit Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nunc tristique vitae sem vitae blandit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nunc tristique vitae sem vitae blandit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nunc tristique vitae sem vitae blandit" /> + + + android:layout_below="@id/playlist_description_read_more"> %s reply %s replies + Show more + Show less