Update NewPipeExtractor and properly linkify comments

This commit is contained in:
Stypox 2023-01-05 19:01:38 +01:00
parent 2db29187f4
commit 489df0ed7d
No known key found for this signature in database
GPG key ID: 4BDF1B40A49FDD23
8 changed files with 278 additions and 217 deletions

View file

@ -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 final class ExtractorHelper {
}
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);
}
}

View file

@ -2,51 +2,37 @@ package org.schabi.newpipe.util.text;
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;
}
}

View file

@ -5,7 +5,6 @@ import android.view.View;
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

View file

@ -12,11 +12,12 @@ import androidx.annotation.NonNull;
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<TextView> SET_LINK_MOVEMENT_METHOD =
v -> v.setMovementMethod(LongPressLinkMovementMethod.getInstance());
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.
*
* <p>
* 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)}.
* </p>
*
* @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<TextView> onCompletion) {
changeLinkIntents(
textView, HtmlCompat.fromHtml(htmlBlock, htmlCompatFlag), relatedInfoService,
relatedStreamUrl, disposables, onCompletion);
}
/**
* Create links for contents with a plain text description.
*
* <p>
* 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)}.
* </p>
*
* @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<TextView> 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.
*
* <p>
* 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)}.
* </p>
*
* @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<TextView> 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 final class TextLinkifier {
* 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.
* </p>
*
* <p>
@ -141,20 +209,25 @@ public final class TextLinkifier {
* before opening a web link.
* </p>
*
* @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<TextView> onCompletion) {
disposables.add(Single.fromCallable(() -> {
final Context context = textView.getContext();
@ -176,26 +249,26 @@ public final class TextLinkifier {
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 @@ public final class TextLinkifier {
* @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 @@ public final class TextLinkifier {
// 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 @@ public final class TextLinkifier {
* @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 @@ public final class TextLinkifier {
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 @@ public final class TextLinkifier {
}
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.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.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