1
0
mirror of https://github.com/TeamNewPipe/NewPipe synced 2024-12-23 08:30:44 +00:00

Merge pull request #9631 from TeamNewPipe/update-npe

Update NewPipeExtractor and properly linkify comments
This commit is contained in:
Stypox 2023-01-28 22:40:19 +01:00 committed by GitHub
commit cd12503f99
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 302 additions and 217 deletions

View File

@ -187,7 +187,7 @@ dependencies {
// name and the commit hash with the commit hash of the (pushed) commit you want to test // 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/ // This works thanks to JitPack: https://jitpack.io/
implementation 'com.github.TeamNewPipe:nanojson:1d9e1aea9049fc9f85e68b43ba39fe7be1c1f751' 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' implementation 'com.github.TeamNewPipe:NoNonsense-FilePicker:5.0.0'
/** Checkstyle **/ /** Checkstyle **/

View File

@ -4,6 +4,7 @@ import static android.text.TextUtils.isEmpty;
import static org.schabi.newpipe.extractor.stream.StreamExtractor.NO_AGE_LIMIT; 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.extractor.utils.Utils.isBlank;
import static org.schabi.newpipe.util.Localization.getAppLocale; 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.os.Bundle;
import android.view.LayoutInflater; import android.view.LayoutInflater;
@ -112,7 +113,10 @@ public class DescriptionFragment extends BaseFragment {
private void disableDescriptionSelection() { private void disableDescriptionSelection() {
// show description content again, otherwise some links are not clickable // 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.detailDescriptionNoteView.setVisibility(View.GONE);
binding.detailDescriptionView.setTextIsSelectable(false); binding.detailDescriptionView.setTextIsSelectable(false);
@ -123,27 +127,6 @@ public class DescriptionFragment extends BaseFragment {
binding.detailSelectDescriptionButton.setImageResource(R.drawable.ic_select_all); 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, private void setupMetadata(final LayoutInflater inflater,
final LinearLayout layout) { final LinearLayout layout) {
addMetadataItem(inflater, layout, false, R.string.metadata_category, addMetadataItem(inflater, layout, false, R.string.metadata_category,
@ -193,8 +176,8 @@ public class DescriptionFragment extends BaseFragment {
}); });
if (linkifyContent) { if (linkifyContent) {
TextLinkifier.createLinksFromPlainText(itemBinding.metadataContentView, content, TextLinkifier.fromPlainText(itemBinding.metadataContentView, content, null, null,
null, descriptionDisposables); descriptionDisposables, SET_LINK_MOVEMENT_METHOD);
} else { } else {
itemBinding.metadataContentView.setText(content); itemBinding.metadataContentView.setText(content);
} }

View File

@ -1,9 +1,10 @@
package org.schabi.newpipe.info_list.holder; package org.schabi.newpipe.info_list.holder;
import android.graphics.Paint;
import android.text.Layout;
import android.text.TextUtils; import android.text.TextUtils;
import android.text.method.LinkMovementMethod; import android.text.method.LinkMovementMethod;
import android.text.style.URLSpan; import android.text.style.URLSpan;
import android.text.util.Linkify;
import android.util.Log; import android.util.Log;
import android.view.View; import android.view.View;
import android.view.ViewGroup; import android.view.ViewGroup;
@ -11,27 +12,36 @@ import android.widget.ImageView;
import android.widget.RelativeLayout; import android.widget.RelativeLayout;
import android.widget.TextView; import android.widget.TextView;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AppCompatActivity; 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.R;
import org.schabi.newpipe.error.ErrorUtil; import org.schabi.newpipe.error.ErrorUtil;
import org.schabi.newpipe.extractor.InfoItem; 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.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.info_list.InfoItemBuilder;
import org.schabi.newpipe.local.history.HistoryRecordManager; 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.DeviceUtils;
import org.schabi.newpipe.util.Localization; import org.schabi.newpipe.util.Localization;
import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.NavigationHelper;
import org.schabi.newpipe.util.PicassoHelper; import org.schabi.newpipe.util.PicassoHelper;
import org.schabi.newpipe.util.external_communication.ShareUtils; 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 { public class CommentsMiniInfoItemHolder extends InfoItemHolder {
private static final String TAG = "CommentsMiniIIHolder"; private static final String TAG = "CommentsMiniIIHolder";
private static final String ELLIPSIS = "";
private static final int COMMENT_DEFAULT_LINES = 2; private static final int COMMENT_DEFAULT_LINES = 2;
private static final int COMMENT_EXPANDED_LINES = 1000; private static final int COMMENT_EXPANDED_LINES = 1000;
@ -39,13 +49,18 @@ public class CommentsMiniInfoItemHolder extends InfoItemHolder {
private final int commentHorizontalPadding; private final int commentHorizontalPadding;
private final int commentVerticalPadding; private final int commentVerticalPadding;
private final Paint paintAtContentSize;
private final float ellipsisWidthPx;
private final RelativeLayout itemRoot; private final RelativeLayout itemRoot;
private final ImageView itemThumbnailView; private final ImageView itemThumbnailView;
private final TextView itemContentView; private final TextView itemContentView;
private final TextView itemLikesCountView; private final TextView itemLikesCountView;
private final TextView itemPublishedTime; private final TextView itemPublishedTime;
private String commentText; private final CompositeDisposable disposables = new CompositeDisposable();
private Description commentText;
private StreamingService streamService;
private String streamUrl; private String streamUrl;
CommentsMiniInfoItemHolder(final InfoItemBuilder infoItemBuilder, final int layoutId, CommentsMiniInfoItemHolder(final InfoItemBuilder infoItemBuilder, final int layoutId,
@ -62,6 +77,10 @@ public class CommentsMiniInfoItemHolder extends InfoItemHolder {
.getResources().getDimension(R.dimen.comments_horizontal_padding); .getResources().getDimension(R.dimen.comments_horizontal_padding);
commentVerticalPadding = (int) infoItemBuilder.getContext() commentVerticalPadding = (int) infoItemBuilder.getContext()
.getResources().getDimension(R.dimen.comments_vertical_padding); .getResources().getDimension(R.dimen.comments_vertical_padding);
paintAtContentSize = new Paint();
paintAtContentSize.setTextSize(itemContentView.getTextSize());
ellipsisWidthPx = paintAtContentSize.measureText(ELLIPSIS);
} }
public CommentsMiniInfoItemHolder(final InfoItemBuilder infoItemBuilder, public CommentsMiniInfoItemHolder(final InfoItemBuilder infoItemBuilder,
@ -91,18 +110,20 @@ public class CommentsMiniInfoItemHolder extends InfoItemHolder {
itemThumbnailView.setOnClickListener(view -> openCommentAuthor(item)); itemThumbnailView.setOnClickListener(view -> openCommentAuthor(item));
streamUrl = item.getUrl(); try {
streamService = NewPipe.getService(item.getServiceId());
itemContentView.setLines(COMMENT_DEFAULT_LINES); } catch (final ExtractionException e) {
commentText = item.getCommentText(); // should never happen
itemContentView.setText(commentText, TextView.BufferType.SPANNABLE); ErrorUtil.showUiErrorSnackbar(itemBuilder.getContext(), "Getting StreamingService", e);
itemContentView.setOnTouchListener(CommentTextOnTouchListener.INSTANCE); Log.w(TAG, "Cannot obtain service from comment service id, defaulting to YouTube", e);
streamService = ServiceList.YouTube;
if (itemContentView.getLineCount() == 0) {
itemContentView.post(this::ellipsize);
} else {
ellipsize();
} }
streamUrl = item.getUrl();
commentText = item.getCommentText();
ellipsize();
//noinspection ClickableViewAccessibility
itemContentView.setOnTouchListener(CommentTextOnTouchListener.INSTANCE);
if (item.getLikeCount() >= 0) { if (item.getLikeCount() >= 0) {
itemLikesCountView.setText( itemLikesCountView.setText(
@ -132,7 +153,8 @@ public class CommentsMiniInfoItemHolder extends InfoItemHolder {
if (DeviceUtils.isTv(itemBuilder.getContext())) { if (DeviceUtils.isTv(itemBuilder.getContext())) {
openCommentAuthor(item); openCommentAuthor(item);
} else { } else {
ShareUtils.copyToClipboard(itemBuilder.getContext(), commentText); ShareUtils.copyToClipboard(itemBuilder.getContext(),
itemContentView.getText().toString());
} }
return true; return true;
}); });
@ -172,7 +194,7 @@ public class CommentsMiniInfoItemHolder extends InfoItemHolder {
return urls != null && urls.length != 0; return urls != null && urls.length != 0;
} }
private void determineLinkFocus() { private void determineMovementMethod() {
if (shouldFocusLinks()) { if (shouldFocusLinks()) {
allowLinkFocus(); allowLinkFocus();
} else { } else {
@ -181,63 +203,73 @@ public class CommentsMiniInfoItemHolder extends InfoItemHolder {
} }
private void ellipsize() { private void ellipsize() {
itemContentView.setMaxLines(COMMENT_EXPANDED_LINES);
linkifyCommentContentView(v -> {
boolean hasEllipsis = false; boolean hasEllipsis = false;
if (itemContentView.getLineCount() > COMMENT_DEFAULT_LINES) { if (itemContentView.getLineCount() > COMMENT_DEFAULT_LINES) {
final int endOfLastLine = itemContentView // Note that converting to String removes spans (i.e. links), but that's something
.getLayout() // we actually want since when the text is ellipsized we want all clicks on the
.getLineEnd(COMMENT_DEFAULT_LINES - 1); // comment to expand the comment, not to open links.
int end = itemContentView.getText().toString().lastIndexOf(' ', endOfLastLine - 2); final String text = itemContentView.getText().toString();
if (end == -1) {
end = Math.max(endOfLastLine - 2, 0); 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); itemContentView.setText(newVal);
hasEllipsis = true; hasEllipsis = true;
} }
linkify(); itemContentView.setMaxLines(COMMENT_DEFAULT_LINES);
if (hasEllipsis) { if (hasEllipsis) {
denyLinkFocus(); denyLinkFocus();
} else { } else {
determineLinkFocus(); determineMovementMethod();
} }
});
} }
private void toggleEllipsize() { private void toggleEllipsize() {
if (itemContentView.getText().toString().equals(commentText)) { final CharSequence text = itemContentView.getText();
if (itemContentView.getLineCount() > COMMENT_DEFAULT_LINES) { if (text.charAt(text.length() - 1) == ELLIPSIS.charAt(0)) {
ellipsize();
}
} else {
expand(); expand();
} else if (itemContentView.getLineCount() > COMMENT_DEFAULT_LINES) {
ellipsize();
} }
} }
private void expand() { private void expand() {
itemContentView.setMaxLines(COMMENT_EXPANDED_LINES); itemContentView.setMaxLines(COMMENT_EXPANDED_LINES);
itemContentView.setText(commentText); linkifyCommentContentView(v -> determineMovementMethod());
linkify();
determineLinkFocus();
} }
private void linkify() { private void linkifyCommentContentView(@Nullable final Consumer<TextView> onCompletion) {
LinkifyCompat.addLinks(itemContentView, Linkify.WEB_URLS); disposables.clear();
LinkifyCompat.addLinks(itemContentView, TimestampExtractor.TIMESTAMPS_PATTERN, null, null, if (commentText != null) {
(match, url) -> { TextLinkifier.fromDescription(itemContentView, commentText,
try { HtmlCompat.FROM_HTML_MODE_LEGACY, streamService, streamUrl, disposables,
final var timestampMatch = TimestampExtractor onCompletion);
.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;
}
});
} }
} }

View File

@ -20,6 +20,7 @@
package org.schabi.newpipe.util; package org.schabi.newpipe.util;
import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty; 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.content.Context;
import android.util.Log; import android.util.Log;
@ -319,8 +320,9 @@ public final class ExtractorHelper {
} }
metaInfoSeparator.setVisibility(View.VISIBLE); metaInfoSeparator.setVisibility(View.VISIBLE);
TextLinkifier.createLinksFromHtmlBlock(metaInfoTextView, stringBuilder.toString(), TextLinkifier.fromHtml(metaInfoTextView, stringBuilder.toString(),
HtmlCompat.FROM_HTML_SEPARATOR_LINE_BREAK_HEADING, null, disposables); HtmlCompat.FROM_HTML_SEPARATOR_LINE_BREAK_HEADING, null, null, disposables,
SET_LINK_MOVEMENT_METHOD);
} }
} }

View File

@ -2,51 +2,37 @@ package org.schabi.newpipe.util.text;
import static org.schabi.newpipe.util.text.TouchUtils.getOffsetForHorizontalLine; import static org.schabi.newpipe.util.text.TouchUtils.getOffsetForHorizontalLine;
import android.text.Selection; import android.annotation.SuppressLint;
import android.text.Spannable;
import android.text.Spanned; import android.text.Spanned;
import android.text.style.ClickableSpan; import android.text.style.ClickableSpan;
import android.text.style.URLSpan;
import android.view.MotionEvent; import android.view.MotionEvent;
import android.view.View; import android.view.View;
import android.widget.TextView; 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 class CommentTextOnTouchListener implements View.OnTouchListener {
public static final CommentTextOnTouchListener INSTANCE = new CommentTextOnTouchListener(); public static final CommentTextOnTouchListener INSTANCE = new CommentTextOnTouchListener();
@SuppressLint("ClickableViewAccessibility")
@Override @Override
public boolean onTouch(final View v, final MotionEvent event) { public boolean onTouch(final View v, final MotionEvent event) {
if (!(v instanceof TextView)) { if (!(v instanceof TextView)) {
return false; return false;
} }
final TextView widget = (TextView) v; final TextView widget = (TextView) v;
final Object text = widget.getText(); final CharSequence text = widget.getText();
if (text instanceof Spanned) { if (text instanceof Spanned) {
final Spannable buffer = (Spannable) text; final Spanned buffer = (Spanned) text;
final int action = event.getAction(); final int action = event.getAction();
if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_DOWN) { if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_DOWN) {
final int offset = getOffsetForHorizontalLine(widget, event); 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 (action == MotionEvent.ACTION_UP) {
if (link[0] instanceof URLSpan) { links[0].onClick(widget);
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]));
} }
// we handle events that intersect links, so return true
return true; return true;
} }
} }

View File

@ -5,7 +5,6 @@ import android.view.View;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import org.schabi.newpipe.extractor.Info;
import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.NavigationHelper;
import org.schabi.newpipe.util.external_communication.ShareUtils; import org.schabi.newpipe.util.external_communication.ShareUtils;
@ -15,20 +14,19 @@ final class HashtagLongPressClickableSpan extends LongPressClickableSpan {
private final Context context; private final Context context;
@NonNull @NonNull
private final String parsedHashtag; private final String parsedHashtag;
@NonNull private final int relatedInfoServiceId;
private final Info relatedInfo;
HashtagLongPressClickableSpan(@NonNull final Context context, HashtagLongPressClickableSpan(@NonNull final Context context,
@NonNull final String parsedHashtag, @NonNull final String parsedHashtag,
@NonNull final Info relatedInfo) { final int relatedInfoServiceId) {
this.context = context; this.context = context;
this.parsedHashtag = parsedHashtag; this.parsedHashtag = parsedHashtag;
this.relatedInfo = relatedInfo; this.relatedInfoServiceId = relatedInfoServiceId;
} }
@Override @Override
public void onClick(@NonNull final View view) { public void onClick(@NonNull final View view) {
NavigationHelper.openSearch(context, relatedInfo.getServiceId(), parsedHashtag); NavigationHelper.openSearch(context, relatedInfoServiceId, parsedHashtag);
} }
@Override @Override

View File

@ -12,11 +12,12 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.core.text.HtmlCompat; import androidx.core.text.HtmlCompat;
import org.schabi.newpipe.extractor.Info; import org.schabi.newpipe.extractor.StreamingService;
import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.extractor.stream.Description;
import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.NavigationHelper;
import org.schabi.newpipe.util.external_communication.ShareUtils; import org.schabi.newpipe.util.external_communication.ShareUtils;
import java.util.function.Consumer;
import java.util.regex.Matcher; import java.util.regex.Matcher;
import java.util.regex.Pattern; 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 // 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_]+)"); private static final Pattern HASHTAGS_PATTERN = Pattern.compile("(#[\\p{L}0-9_]+)");
public static final Consumer<TextView> SET_LINK_MOVEMENT_METHOD =
v -> v.setMovementMethod(LongPressLinkMovementMethod.getInstance());
private TextLinkifier() { private TextLinkifier() {
} }
/**
* Create links for contents with an {@link Description} in the various possible formats.
* <p>
* 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<TextView> 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. * Create links for contents with an HTML description.
* *
* <p> * <p>
* This method will call {@link #changeIntentsOfDescriptionLinks(TextView, CharSequence, Info, * This method will call {@link #changeLinkIntents(TextView, CharSequence, StreamingService,
* CompositeDisposable)} after having linked the URLs with * String, CompositeDisposable, Consumer)} after having linked the URLs with
* {@link HtmlCompat#fromHtml(String, int)}. * {@link HtmlCompat#fromHtml(String, int)}.
* </p> * </p>
* *
* @param textView the {@link TextView} to set the the HTML string block linked * @param textView the {@link TextView} to set the the HTML string block linked
* @param htmlBlock the HTML string block to be linked * @param htmlBlock the HTML string block to be linked
* @param htmlCompatFlag the int flag to be set when {@link HtmlCompat#fromHtml(String, int)} * @param htmlCompatFlag the int flag to be set when {@link HtmlCompat#fromHtml(String,
* will be called * int)} will be called
* @param relatedInfo if given, handle timestamps to open the stream in the popup player at * @param relatedInfoService if given, handle hashtags to search for the term in the correct
* the specific time, and hashtags to search for the term in the correct
* service * service
* @param disposables disposables created by the method are added here and their lifecycle * @param relatedStreamUrl if given, used alongside {@code relatedInfoService} to handle
* should be handled by the calling class * 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, public static void fromHtml(@NonNull final TextView textView,
@NonNull final String htmlBlock, @NonNull final String htmlBlock,
final int htmlCompatFlag, final int htmlCompatFlag,
@Nullable final Info relatedInfo, @Nullable final StreamingService relatedInfoService,
@NonNull final CompositeDisposable disposables) { @Nullable final String relatedStreamUrl,
changeIntentsOfDescriptionLinks(textView, HtmlCompat.fromHtml(htmlBlock, htmlCompatFlag), @NonNull final CompositeDisposable disposables,
relatedInfo, disposables); @Nullable final Consumer<TextView> onCompletion) {
changeLinkIntents(
textView, HtmlCompat.fromHtml(htmlBlock, htmlCompatFlag), relatedInfoService,
relatedStreamUrl, disposables, onCompletion);
} }
/** /**
* Create links for contents with a plain text description. * Create links for contents with a plain text description.
* *
* <p> * <p>
* This method will call {@link #changeIntentsOfDescriptionLinks(TextView, CharSequence, Info, * This method will call {@link #changeLinkIntents(TextView, CharSequence, StreamingService,
* CompositeDisposable)} after having linked the URLs with {@link TextView#setAutoLinkMask(int)} * String, CompositeDisposable, Consumer)} after having linked the URLs with
* and {@link TextView#setText(CharSequence, TextView.BufferType)}. * {@link TextView#setAutoLinkMask(int)} and
* {@link TextView#setText(CharSequence, TextView.BufferType)}.
* </p> * </p>
* *
* @param textView the {@link TextView} to set the plain text block linked * @param textView the {@link TextView} to set the plain text block linked
* @param plainTextBlock the block of plain text to be 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 * @param relatedInfoService if given, handle hashtags to search for the term in the correct
* the specified time, and hashtags to search for the term in the correct
* service * service
* @param disposables disposables created by the method are added here and their lifecycle * @param relatedStreamUrl if given, used alongside {@code relatedInfoService} to handle
* should be handled by the calling class * 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, public static void fromPlainText(@NonNull final TextView textView,
@NonNull final String plainTextBlock, @NonNull final String plainTextBlock,
@Nullable final Info relatedInfo, @Nullable final StreamingService relatedInfoService,
@NonNull final CompositeDisposable disposables) { @Nullable final String relatedStreamUrl,
@NonNull final CompositeDisposable disposables,
@Nullable final Consumer<TextView> onCompletion) {
textView.setAutoLinkMask(Linkify.WEB_URLS); textView.setAutoLinkMask(Linkify.WEB_URLS);
textView.setText(plainTextBlock, TextView.BufferType.SPANNABLE); 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. * Create links for contents with a markdown description.
* *
* <p> * <p>
* This method will call {@link #changeIntentsOfDescriptionLinks(TextView, CharSequence, Info, * This method will call {@link #changeLinkIntents(TextView, CharSequence, StreamingService,
* CompositeDisposable)} after creating a {@link Markwon} object and using * String, CompositeDisposable, Consumer)} after creating a {@link Markwon} object and using
* {@link Markwon#setMarkdown(TextView, String)}. * {@link Markwon#setMarkdown(TextView, String)}.
* </p> * </p>
* *
* @param textView the {@link TextView} to set the plain text block linked * @param textView the {@link TextView} to set the plain text block linked
* @param markdownBlock the block of markdown text to be 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 * @param relatedInfoService if given, handle hashtags to search for the term in the correct
* the specific time, and hashtags to search for the term in the correct
* service * service
* @param disposables disposables created by the method are added here and their lifecycle * @param relatedStreamUrl if given, used alongside {@code relatedInfoService} to handle
* should be handled by the calling class * 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, public static void fromMarkdown(@NonNull final TextView textView,
final String markdownBlock, @NonNull final String markdownBlock,
@Nullable final Info relatedInfo, @Nullable final StreamingService relatedInfoService,
final CompositeDisposable disposables) { @Nullable final String relatedStreamUrl,
@NonNull final CompositeDisposable disposables,
@Nullable final Consumer<TextView> onCompletion) {
final Markwon markwon = Markwon.builder(textView.getContext()) final Markwon markwon = Markwon.builder(textView.getContext())
.usePlugin(LinkifyPlugin.create()).build(); .usePlugin(LinkifyPlugin.create()).build();
changeIntentsOfDescriptionLinks(textView, markwon.toMarkdown(markdownBlock), relatedInfo, changeLinkIntents(textView, markwon.toMarkdown(markdownBlock),
disposables); relatedInfoService, relatedStreamUrl, disposables, onCompletion);
} }
/** /**
@ -131,9 +199,9 @@ public final class TextLinkifier {
* This method will also add click listeners on timestamps in this description, which will play * 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 * the content in the popup player at the time indicated in the timestamp, by using
* {@link TextLinkifier#addClickListenersOnTimestamps(Context, SpannableStringBuilder, * {@link TextLinkifier#addClickListenersOnTimestamps(Context, SpannableStringBuilder,
* StreamInfo, CompositeDisposable)} method and click listeners on hashtags, by using * StreamingService, String, CompositeDisposable)} method and click listeners on hashtags, by
* {@link TextLinkifier#addClickListenersOnHashtags(Context, SpannableStringBuilder, Info)})}, * using {@link TextLinkifier#addClickListenersOnHashtags(Context, SpannableStringBuilder,
* which will open a search on the current service with the hashtag. * StreamingService)}, which will open a search on the current service with the hashtag.
* </p> * </p>
* *
* <p> * <p>
@ -141,20 +209,25 @@ public final class TextLinkifier {
* before opening a web link. * before opening a web link.
* </p> * </p>
* *
* @param textView the {@link TextView} in which the converted {@link CharSequence} will be * @param textView the {@link TextView} to which the converted {@link CharSequence}
* applied * will be applied
* @param chars the {@link CharSequence} to be parsed * @param chars the {@link CharSequence} to be parsed
* @param relatedInfo if given, handle timestamps to open the stream in the popup player at the * @param relatedInfoService if given, handle hashtags to search for the term in the correct
* specific time, and hashtags to search for the term in the correct service * service
* @param disposables disposables created by the method are added here and their lifecycle * @param relatedStreamUrl if given, used alongside {@code relatedInfoService} to handle
* should be handled by the calling class * 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( private static void changeLinkIntents(@NonNull final TextView textView,
@NonNull final TextView textView,
@NonNull final CharSequence chars, @NonNull final CharSequence chars,
@Nullable final Info relatedInfo, @Nullable final StreamingService relatedInfoService,
@NonNull final CompositeDisposable disposables) { @Nullable final String relatedStreamUrl,
textView.setMovementMethod(LongPressLinkMovementMethod.getInstance()); @NonNull final CompositeDisposable disposables,
@Nullable final Consumer<TextView> onCompletion) {
disposables.add(Single.fromCallable(() -> { disposables.add(Single.fromCallable(() -> {
final Context context = textView.getContext(); final Context context = textView.getContext();
@ -176,26 +249,26 @@ public final class TextLinkifier {
textBlockLinked.removeSpan(span); textBlockLinked.removeSpan(span);
} }
if (relatedInfo != null) { // add click actions on plain text timestamps only for description of contents,
// add click actions on plain text timestamps only for description of // unneeded for meta-info or other TextViews
// contents, unneeded for meta-info or other TextViews if (relatedInfoService != null) {
if (relatedInfo instanceof StreamInfo) { if (relatedStreamUrl != null) {
addClickListenersOnTimestamps(context, textBlockLinked, addClickListenersOnTimestamps(context, textBlockLinked,
(StreamInfo) relatedInfo, disposables); relatedInfoService, relatedStreamUrl, disposables);
} }
addClickListenersOnHashtags(context, textBlockLinked, relatedInfoService);
addClickListenersOnHashtags(context, textBlockLinked, relatedInfo);
} }
return textBlockLinked; return textBlockLinked;
}).subscribeOn(Schedulers.computation()) }).subscribeOn(Schedulers.computation())
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
.subscribe( .subscribe(
textBlockLinked -> setTextViewCharSequence(textView, textBlockLinked), textBlockLinked ->
setTextViewCharSequence(textView, textBlockLinked, onCompletion),
throwable -> { throwable -> {
Log.e(TAG, "Unable to linkify text", throwable); Log.e(TAG, "Unable to linkify text", throwable);
// this should never happen, but if it does, just fallback to it // this should never happen, but if it does, just fallback to it
setTextViewCharSequence(textView, chars); setTextViewCharSequence(textView, chars, onCompletion);
})); }));
} }
@ -213,12 +286,12 @@ public final class TextLinkifier {
* @param context the {@link Context} to use * @param context the {@link Context} to use
* @param spannableDescription the {@link SpannableStringBuilder} with the text of the * @param spannableDescription the {@link SpannableStringBuilder} with the text of the
* content description * 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( private static void addClickListenersOnHashtags(
@NonNull final Context context, @NonNull final Context context,
@NonNull final SpannableStringBuilder spannableDescription, @NonNull final SpannableStringBuilder spannableDescription,
@NonNull final Info relatedInfo) { @NonNull final StreamingService relatedInfoService) {
final String descriptionText = spannableDescription.toString(); final String descriptionText = spannableDescription.toString();
final Matcher hashtagsMatches = HASHTAGS_PATTERN.matcher(descriptionText); final Matcher hashtagsMatches = HASHTAGS_PATTERN.matcher(descriptionText);
@ -231,8 +304,9 @@ public final class TextLinkifier {
// of an URL, already parsed before // of an URL, already parsed before
if (spannableDescription.getSpans(hashtagStart, hashtagEnd, if (spannableDescription.getSpans(hashtagStart, hashtagEnd,
LongPressClickableSpan.class).length == 0) { LongPressClickableSpan.class).length == 0) {
final int serviceId = relatedInfoService.getServiceId();
spannableDescription.setSpan( spannableDescription.setSpan(
new HashtagLongPressClickableSpan(context, parsedHashtag, relatedInfo), new HashtagLongPressClickableSpan(context, parsedHashtag, serviceId),
hashtagStart, hashtagEnd, 0); hashtagStart, hashtagEnd, 0);
} }
} }
@ -251,14 +325,16 @@ public final class TextLinkifier {
* @param context the {@link Context} to use * @param context the {@link Context} to use
* @param spannableDescription the {@link SpannableStringBuilder} with the text of the * @param spannableDescription the {@link SpannableStringBuilder} with the text of the
* content description * 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 * @param disposables disposables created by the method are added here and their
* lifecycle should be handled by the calling class * lifecycle should be handled by the calling class
*/ */
private static void addClickListenersOnTimestamps( private static void addClickListenersOnTimestamps(
@NonNull final Context context, @NonNull final Context context,
@NonNull final SpannableStringBuilder spannableDescription, @NonNull final SpannableStringBuilder spannableDescription,
@NonNull final StreamInfo streamInfo, @NonNull final StreamingService relatedInfoService,
@NonNull final String relatedStreamUrl,
@NonNull final CompositeDisposable disposables) { @NonNull final CompositeDisposable disposables) {
final String descriptionText = spannableDescription.toString(); final String descriptionText = spannableDescription.toString();
final Matcher timestampsMatches = TimestampExtractor.TIMESTAMPS_PATTERN.matcher( final Matcher timestampsMatches = TimestampExtractor.TIMESTAMPS_PATTERN.matcher(
@ -272,8 +348,9 @@ public final class TextLinkifier {
continue; continue;
} }
spannableDescription.setSpan(new TimestampLongPressClickableSpan( spannableDescription.setSpan(
context, descriptionText, disposables, streamInfo, timestampMatchDTO), new TimestampLongPressClickableSpan(context, descriptionText, disposables,
relatedInfoService, relatedStreamUrl, timestampMatchDTO),
timestampMatchDTO.timestampStart(), timestampMatchDTO.timestampStart(),
timestampMatchDTO.timestampEnd(), timestampMatchDTO.timestampEnd(),
0); 0);
@ -281,8 +358,12 @@ public final class TextLinkifier {
} }
private static void setTextViewCharSequence(@NonNull final TextView textView, private static void setTextViewCharSequence(@NonNull final TextView textView,
@Nullable final CharSequence charSequence) { @Nullable final CharSequence charSequence,
@Nullable final Consumer<TextView> onCompletion) {
textView.setText(charSequence); textView.setText(charSequence);
textView.setVisibility(View.VISIBLE); textView.setVisibility(View.VISIBLE);
if (onCompletion != null) {
onCompletion.accept(textView);
}
} }
} }

View File

@ -9,7 +9,6 @@ import androidx.annotation.NonNull;
import org.schabi.newpipe.extractor.ServiceList; import org.schabi.newpipe.extractor.ServiceList;
import org.schabi.newpipe.extractor.StreamingService; import org.schabi.newpipe.extractor.StreamingService;
import org.schabi.newpipe.extractor.stream.StreamInfo;
import org.schabi.newpipe.util.external_communication.ShareUtils; import org.schabi.newpipe.util.external_communication.ShareUtils;
import io.reactivex.rxjava3.disposables.CompositeDisposable; import io.reactivex.rxjava3.disposables.CompositeDisposable;
@ -23,7 +22,9 @@ final class TimestampLongPressClickableSpan extends LongPressClickableSpan {
@NonNull @NonNull
private final CompositeDisposable disposables; private final CompositeDisposable disposables;
@NonNull @NonNull
private final StreamInfo streamInfo; private final StreamingService relatedInfoService;
@NonNull
private final String relatedStreamUrl;
@NonNull @NonNull
private final TimestampExtractor.TimestampMatchDTO timestampMatchDTO; private final TimestampExtractor.TimestampMatchDTO timestampMatchDTO;
@ -31,41 +32,43 @@ final class TimestampLongPressClickableSpan extends LongPressClickableSpan {
@NonNull final Context context, @NonNull final Context context,
@NonNull final String descriptionText, @NonNull final String descriptionText,
@NonNull final CompositeDisposable disposables, @NonNull final CompositeDisposable disposables,
@NonNull final StreamInfo streamInfo, @NonNull final StreamingService relatedInfoService,
@NonNull final String relatedStreamUrl,
@NonNull final TimestampExtractor.TimestampMatchDTO timestampMatchDTO) { @NonNull final TimestampExtractor.TimestampMatchDTO timestampMatchDTO) {
this.context = context; this.context = context;
this.descriptionText = descriptionText; this.descriptionText = descriptionText;
this.disposables = disposables; this.disposables = disposables;
this.streamInfo = streamInfo; this.relatedInfoService = relatedInfoService;
this.relatedStreamUrl = relatedStreamUrl;
this.timestampMatchDTO = timestampMatchDTO; this.timestampMatchDTO = timestampMatchDTO;
} }
@Override @Override
public void onClick(@NonNull final View view) { public void onClick(@NonNull final View view) {
playOnPopup(context, streamInfo.getUrl(), streamInfo.getService(), playOnPopup(context, relatedStreamUrl, relatedInfoService,
timestampMatchDTO.seconds(), disposables); timestampMatchDTO.seconds(), disposables);
} }
@Override @Override
public void onLongClick(@NonNull final View view) { public void onLongClick(@NonNull final View view) {
ShareUtils.copyToClipboard(context, ShareUtils.copyToClipboard(context, getTimestampTextToCopy(
getTimestampTextToCopy(streamInfo, descriptionText, timestampMatchDTO)); relatedInfoService, relatedStreamUrl, descriptionText, timestampMatchDTO));
} }
@NonNull @NonNull
private static String getTimestampTextToCopy( private static String getTimestampTextToCopy(
@NonNull final StreamInfo relatedInfo, @NonNull final StreamingService relatedInfoService,
@NonNull final String relatedStreamUrl,
@NonNull final String descriptionText, @NonNull final String descriptionText,
@NonNull final TimestampExtractor.TimestampMatchDTO timestampMatchDTO) { @NonNull final TimestampExtractor.TimestampMatchDTO timestampMatchDTO) {
// TODO: use extractor methods to get timestamps when this feature will be implemented in it // TODO: use extractor methods to get timestamps when this feature will be implemented in it
final StreamingService streamingService = relatedInfo.getService(); if (relatedInfoService == ServiceList.YouTube) {
if (streamingService == ServiceList.YouTube) { return relatedStreamUrl + "&t=" + timestampMatchDTO.seconds();
return relatedInfo.getUrl() + "&t=" + timestampMatchDTO.seconds(); } else if (relatedInfoService == ServiceList.SoundCloud
} else if (streamingService == ServiceList.SoundCloud || relatedInfoService == ServiceList.MediaCCC) {
|| streamingService == ServiceList.MediaCCC) { return relatedStreamUrl + "#t=" + timestampMatchDTO.seconds();
return relatedInfo.getUrl() + "#t=" + timestampMatchDTO.seconds(); } else if (relatedInfoService == ServiceList.PeerTube) {
} else if (streamingService == ServiceList.PeerTube) { return relatedStreamUrl + "?start=" + timestampMatchDTO.seconds();
return relatedInfo.getUrl() + "?start=" + timestampMatchDTO.seconds();
} }
// Return timestamp text for other services // Return timestamp text for other services