From 489df0ed7deb3f3f008e9d74e194d381f3b5880b Mon Sep 17 00:00:00 2001 From: Stypox Date: Thu, 5 Jan 2023 19:01:38 +0100 Subject: [PATCH 1/2] Update NewPipeExtractor and properly linkify comments --- app/build.gradle | 2 +- .../fragments/detail/DescriptionFragment.java | 31 +-- .../holder/CommentsMiniInfoItemHolder.java | 132 +++++----- .../schabi/newpipe/util/ExtractorHelper.java | 6 +- .../util/text/CommentTextOnTouchListener.java | 30 +-- .../text/HashtagLongPressClickableSpan.java | 10 +- .../newpipe/util/text/TextLinkifier.java | 247 ++++++++++++------ .../text/TimestampLongPressClickableSpan.java | 35 +-- 8 files changed, 277 insertions(+), 216 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 6066bce4316..a76d986fb80 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -187,7 +187,7 @@ dependencies { // name and the commit hash with the commit hash of the (pushed) commit you want to test // This works thanks to JitPack: https://jitpack.io/ implementation 'com.github.TeamNewPipe:nanojson:1d9e1aea9049fc9f85e68b43ba39fe7be1c1f751' - implementation 'com.github.TeamNewPipe:NewPipeExtractor:2211a24b6934a8a8cdf5547ea1b52daa4cb5de6c' + implementation 'com.github.TeamNewPipe:NewPipeExtractor:ff94e9f30bc5d7831734cc85ecebe7d30ac9c040' implementation 'com.github.TeamNewPipe:NoNonsense-FilePicker:5.0.0' /** Checkstyle **/ diff --git a/app/src/main/java/org/schabi/newpipe/fragments/detail/DescriptionFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/detail/DescriptionFragment.java index ea89424ece2..d364c0c0fd4 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/detail/DescriptionFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/detail/DescriptionFragment.java @@ -4,6 +4,7 @@ import static org.schabi.newpipe.extractor.stream.StreamExtractor.NO_AGE_LIMIT; import static org.schabi.newpipe.extractor.utils.Utils.isBlank; import static org.schabi.newpipe.util.Localization.getAppLocale; +import static org.schabi.newpipe.util.text.TextLinkifier.SET_LINK_MOVEMENT_METHOD; import android.os.Bundle; import android.view.LayoutInflater; @@ -112,7 +113,10 @@ private void enableDescriptionSelection() { private void disableDescriptionSelection() { // show description content again, otherwise some links are not clickable - loadDescriptionContent(); + TextLinkifier.fromDescription(binding.detailDescriptionView, + streamInfo.getDescription(), HtmlCompat.FROM_HTML_MODE_LEGACY, + streamInfo.getService(), streamInfo.getUrl(), + descriptionDisposables, SET_LINK_MOVEMENT_METHOD); binding.detailDescriptionNoteView.setVisibility(View.GONE); binding.detailDescriptionView.setTextIsSelectable(false); @@ -123,27 +127,6 @@ private void disableDescriptionSelection() { binding.detailSelectDescriptionButton.setImageResource(R.drawable.ic_select_all); } - private void loadDescriptionContent() { - final Description description = streamInfo.getDescription(); - switch (description.getType()) { - case Description.HTML: - TextLinkifier.createLinksFromHtmlBlock(binding.detailDescriptionView, - description.getContent(), HtmlCompat.FROM_HTML_MODE_LEGACY, streamInfo, - descriptionDisposables); - break; - case Description.MARKDOWN: - TextLinkifier.createLinksFromMarkdownText(binding.detailDescriptionView, - description.getContent(), streamInfo, descriptionDisposables); - break; - case Description.PLAIN_TEXT: - default: - TextLinkifier.createLinksFromPlainText(binding.detailDescriptionView, - description.getContent(), streamInfo, descriptionDisposables); - break; - } - } - - private void setupMetadata(final LayoutInflater inflater, final LinearLayout layout) { addMetadataItem(inflater, layout, false, R.string.metadata_category, @@ -193,8 +176,8 @@ private void addMetadataItem(final LayoutInflater inflater, }); if (linkifyContent) { - TextLinkifier.createLinksFromPlainText(itemBinding.metadataContentView, content, - null, descriptionDisposables); + TextLinkifier.fromPlainText(itemBinding.metadataContentView, content, null, null, + descriptionDisposables, SET_LINK_MOVEMENT_METHOD); } else { itemBinding.metadataContentView.setText(content); } diff --git a/app/src/main/java/org/schabi/newpipe/info_list/holder/CommentsMiniInfoItemHolder.java b/app/src/main/java/org/schabi/newpipe/info_list/holder/CommentsMiniInfoItemHolder.java index 69aba8c4f18..fe04ac7eecf 100644 --- a/app/src/main/java/org/schabi/newpipe/info_list/holder/CommentsMiniInfoItemHolder.java +++ b/app/src/main/java/org/schabi/newpipe/info_list/holder/CommentsMiniInfoItemHolder.java @@ -1,9 +1,10 @@ package org.schabi.newpipe.info_list.holder; +import android.graphics.Paint; +import android.text.Layout; import android.text.TextUtils; import android.text.method.LinkMovementMethod; import android.text.style.URLSpan; -import android.text.util.Linkify; import android.util.Log; import android.view.View; import android.view.ViewGroup; @@ -11,33 +12,43 @@ import android.widget.RelativeLayout; import android.widget.TextView; +import androidx.annotation.Nullable; import androidx.appcompat.app.AppCompatActivity; -import androidx.core.text.util.LinkifyCompat; +import androidx.core.text.HtmlCompat; import org.schabi.newpipe.R; import org.schabi.newpipe.error.ErrorUtil; import org.schabi.newpipe.extractor.InfoItem; +import org.schabi.newpipe.extractor.NewPipe; +import org.schabi.newpipe.extractor.ServiceList; +import org.schabi.newpipe.extractor.StreamingService; import org.schabi.newpipe.extractor.comments.CommentsInfoItem; +import org.schabi.newpipe.extractor.exceptions.ExtractionException; +import org.schabi.newpipe.extractor.stream.Description; import org.schabi.newpipe.info_list.InfoItemBuilder; import org.schabi.newpipe.local.history.HistoryRecordManager; -import org.schabi.newpipe.util.text.CommentTextOnTouchListener; import org.schabi.newpipe.util.DeviceUtils; import org.schabi.newpipe.util.Localization; import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.PicassoHelper; import org.schabi.newpipe.util.external_communication.ShareUtils; -import org.schabi.newpipe.util.text.TimestampExtractor; +import org.schabi.newpipe.util.text.CommentTextOnTouchListener; +import org.schabi.newpipe.util.text.TextLinkifier; -import java.util.Objects; +import java.util.function.Consumer; + +import io.reactivex.rxjava3.disposables.CompositeDisposable; public class CommentsMiniInfoItemHolder extends InfoItemHolder { private static final String TAG = "CommentsMiniIIHolder"; + 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 float ellipsisWidthPx; private final RelativeLayout itemRoot; private final ImageView itemThumbnailView; @@ -45,7 +56,9 @@ public class CommentsMiniInfoItemHolder extends InfoItemHolder { private final TextView itemLikesCountView; private final TextView itemPublishedTime; - private String commentText; + private final CompositeDisposable disposables = new CompositeDisposable(); + private Description commentText; + private StreamingService streamService; private String streamUrl; CommentsMiniInfoItemHolder(final InfoItemBuilder infoItemBuilder, final int layoutId, @@ -62,6 +75,10 @@ public class CommentsMiniInfoItemHolder extends InfoItemHolder { .getResources().getDimension(R.dimen.comments_horizontal_padding); commentVerticalPadding = (int) infoItemBuilder.getContext() .getResources().getDimension(R.dimen.comments_vertical_padding); + + final Paint paint = new Paint(); + paint.setTextSize(itemContentView.getTextSize()); + ellipsisWidthPx = paint.measureText(ELLIPSIS); } public CommentsMiniInfoItemHolder(final InfoItemBuilder infoItemBuilder, @@ -91,18 +108,20 @@ public void updateFromItem(final InfoItem infoItem, itemThumbnailView.setOnClickListener(view -> openCommentAuthor(item)); + try { + streamService = NewPipe.getService(item.getServiceId()); + } catch (final ExtractionException e) { + // should never happen + ErrorUtil.showUiErrorSnackbar(itemBuilder.getContext(), "Getting StreamingService", e); + Log.w(TAG, "Cannot obtain service from comment service id, defaulting to YouTube", e); + streamService = ServiceList.YouTube; + } streamUrl = item.getUrl(); - - itemContentView.setLines(COMMENT_DEFAULT_LINES); commentText = item.getCommentText(); - itemContentView.setText(commentText, TextView.BufferType.SPANNABLE); - itemContentView.setOnTouchListener(CommentTextOnTouchListener.INSTANCE); + ellipsize(); - if (itemContentView.getLineCount() == 0) { - itemContentView.post(this::ellipsize); - } else { - ellipsize(); - } + //noinspection ClickableViewAccessibility + itemContentView.setOnTouchListener(CommentTextOnTouchListener.INSTANCE); if (item.getLikeCount() >= 0) { itemLikesCountView.setText( @@ -132,7 +151,8 @@ public void updateFromItem(final InfoItem infoItem, if (DeviceUtils.isTv(itemBuilder.getContext())) { openCommentAuthor(item); } else { - ShareUtils.copyToClipboard(itemBuilder.getContext(), commentText); + ShareUtils.copyToClipboard(itemBuilder.getContext(), + itemContentView.getText().toString()); } return true; }); @@ -172,7 +192,7 @@ private boolean shouldFocusLinks() { return urls != null && urls.length != 0; } - private void determineLinkFocus() { + private void determineMovementMethod() { if (shouldFocusLinks()) { allowLinkFocus(); } else { @@ -181,63 +201,51 @@ private void determineLinkFocus() { } private void ellipsize() { - boolean hasEllipsis = false; - - if (itemContentView.getLineCount() > COMMENT_DEFAULT_LINES) { - final int endOfLastLine = itemContentView - .getLayout() - .getLineEnd(COMMENT_DEFAULT_LINES - 1); - int end = itemContentView.getText().toString().lastIndexOf(' ', endOfLastLine - 2); - if (end == -1) { - end = Math.max(endOfLastLine - 2, 0); - } - final String newVal = itemContentView.getText().subSequence(0, end) + " …"; - itemContentView.setText(newVal); - hasEllipsis = true; - } + linkifyCommentContentView(v -> { + boolean hasEllipsis = false; - linkify(); + if (itemContentView.getLineCount() > COMMENT_DEFAULT_LINES) { + final int endOfLastLine = itemContentView + .getLayout() + .getLineEnd(COMMENT_DEFAULT_LINES - 1); + int end = itemContentView.getText().toString().lastIndexOf(' ', endOfLastLine - 2); + if (end == -1) { + end = Math.max(endOfLastLine - 2, 0); + } + final String newVal = itemContentView.getText().subSequence(0, end) + " …"; + itemContentView.setText(newVal); + hasEllipsis = true; + } - if (hasEllipsis) { - denyLinkFocus(); - } else { - determineLinkFocus(); - } + itemContentView.setMaxLines(COMMENT_DEFAULT_LINES); + if (hasEllipsis) { + denyLinkFocus(); + } else { + determineMovementMethod(); + } + }); } private void toggleEllipsize() { - if (itemContentView.getText().toString().equals(commentText)) { - if (itemContentView.getLineCount() > COMMENT_DEFAULT_LINES) { - ellipsize(); - } - } else { + final CharSequence text = itemContentView.getText(); + if (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); - itemContentView.setText(commentText); - linkify(); - determineLinkFocus(); + linkifyCommentContentView(v -> determineMovementMethod()); } - private void linkify() { - LinkifyCompat.addLinks(itemContentView, Linkify.WEB_URLS); - LinkifyCompat.addLinks(itemContentView, TimestampExtractor.TIMESTAMPS_PATTERN, null, null, - (match, url) -> { - try { - final var timestampMatch = TimestampExtractor - .getTimestampFromMatcher(match, commentText); - if (timestampMatch == null) { - return url; - } - return streamUrl + url.replace(Objects.requireNonNull(match.group(0)), - "#timestamp=" + timestampMatch.seconds()); - } catch (final Exception ex) { - Log.e(TAG, "Unable to process url='" + url + "' as timestampLink", ex); - return url; - } - }); + 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/ExtractorHelper.java b/app/src/main/java/org/schabi/newpipe/util/ExtractorHelper.java index 2123010aaff..d5d472d6f28 100644 --- a/app/src/main/java/org/schabi/newpipe/util/ExtractorHelper.java +++ b/app/src/main/java/org/schabi/newpipe/util/ExtractorHelper.java @@ -20,6 +20,7 @@ package org.schabi.newpipe.util; import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty; +import static org.schabi.newpipe.util.text.TextLinkifier.SET_LINK_MOVEMENT_METHOD; import android.content.Context; import android.util.Log; @@ -319,8 +320,9 @@ public static void showMetaInfoInTextView(@Nullable final List metaInf } metaInfoSeparator.setVisibility(View.VISIBLE); - TextLinkifier.createLinksFromHtmlBlock(metaInfoTextView, stringBuilder.toString(), - HtmlCompat.FROM_HTML_SEPARATOR_LINE_BREAK_HEADING, null, disposables); + TextLinkifier.fromHtml(metaInfoTextView, stringBuilder.toString(), + HtmlCompat.FROM_HTML_SEPARATOR_LINE_BREAK_HEADING, null, null, disposables, + SET_LINK_MOVEMENT_METHOD); } } diff --git a/app/src/main/java/org/schabi/newpipe/util/text/CommentTextOnTouchListener.java b/app/src/main/java/org/schabi/newpipe/util/text/CommentTextOnTouchListener.java index 4ced4be77ac..5018a6120a1 100644 --- a/app/src/main/java/org/schabi/newpipe/util/text/CommentTextOnTouchListener.java +++ b/app/src/main/java/org/schabi/newpipe/util/text/CommentTextOnTouchListener.java @@ -2,51 +2,37 @@ import static org.schabi.newpipe.util.text.TouchUtils.getOffsetForHorizontalLine; -import android.text.Selection; -import android.text.Spannable; +import android.annotation.SuppressLint; import android.text.Spanned; import android.text.style.ClickableSpan; -import android.text.style.URLSpan; import android.view.MotionEvent; import android.view.View; import android.widget.TextView; -import org.schabi.newpipe.util.external_communication.ShareUtils; - -import io.reactivex.rxjava3.disposables.CompositeDisposable; - public class CommentTextOnTouchListener implements View.OnTouchListener { public static final CommentTextOnTouchListener INSTANCE = new CommentTextOnTouchListener(); + @SuppressLint("ClickableViewAccessibility") @Override public boolean onTouch(final View v, final MotionEvent event) { if (!(v instanceof TextView)) { return false; } final TextView widget = (TextView) v; - final Object text = widget.getText(); + final CharSequence text = widget.getText(); if (text instanceof Spanned) { - final Spannable buffer = (Spannable) text; - + final Spanned buffer = (Spanned) text; final int action = event.getAction(); if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_DOWN) { final int offset = getOffsetForHorizontalLine(widget, event); - final ClickableSpan[] link = buffer.getSpans(offset, offset, ClickableSpan.class); + final ClickableSpan[] links = buffer.getSpans(offset, offset, ClickableSpan.class); - if (link.length != 0) { + if (links.length != 0) { if (action == MotionEvent.ACTION_UP) { - if (link[0] instanceof URLSpan) { - final String url = ((URLSpan) link[0]).getURL(); - if (!InternalUrlsHandler.handleUrlCommentsTimestamp( - new CompositeDisposable(), v.getContext(), url)) { - ShareUtils.openUrlInBrowser(v.getContext(), url, false); - } - } - } else if (action == MotionEvent.ACTION_DOWN) { - Selection.setSelection(buffer, buffer.getSpanStart(link[0]), - buffer.getSpanEnd(link[0])); + links[0].onClick(widget); } + // we handle events that intersect links, so return true return true; } } diff --git a/app/src/main/java/org/schabi/newpipe/util/text/HashtagLongPressClickableSpan.java b/app/src/main/java/org/schabi/newpipe/util/text/HashtagLongPressClickableSpan.java index 4ca6c326efb..8a0363ecbce 100644 --- a/app/src/main/java/org/schabi/newpipe/util/text/HashtagLongPressClickableSpan.java +++ b/app/src/main/java/org/schabi/newpipe/util/text/HashtagLongPressClickableSpan.java @@ -5,7 +5,6 @@ import androidx.annotation.NonNull; -import org.schabi.newpipe.extractor.Info; import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.external_communication.ShareUtils; @@ -15,20 +14,19 @@ final class HashtagLongPressClickableSpan extends LongPressClickableSpan { private final Context context; @NonNull private final String parsedHashtag; - @NonNull - private final Info relatedInfo; + private final int relatedInfoServiceId; HashtagLongPressClickableSpan(@NonNull final Context context, @NonNull final String parsedHashtag, - @NonNull final Info relatedInfo) { + final int relatedInfoServiceId) { this.context = context; this.parsedHashtag = parsedHashtag; - this.relatedInfo = relatedInfo; + this.relatedInfoServiceId = relatedInfoServiceId; } @Override public void onClick(@NonNull final View view) { - NavigationHelper.openSearch(context, relatedInfo.getServiceId(), parsedHashtag); + NavigationHelper.openSearch(context, relatedInfoServiceId, parsedHashtag); } @Override diff --git a/app/src/main/java/org/schabi/newpipe/util/text/TextLinkifier.java b/app/src/main/java/org/schabi/newpipe/util/text/TextLinkifier.java index b7220d22f96..e59a3dc0577 100644 --- a/app/src/main/java/org/schabi/newpipe/util/text/TextLinkifier.java +++ b/app/src/main/java/org/schabi/newpipe/util/text/TextLinkifier.java @@ -12,11 +12,12 @@ import androidx.annotation.Nullable; import androidx.core.text.HtmlCompat; -import org.schabi.newpipe.extractor.Info; -import org.schabi.newpipe.extractor.stream.StreamInfo; +import org.schabi.newpipe.extractor.StreamingService; +import org.schabi.newpipe.extractor.stream.Description; import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.external_communication.ShareUtils; +import java.util.function.Consumer; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -33,88 +34,155 @@ public final class TextLinkifier { // Looks for hashtags with characters from any language (\p{L}), numbers, or underscores private static final Pattern HASHTAGS_PATTERN = Pattern.compile("(#[\\p{L}0-9_]+)"); + public static final Consumer SET_LINK_MOVEMENT_METHOD = + v -> v.setMovementMethod(LongPressLinkMovementMethod.getInstance()); + private TextLinkifier() { } + /** + * Create links for contents with an {@link Description} in the various possible formats. + *

+ * This will call one of these three functions based on the format: {@link #fromHtml}, + * {@link #fromMarkdown} or {@link #fromPlainText}. + * + * @param textView the TextView to set the htmlBlock linked + * @param description the htmlBlock to be linked + * @param htmlCompatFlag the int flag to be set if {@link HtmlCompat#fromHtml(String, int)} + * will be called (not used for formats different than HTML) + * @param relatedInfoService if given, handle hashtags to search for the term in the correct + * service + * @param relatedStreamUrl if given, used alongside {@code relatedInfoService} to handle + * timestamps to open the stream in the popup player at the specific + * time + * @param disposables disposables created by the method are added here and their + * lifecycle should be handled by the calling class + * @param onCompletion will be run when setting text to the textView completes; use {@link + * #SET_LINK_MOVEMENT_METHOD} to make links clickable and focusable + */ + public static void fromDescription(@NonNull final TextView textView, + @NonNull final Description description, + final int htmlCompatFlag, + @Nullable final StreamingService relatedInfoService, + @Nullable final String relatedStreamUrl, + @NonNull final CompositeDisposable disposables, + @Nullable final Consumer onCompletion) { + switch (description.getType()) { + case Description.HTML: + TextLinkifier.fromHtml(textView, description.getContent(), htmlCompatFlag, + relatedInfoService, relatedStreamUrl, disposables, onCompletion); + break; + case Description.MARKDOWN: + TextLinkifier.fromMarkdown(textView, description.getContent(), + relatedInfoService, relatedStreamUrl, disposables, onCompletion); + break; + case Description.PLAIN_TEXT: default: + TextLinkifier.fromPlainText(textView, description.getContent(), + relatedInfoService, relatedStreamUrl, disposables, onCompletion); + break; + } + } + /** * Create links for contents with an HTML description. * *

- * This method will call {@link #changeIntentsOfDescriptionLinks(TextView, CharSequence, Info, - * CompositeDisposable)} after having linked the URLs with + * This method will call {@link #changeLinkIntents(TextView, CharSequence, StreamingService, + * String, CompositeDisposable, Consumer)} after having linked the URLs with * {@link HtmlCompat#fromHtml(String, int)}. *

* - * @param textView the {@link TextView} to set the the HTML string block linked - * @param htmlBlock the HTML string block to be linked - * @param htmlCompatFlag the int flag to be set when {@link HtmlCompat#fromHtml(String, int)} - * will be called - * @param relatedInfo if given, handle timestamps to open the stream in the popup player at - * the specific time, and hashtags to search for the term in the correct - * service - * @param disposables disposables created by the method are added here and their lifecycle - * should be handled by the calling class + * @param textView the {@link TextView} to set the the HTML string block linked + * @param htmlBlock the HTML string block to be linked + * @param htmlCompatFlag the int flag to be set when {@link HtmlCompat#fromHtml(String, + * int)} will be called + * @param relatedInfoService if given, handle hashtags to search for the term in the correct + * service + * @param relatedStreamUrl if given, used alongside {@code relatedInfoService} to handle + * timestamps to open the stream in the popup player at the specific + * time + * @param disposables disposables created by the method are added here and their + * lifecycle should be handled by the calling class + * @param onCompletion will be run when setting text to the textView completes; use {@link + * #SET_LINK_MOVEMENT_METHOD} to make links clickable and focusable */ - public static void createLinksFromHtmlBlock(@NonNull final TextView textView, - @NonNull final String htmlBlock, - final int htmlCompatFlag, - @Nullable final Info relatedInfo, - @NonNull final CompositeDisposable disposables) { - changeIntentsOfDescriptionLinks(textView, HtmlCompat.fromHtml(htmlBlock, htmlCompatFlag), - relatedInfo, disposables); + public static void fromHtml(@NonNull final TextView textView, + @NonNull final String htmlBlock, + final int htmlCompatFlag, + @Nullable final StreamingService relatedInfoService, + @Nullable final String relatedStreamUrl, + @NonNull final CompositeDisposable disposables, + @Nullable final Consumer onCompletion) { + changeLinkIntents( + textView, HtmlCompat.fromHtml(htmlBlock, htmlCompatFlag), relatedInfoService, + relatedStreamUrl, disposables, onCompletion); } /** * Create links for contents with a plain text description. * *

- * This method will call {@link #changeIntentsOfDescriptionLinks(TextView, CharSequence, Info, - * CompositeDisposable)} after having linked the URLs with {@link TextView#setAutoLinkMask(int)} - * and {@link TextView#setText(CharSequence, TextView.BufferType)}. + * This method will call {@link #changeLinkIntents(TextView, CharSequence, StreamingService, + * String, CompositeDisposable, Consumer)} after having linked the URLs with + * {@link TextView#setAutoLinkMask(int)} and + * {@link TextView#setText(CharSequence, TextView.BufferType)}. *

* - * @param textView the {@link TextView} to set the plain text block linked - * @param plainTextBlock the block of plain text to be linked - * @param relatedInfo if given, handle timestamps to open the stream in the popup player, at - * the specified time, and hashtags to search for the term in the correct - * service - * @param disposables disposables created by the method are added here and their lifecycle - * should be handled by the calling class + * @param textView the {@link TextView} to set the plain text block linked + * @param plainTextBlock the block of plain text to be linked + * @param relatedInfoService if given, handle hashtags to search for the term in the correct + * service + * @param relatedStreamUrl if given, used alongside {@code relatedInfoService} to handle + * timestamps to open the stream in the popup player at the specific + * time + * @param disposables disposables created by the method are added here and their + * lifecycle should be handled by the calling class + * @param onCompletion will be run when setting text to the textView completes; use {@link + * #SET_LINK_MOVEMENT_METHOD} to make links clickable and focusable */ - public static void createLinksFromPlainText(@NonNull final TextView textView, - @NonNull final String plainTextBlock, - @Nullable final Info relatedInfo, - @NonNull final CompositeDisposable disposables) { + public static void fromPlainText(@NonNull final TextView textView, + @NonNull final String plainTextBlock, + @Nullable final StreamingService relatedInfoService, + @Nullable final String relatedStreamUrl, + @NonNull final CompositeDisposable disposables, + @Nullable final Consumer onCompletion) { textView.setAutoLinkMask(Linkify.WEB_URLS); textView.setText(plainTextBlock, TextView.BufferType.SPANNABLE); - changeIntentsOfDescriptionLinks(textView, textView.getText(), relatedInfo, disposables); + changeLinkIntents(textView, textView.getText(), relatedInfoService, + relatedStreamUrl, disposables, onCompletion); } /** * Create links for contents with a markdown description. * *

- * This method will call {@link #changeIntentsOfDescriptionLinks(TextView, CharSequence, Info, - * CompositeDisposable)} after creating a {@link Markwon} object and using + * This method will call {@link #changeLinkIntents(TextView, CharSequence, StreamingService, + * String, CompositeDisposable, Consumer)} after creating a {@link Markwon} object and using * {@link Markwon#setMarkdown(TextView, String)}. *

* - * @param textView the {@link TextView} to set the plain text block linked - * @param markdownBlock the block of markdown text to be linked - * @param relatedInfo if given, handle timestamps to open the stream in the popup player at - * the specific time, and hashtags to search for the term in the correct - * service - * @param disposables disposables created by the method are added here and their lifecycle - * should be handled by the calling class + * @param textView the {@link TextView} to set the plain text block linked + * @param markdownBlock the block of markdown text to be linked + * @param relatedInfoService if given, handle hashtags to search for the term in the correct + * service + * @param relatedStreamUrl if given, used alongside {@code relatedInfoService} to handle + * timestamps to open the stream in the popup player at the specific + * time + * @param disposables disposables created by the method are added here and their + * lifecycle should be handled by the calling class + * @param onCompletion will be run when setting text to the textView completes; use {@link + * #SET_LINK_MOVEMENT_METHOD} to make links clickable and focusable */ - public static void createLinksFromMarkdownText(@NonNull final TextView textView, - final String markdownBlock, - @Nullable final Info relatedInfo, - final CompositeDisposable disposables) { + public static void fromMarkdown(@NonNull final TextView textView, + @NonNull final String markdownBlock, + @Nullable final StreamingService relatedInfoService, + @Nullable final String relatedStreamUrl, + @NonNull final CompositeDisposable disposables, + @Nullable final Consumer onCompletion) { final Markwon markwon = Markwon.builder(textView.getContext()) .usePlugin(LinkifyPlugin.create()).build(); - changeIntentsOfDescriptionLinks(textView, markwon.toMarkdown(markdownBlock), relatedInfo, - disposables); + changeLinkIntents(textView, markwon.toMarkdown(markdownBlock), + relatedInfoService, relatedStreamUrl, disposables, onCompletion); } /** @@ -131,9 +199,9 @@ public static void createLinksFromMarkdownText(@NonNull final TextView textView, * This method will also add click listeners on timestamps in this description, which will play * the content in the popup player at the time indicated in the timestamp, by using * {@link TextLinkifier#addClickListenersOnTimestamps(Context, SpannableStringBuilder, - * StreamInfo, CompositeDisposable)} method and click listeners on hashtags, by using - * {@link TextLinkifier#addClickListenersOnHashtags(Context, SpannableStringBuilder, Info)})}, - * which will open a search on the current service with the hashtag. + * StreamingService, String, CompositeDisposable)} method and click listeners on hashtags, by + * using {@link TextLinkifier#addClickListenersOnHashtags(Context, SpannableStringBuilder, + * StreamingService)}, which will open a search on the current service with the hashtag. *

* *

@@ -141,20 +209,25 @@ public static void createLinksFromMarkdownText(@NonNull final TextView textView, * before opening a web link. *

* - * @param textView the {@link TextView} in which the converted {@link CharSequence} will be - * applied - * @param chars the {@link CharSequence} to be parsed - * @param relatedInfo if given, handle timestamps to open the stream in the popup player at the - * specific time, and hashtags to search for the term in the correct service - * @param disposables disposables created by the method are added here and their lifecycle - * should be handled by the calling class + * @param textView the {@link TextView} to which the converted {@link CharSequence} + * will be applied + * @param chars the {@link CharSequence} to be parsed + * @param relatedInfoService if given, handle hashtags to search for the term in the correct + * service + * @param relatedStreamUrl if given, used alongside {@code relatedInfoService} to handle + * timestamps to open the stream in the popup player at the specific + * time + * @param disposables disposables created by the method are added here and their + * lifecycle should be handled by the calling class + * @param onCompletion will be run when setting text to the textView completes; use {@link + * #SET_LINK_MOVEMENT_METHOD} to make links clickable and focusable */ - private static void changeIntentsOfDescriptionLinks( - @NonNull final TextView textView, - @NonNull final CharSequence chars, - @Nullable final Info relatedInfo, - @NonNull final CompositeDisposable disposables) { - textView.setMovementMethod(LongPressLinkMovementMethod.getInstance()); + private static void changeLinkIntents(@NonNull final TextView textView, + @NonNull final CharSequence chars, + @Nullable final StreamingService relatedInfoService, + @Nullable final String relatedStreamUrl, + @NonNull final CompositeDisposable disposables, + @Nullable final Consumer onCompletion) { disposables.add(Single.fromCallable(() -> { final Context context = textView.getContext(); @@ -176,26 +249,26 @@ private static void changeIntentsOfDescriptionLinks( textBlockLinked.removeSpan(span); } - if (relatedInfo != null) { - // add click actions on plain text timestamps only for description of - // contents, unneeded for meta-info or other TextViews - if (relatedInfo instanceof StreamInfo) { + // add click actions on plain text timestamps only for description of contents, + // unneeded for meta-info or other TextViews + if (relatedInfoService != null) { + if (relatedStreamUrl != null) { addClickListenersOnTimestamps(context, textBlockLinked, - (StreamInfo) relatedInfo, disposables); + relatedInfoService, relatedStreamUrl, disposables); } - - addClickListenersOnHashtags(context, textBlockLinked, relatedInfo); + addClickListenersOnHashtags(context, textBlockLinked, relatedInfoService); } return textBlockLinked; }).subscribeOn(Schedulers.computation()) .observeOn(AndroidSchedulers.mainThread()) .subscribe( - textBlockLinked -> setTextViewCharSequence(textView, textBlockLinked), + textBlockLinked -> + setTextViewCharSequence(textView, textBlockLinked, onCompletion), throwable -> { Log.e(TAG, "Unable to linkify text", throwable); // this should never happen, but if it does, just fallback to it - setTextViewCharSequence(textView, chars); + setTextViewCharSequence(textView, chars, onCompletion); })); } @@ -213,12 +286,12 @@ private static void changeIntentsOfDescriptionLinks( * @param context the {@link Context} to use * @param spannableDescription the {@link SpannableStringBuilder} with the text of the * content description - * @param relatedInfo used to search for the term in the correct service + * @param relatedInfoService used to search for the term in the correct service */ private static void addClickListenersOnHashtags( @NonNull final Context context, @NonNull final SpannableStringBuilder spannableDescription, - @NonNull final Info relatedInfo) { + @NonNull final StreamingService relatedInfoService) { final String descriptionText = spannableDescription.toString(); final Matcher hashtagsMatches = HASHTAGS_PATTERN.matcher(descriptionText); @@ -231,8 +304,9 @@ private static void addClickListenersOnHashtags( // of an URL, already parsed before if (spannableDescription.getSpans(hashtagStart, hashtagEnd, LongPressClickableSpan.class).length == 0) { + final int serviceId = relatedInfoService.getServiceId(); spannableDescription.setSpan( - new HashtagLongPressClickableSpan(context, parsedHashtag, relatedInfo), + new HashtagLongPressClickableSpan(context, parsedHashtag, serviceId), hashtagStart, hashtagEnd, 0); } } @@ -251,14 +325,16 @@ private static void addClickListenersOnHashtags( * @param context the {@link Context} to use * @param spannableDescription the {@link SpannableStringBuilder} with the text of the * content description - * @param streamInfo what to open in the popup player when timestamps are clicked + * @param relatedInfoService the service of the {@code relatedStreamUrl} + * @param relatedStreamUrl what to open in the popup player when timestamps are clicked * @param disposables disposables created by the method are added here and their * lifecycle should be handled by the calling class */ private static void addClickListenersOnTimestamps( @NonNull final Context context, @NonNull final SpannableStringBuilder spannableDescription, - @NonNull final StreamInfo streamInfo, + @NonNull final StreamingService relatedInfoService, + @NonNull final String relatedStreamUrl, @NonNull final CompositeDisposable disposables) { final String descriptionText = spannableDescription.toString(); final Matcher timestampsMatches = TimestampExtractor.TIMESTAMPS_PATTERN.matcher( @@ -272,8 +348,9 @@ private static void addClickListenersOnTimestamps( continue; } - spannableDescription.setSpan(new TimestampLongPressClickableSpan( - context, descriptionText, disposables, streamInfo, timestampMatchDTO), + spannableDescription.setSpan( + new TimestampLongPressClickableSpan(context, descriptionText, disposables, + relatedInfoService, relatedStreamUrl, timestampMatchDTO), timestampMatchDTO.timestampStart(), timestampMatchDTO.timestampEnd(), 0); @@ -281,8 +358,12 @@ private static void addClickListenersOnTimestamps( } private static void setTextViewCharSequence(@NonNull final TextView textView, - @Nullable final CharSequence charSequence) { + @Nullable final CharSequence charSequence, + @Nullable final Consumer onCompletion) { textView.setText(charSequence); textView.setVisibility(View.VISIBLE); + if (onCompletion != null) { + onCompletion.accept(textView); + } } } diff --git a/app/src/main/java/org/schabi/newpipe/util/text/TimestampLongPressClickableSpan.java b/app/src/main/java/org/schabi/newpipe/util/text/TimestampLongPressClickableSpan.java index 48110312d37..f5864794a72 100644 --- a/app/src/main/java/org/schabi/newpipe/util/text/TimestampLongPressClickableSpan.java +++ b/app/src/main/java/org/schabi/newpipe/util/text/TimestampLongPressClickableSpan.java @@ -9,7 +9,6 @@ import org.schabi.newpipe.extractor.ServiceList; import org.schabi.newpipe.extractor.StreamingService; -import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.util.external_communication.ShareUtils; import io.reactivex.rxjava3.disposables.CompositeDisposable; @@ -23,7 +22,9 @@ final class TimestampLongPressClickableSpan extends LongPressClickableSpan { @NonNull private final CompositeDisposable disposables; @NonNull - private final StreamInfo streamInfo; + private final StreamingService relatedInfoService; + @NonNull + private final String relatedStreamUrl; @NonNull private final TimestampExtractor.TimestampMatchDTO timestampMatchDTO; @@ -31,41 +32,43 @@ final class TimestampLongPressClickableSpan extends LongPressClickableSpan { @NonNull final Context context, @NonNull final String descriptionText, @NonNull final CompositeDisposable disposables, - @NonNull final StreamInfo streamInfo, + @NonNull final StreamingService relatedInfoService, + @NonNull final String relatedStreamUrl, @NonNull final TimestampExtractor.TimestampMatchDTO timestampMatchDTO) { this.context = context; this.descriptionText = descriptionText; this.disposables = disposables; - this.streamInfo = streamInfo; + this.relatedInfoService = relatedInfoService; + this.relatedStreamUrl = relatedStreamUrl; this.timestampMatchDTO = timestampMatchDTO; } @Override public void onClick(@NonNull final View view) { - playOnPopup(context, streamInfo.getUrl(), streamInfo.getService(), + playOnPopup(context, relatedStreamUrl, relatedInfoService, timestampMatchDTO.seconds(), disposables); } @Override public void onLongClick(@NonNull final View view) { - ShareUtils.copyToClipboard(context, - getTimestampTextToCopy(streamInfo, descriptionText, timestampMatchDTO)); + ShareUtils.copyToClipboard(context, getTimestampTextToCopy( + relatedInfoService, relatedStreamUrl, descriptionText, timestampMatchDTO)); } @NonNull private static String getTimestampTextToCopy( - @NonNull final StreamInfo relatedInfo, + @NonNull final StreamingService relatedInfoService, + @NonNull final String relatedStreamUrl, @NonNull final String descriptionText, @NonNull final TimestampExtractor.TimestampMatchDTO timestampMatchDTO) { // TODO: use extractor methods to get timestamps when this feature will be implemented in it - final StreamingService streamingService = relatedInfo.getService(); - if (streamingService == ServiceList.YouTube) { - return relatedInfo.getUrl() + "&t=" + timestampMatchDTO.seconds(); - } else if (streamingService == ServiceList.SoundCloud - || streamingService == ServiceList.MediaCCC) { - return relatedInfo.getUrl() + "#t=" + timestampMatchDTO.seconds(); - } else if (streamingService == ServiceList.PeerTube) { - return relatedInfo.getUrl() + "?start=" + timestampMatchDTO.seconds(); + if (relatedInfoService == ServiceList.YouTube) { + return relatedStreamUrl + "&t=" + timestampMatchDTO.seconds(); + } else if (relatedInfoService == ServiceList.SoundCloud + || relatedInfoService == ServiceList.MediaCCC) { + return relatedStreamUrl + "#t=" + timestampMatchDTO.seconds(); + } else if (relatedInfoService == ServiceList.PeerTube) { + return relatedStreamUrl + "?start=" + timestampMatchDTO.seconds(); } // Return timestamp text for other services From 6e73c489dee61f4364f0fa4faf0702f0d3374b34 Mon Sep 17 00:00:00 2001 From: Stypox Date: Sun, 15 Jan 2023 14:57:34 +0100 Subject: [PATCH 2/2] Improve ellipsizing comments --- .../holder/CommentsMiniInfoItemHolder.java | 44 ++++++++++++++----- 1 file changed, 34 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/org/schabi/newpipe/info_list/holder/CommentsMiniInfoItemHolder.java b/app/src/main/java/org/schabi/newpipe/info_list/holder/CommentsMiniInfoItemHolder.java index fe04ac7eecf..799aee8ba59 100644 --- a/app/src/main/java/org/schabi/newpipe/info_list/holder/CommentsMiniInfoItemHolder.java +++ b/app/src/main/java/org/schabi/newpipe/info_list/holder/CommentsMiniInfoItemHolder.java @@ -48,6 +48,8 @@ public class CommentsMiniInfoItemHolder extends InfoItemHolder { private final int commentHorizontalPadding; private final int commentVerticalPadding; + + private final Paint paintAtContentSize; private final float ellipsisWidthPx; private final RelativeLayout itemRoot; @@ -76,9 +78,9 @@ public class CommentsMiniInfoItemHolder extends InfoItemHolder { commentVerticalPadding = (int) infoItemBuilder.getContext() .getResources().getDimension(R.dimen.comments_vertical_padding); - final Paint paint = new Paint(); - paint.setTextSize(itemContentView.getTextSize()); - ellipsisWidthPx = paint.measureText(ELLIPSIS); + paintAtContentSize = new Paint(); + paintAtContentSize.setTextSize(itemContentView.getTextSize()); + ellipsisWidthPx = paintAtContentSize.measureText(ELLIPSIS); } public CommentsMiniInfoItemHolder(final InfoItemBuilder infoItemBuilder, @@ -201,18 +203,40 @@ private void determineMovementMethod() { } private void ellipsize() { + itemContentView.setMaxLines(COMMENT_EXPANDED_LINES); linkifyCommentContentView(v -> { boolean hasEllipsis = false; if (itemContentView.getLineCount() > COMMENT_DEFAULT_LINES) { - final int endOfLastLine = itemContentView - .getLayout() - .getLineEnd(COMMENT_DEFAULT_LINES - 1); - int end = itemContentView.getText().toString().lastIndexOf(' ', endOfLastLine - 2); - if (end == -1) { - end = Math.max(endOfLastLine - 2, 0); + // 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 = itemContentView.getText().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)); } - final String newVal = itemContentView.getText().subSequence(0, end) + " …"; + + // 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; }