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 e82a984d5..ab3963d61 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 @@ -1,7 +1,9 @@ package org.schabi.newpipe.fragments.list.playlist; +import static org.schabi.newpipe.extractor.utils.Utils.isBlank; import static org.schabi.newpipe.ktx.ViewUtils.animate; import static org.schabi.newpipe.ktx.ViewUtils.animateHideRecyclerViewAllowingScrolling; +import static org.schabi.newpipe.util.ServiceHelper.getServiceById; import android.content.Context; import android.os.Bundle; @@ -37,6 +39,7 @@ import org.schabi.newpipe.extractor.ListExtractor; import org.schabi.newpipe.extractor.ServiceList; import org.schabi.newpipe.extractor.playlist.PlaylistInfo; import org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper; +import org.schabi.newpipe.extractor.stream.Description; import org.schabi.newpipe.extractor.stream.StreamInfoItem; import org.schabi.newpipe.fragments.list.BaseListInfoFragment; import org.schabi.newpipe.info_list.dialog.InfoItemDialog; @@ -48,9 +51,10 @@ import org.schabi.newpipe.player.playqueue.PlaylistPlayQueue; import org.schabi.newpipe.util.ExtractorHelper; import org.schabi.newpipe.util.Localization; import org.schabi.newpipe.util.NavigationHelper; -import org.schabi.newpipe.util.image.PicassoHelper; -import org.schabi.newpipe.util.external_communication.ShareUtils; import org.schabi.newpipe.util.PlayButtonHelper; +import org.schabi.newpipe.util.external_communication.ShareUtils; +import org.schabi.newpipe.util.image.PicassoHelper; +import org.schabi.newpipe.util.text.TextEllipsizer; import java.util.ArrayList; import java.util.List; @@ -321,6 +325,29 @@ public class PlaylistFragment extends BaseListInfoFragment + 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()) { showSnackBarError(new ErrorInfo(result.getErrors(), UserAction.REQUESTED_PLAYLIST, result.getUrl(), result)); 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 8327b398b..a3f0384ad 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.RelativeLayout; 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 class CommentInfoItemHolder extends InfoItemHolder { 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 class CommentInfoItemHolder extends InfoItemHolder { // 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 @@ public class CommentInfoItemHolder extends InfoItemHolder { 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 000000000..184b73304 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/text/TextEllipsizer.java @@ -0,0 +1,193 @@ +package org.schabi.newpipe.util.text; + +import android.graphics.Paint; +import android.text.Layout; +import android.view.View; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.text.HtmlCompat; + +import org.schabi.newpipe.extractor.StreamingService; +import org.schabi.newpipe.extractor.stream.Description; + +import java.util.function.Consumer; + + +import io.reactivex.rxjava3.disposables.CompositeDisposable; + +/** + *

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

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