Initial work: add support for opening timestamps in plain text descriptions

This commit adds support for opening plain text timestamps by parsing the description text using a regular expression, add a click listener for each timestamp which opens the popup player at the indicated time in the timestamp.
In order to do this, playOnPopup method of the URLHandler class. Also, handleUrl method of this class has been renamed to canHandleUrl.
This commit is contained in:
TiA4f8R 2021-03-13 12:20:45 +01:00
parent 4031777606
commit ae9349e36c
No known key found for this signature in database
GPG Key ID: E6D3E7F5949450DD
5 changed files with 150 additions and 40 deletions

View File

@ -19,6 +19,7 @@ import org.schabi.newpipe.R;
import org.schabi.newpipe.databinding.FragmentDescriptionBinding;
import org.schabi.newpipe.databinding.ItemMetadataBinding;
import org.schabi.newpipe.databinding.ItemMetadataTagsBinding;
import org.schabi.newpipe.extractor.StreamingService;
import org.schabi.newpipe.extractor.stream.Description;
import org.schabi.newpipe.extractor.stream.StreamInfo;
import org.schabi.newpipe.util.Localization;
@ -131,19 +132,24 @@ public class DescriptionFragment extends BaseFragment {
private void loadDescriptionContent() {
final Description description = streamInfo.getDescription();
final String contentUrl = streamInfo.getUrl();
final StreamingService service = streamInfo.getService();
switch (description.getType()) {
case Description.HTML:
descriptionDisposable = TextLinkifier.createLinksFromHtmlBlock(requireContext(),
description.getContent(), binding.detailDescriptionView,
HtmlCompat.FROM_HTML_MODE_LEGACY);
service, contentUrl, HtmlCompat.FROM_HTML_MODE_LEGACY);
break;
case Description.MARKDOWN:
descriptionDisposable = TextLinkifier.createLinksFromMarkdownText(requireContext(),
description.getContent(), binding.detailDescriptionView);
description.getContent(), binding.detailDescriptionView,
service, contentUrl);
break;
case Description.PLAIN_TEXT: default:
descriptionDisposable = TextLinkifier.createLinksFromPlainText(requireContext(),
description.getContent(), binding.detailDescriptionView);
description.getContent(), binding.detailDescriptionView,
service, contentUrl);
break;
}
}
@ -199,7 +205,7 @@ public class DescriptionFragment extends BaseFragment {
if (linkifyContent) {
TextLinkifier.createLinksFromPlainText(requireContext(),
content, itemBinding.metadataContentView);
content, itemBinding.metadataContentView, null, null);
} else {
itemBinding.metadataContentView.setText(content);
}

View File

@ -47,7 +47,7 @@ public class CommentTextOnTouchListener implements View.OnTouchListener {
if (action == MotionEvent.ACTION_UP) {
boolean handled = false;
if (link[0] instanceof URLSpan) {
handled = URLHandler.handleUrl(v.getContext(),
handled = URLHandler.canHandleUrl(v.getContext(),
((URLSpan) link[0]).getURL(), 1);
}
if (!handled) {

View File

@ -311,7 +311,8 @@ public final class ExtractorHelper {
metaInfoSeparator.setVisibility(View.VISIBLE);
return TextLinkifier.createLinksFromHtmlBlock(context, stringBuilder.toString(),
metaInfoTextView, HtmlCompat.FROM_HTML_SEPARATOR_LINE_BREAK_HEADING);
metaInfoTextView, null, null,
HtmlCompat.FROM_HTML_SEPARATOR_LINE_BREAK_HEADING);
}
}

View File

@ -13,6 +13,11 @@ import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.core.text.HtmlCompat;
import org.schabi.newpipe.extractor.StreamingService;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import io.noties.markwon.Markwon;
import io.noties.markwon.linkify.LinkifyPlugin;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
@ -20,6 +25,8 @@ import io.reactivex.rxjava3.core.Single;
import io.reactivex.rxjava3.disposables.Disposable;
import io.reactivex.rxjava3.schedulers.Schedulers;
import static org.schabi.newpipe.util.URLHandler.playOnPopup;
public final class TextLinkifier {
public static final String TAG = TextLinkifier.class.getSimpleName();
@ -30,92 +37,173 @@ public final class TextLinkifier {
* Create web links for contents with an HTML description.
* <p>
* This will call
* {@link TextLinkifier#changeIntentsOfDescriptionLinks(Context, CharSequence, TextView)}
* {@link TextLinkifier#changeIntentsOfDescriptionLinks(Context, CharSequence, TextView,
* StreamingService, String)}
* after having linked the URLs with {@link HtmlCompat#fromHtml(String, int)}.
*
* @param context the context to use
* @param htmlBlock the htmlBlock to be linked
* @param textView the TextView to set the htmlBlock linked
* @param htmlCompatFlag the int flag to be set when {@link HtmlCompat#fromHtml(String, int)}
* will be called
* @param context the context to use
* @param htmlBlock the htmlBlock to be linked
* @param textView the TextView to set the htmlBlock linked
* @param streamingService the {@link StreamingService} of the content
* @param contentUrl the URL of the content
* @param htmlCompatFlag the int flag to be set when {@link HtmlCompat#fromHtml(String, int)}
* will be called
* @return a disposable to be stored somewhere and disposed when activity/fragment is destroyed
*/
public static Disposable createLinksFromHtmlBlock(final Context context,
final String htmlBlock,
final TextView textView,
final StreamingService streamingService,
final String contentUrl,
final int htmlCompatFlag) {
return changeIntentsOfDescriptionLinks(context,
HtmlCompat.fromHtml(htmlBlock, htmlCompatFlag), textView);
HtmlCompat.fromHtml(htmlBlock, htmlCompatFlag), textView, streamingService,
contentUrl);
}
/**
* Create web links for contents with a plain text description.
* <p>
* This will call
* {@link TextLinkifier#changeIntentsOfDescriptionLinks(Context, CharSequence, TextView)}
* {@link TextLinkifier#changeIntentsOfDescriptionLinks(Context, CharSequence, TextView,
* StreamingService, String)}
* after having linked the URLs with {@link TextView#setAutoLinkMask(int)} and
* {@link TextView#setText(CharSequence, TextView.BufferType)}.
*
* @param context the context to use
* @param plainTextBlock the block of plain text to be linked
* @param textView the TextView to set the plain text block linked
* @param context the context to use
* @param plainTextBlock the block of plain text to be linked
* @param textView the TextView to set the plain text block linked
* @param streamingService the {@link StreamingService} of the content
* @param contentUrl the URL of the content
* @return a disposable to be stored somewhere and disposed when activity/fragment is destroyed
*/
public static Disposable createLinksFromPlainText(final Context context,
final String plainTextBlock,
final TextView textView) {
final TextView textView,
final StreamingService streamingService,
final String contentUrl) {
textView.setAutoLinkMask(Linkify.WEB_URLS);
textView.setText(plainTextBlock, TextView.BufferType.SPANNABLE);
return changeIntentsOfDescriptionLinks(context, textView.getText(), textView);
return changeIntentsOfDescriptionLinks(context, textView.getText(), textView,
streamingService, contentUrl);
}
/**
* Create web links for contents with a markdown description.
* <p>
* This will call
* {@link TextLinkifier#changeIntentsOfDescriptionLinks(Context, CharSequence, TextView)}
* {@link TextLinkifier#changeIntentsOfDescriptionLinks(Context, CharSequence, TextView,
* StreamingService, String)}
* after creating an {@link Markwon} object and using
* {@link Markwon#setMarkdown(TextView, String)}.
*
* @param context the context to use
* @param markdownBlock the block of markdown text to be linked
* @param textView the TextView to set the plain text block linked
* @param context the context to use
* @param markdownBlock the block of markdown text to be linked
* @param textView the TextView to set the plain text block linked
* @param streamingService the {@link StreamingService} of the content
* @param contentUrl the URL of the content
* @return a disposable to be stored somewhere and disposed when activity/fragment is destroyed
*/
public static Disposable createLinksFromMarkdownText(final Context context,
final String markdownBlock,
final TextView textView) {
final TextView textView,
final StreamingService streamingService,
final String contentUrl) {
final Markwon markwon = Markwon.builder(context).usePlugin(LinkifyPlugin.create()).build();
markwon.setMarkdown(textView, markdownBlock);
return changeIntentsOfDescriptionLinks(context, textView.getText(), textView);
return changeIntentsOfDescriptionLinks(context, textView.getText(), textView,
streamingService, contentUrl);
}
private static final Pattern TIMESTAMPS_PATTERN_IN_PLAIN_TEXT =
Pattern.compile("(?:([0-5]?[0-9]):)?([0-5]?[0-9]):([0-5][0-9])");
/**
* Add click listeners which opens the popup player on timestamps in a plain text.
* <p>
* This method finds all timestamps in the {@link SpannableStringBuilder} of the description
* using a regular expression, adds for each a {@link ClickableSpan} which opens the popup
* player at the time indicated in the timestamps.
*
* @param context the context to use
* @param spannableDescription the SpannableStringBuilder with the text of the
* content description
* @param contentUrl the URL of the content
* @param streamingService the {@link StreamingService} of the content
*/
private static void addClickListenersOnTimestamps(final Context context,
final SpannableStringBuilder
spannableDescription,
final String contentUrl,
final StreamingService streamingService) {
final String descriptionText = spannableDescription.toString();
final Matcher timestampMatches = TIMESTAMPS_PATTERN_IN_PLAIN_TEXT.matcher(descriptionText);
while (timestampMatches.find()) {
final int timestampStart = timestampMatches.start(0);
final int timestampEnd = timestampMatches.end(0);
final String parsedTimestamp = descriptionText.substring(timestampStart, timestampEnd);
final String[] timestampParts = parsedTimestamp.split(":");
final int seconds;
if (timestampParts.length == 3) { // timestamp format: XX:XX:XX
seconds = Integer.parseInt(timestampParts[0]) * 3600 + Integer.parseInt(
timestampParts[1]) * 60 + Integer.parseInt(timestampParts[2]);
spannableDescription.setSpan(new ClickableSpan() {
@Override
public void onClick(@NonNull final View view) {
playOnPopup(context, contentUrl, streamingService, seconds);
}
}, timestampStart, timestampEnd, 0);
} else if (timestampParts.length == 2) { // timestamp format: XX:XX
seconds = Integer.parseInt(timestampParts[0]) * 60 + Integer.parseInt(
timestampParts[1]);
spannableDescription.setSpan(new ClickableSpan() {
@Override
public void onClick(@NonNull final View view) {
playOnPopup(context, contentUrl, streamingService, seconds);
}
}, timestampStart, timestampEnd, 0);
}
}
}
/**
* Change links generated by libraries in the description of a content to a custom link action.
* Change links generated by libraries in the description of a content to a custom link action
* and add click listeners on timestamps in this description.
* <p>
* Instead of using an {@link android.content.Intent#ACTION_VIEW} intent in the description of a
* content, this method will parse the {@link CharSequence} and replace all current web links
* Instead of using an {@link android.content.Intent#ACTION_VIEW} intent in the description of
* a content, this method will parse the {@link CharSequence} and replace all current web links
* with {@link ShareUtils#openUrlInBrowser(Context, String, boolean)}.
* 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, String,
* StreamingService)} method.
* <p>
* This method is required in order to intercept links and e.g. show a confirmation dialog
* before opening a web link.
*
* @param context the context to use
* @param chars the CharSequence to be parsed
* @param textView the TextView in which the converted CharSequence will be applied
* @param context the context to use
* @param chars the CharSequence to be parsed
* @param textView the TextView in which the converted CharSequence will be applied
* @param streamingService the {@link StreamingService} of the content
* @param contentUrl the URL of the content
* @return a disposable to be stored somewhere and disposed when activity/fragment is destroyed
*/
private static Disposable changeIntentsOfDescriptionLinks(final Context context,
final CharSequence chars,
final TextView textView) {
final TextView textView,
final StreamingService
streamingService,
final String contentUrl) {
return Single.fromCallable(() -> {
// add custom click actions on web links
final SpannableStringBuilder textBlockLinked = new SpannableStringBuilder(chars);
final URLSpan[] urls = textBlockLinked.getSpans(0, chars.length(), URLSpan.class);
for (final URLSpan span : urls) {
final ClickableSpan clickableSpan = new ClickableSpan() {
public void onClick(@NonNull final View view) {
if (!URLHandler.handleUrl(context, span.getURL(), 0)) {
if (!URLHandler.canHandleUrl(context, span.getURL(), 0)) {
ShareUtils.openUrlInBrowser(context, span.getURL(), false);
}
}
@ -126,6 +214,13 @@ public final class TextLinkifier {
textBlockLinked.removeSpan(span);
}
// add click actions on plain text timestamps only for description of contents,
// unneeded for metainfo TextViews
if (contentUrl != null || streamingService != null) {
addClickListenersOnTimestamps(context, textBlockLinked, contentUrl,
streamingService);
}
return textBlockLinked;
}).subscribeOn(Schedulers.computation())
.observeOn(AndroidSchedulers.mainThread())

View File

@ -39,25 +39,31 @@ public final class URLHandler {
* @param timestampType the type of timestamp
* @return true if the URL can be handled by NewPipe, false if it cannot
*/
public static boolean handleUrl(final Context context, final String url, final int timestampType) {
public static boolean canHandleUrl(final Context context,
final String url,
final int timestampType) {
String matchedUrl = "";
int seconds = -1;
final Pattern TIMESTAMP_PATTERN;
final Pattern timestampPattern;
if (timestampType == 0) {
TIMESTAMP_PATTERN = Pattern.compile("(.*)&t=(\\d+)");
timestampPattern = Pattern.compile("(.*)&t=(\\d+)");
} else if (timestampType == 1) {
TIMESTAMP_PATTERN = Pattern.compile("(.*)#timestamp=(\\d+)");
timestampPattern = Pattern.compile("(.*)#timestamp=(\\d+)");
} else {
return false;
}
final Matcher matcher = TIMESTAMP_PATTERN.matcher(url);
final Matcher matcher = timestampPattern.matcher(url);
if (matcher.matches()) {
matchedUrl = matcher.group(1);
seconds = Integer.parseInt(matcher.group(2));
}
if (matchedUrl == null || matchedUrl.isEmpty()) {
return false;
}
final StreamingService service;
final StreamingService.LinkType linkType;
@ -88,8 +94,10 @@ public final class URLHandler {
* @param seconds the position in seconds at which the floating player will start
* @return true if the playback of the content has successfully started or false if not
*/
private static boolean playOnPopup(final Context context, final String url,
final StreamingService service, final int seconds) {
public static boolean playOnPopup(final Context context,
final String url,
final StreamingService service,
final int seconds) {
final LinkHandlerFactory factory = service.getStreamLHFactory();
final String cleanUrl;