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 5f1cbc365..49ac3ef01 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 @@ -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); } diff --git a/app/src/main/java/org/schabi/newpipe/util/CommentTextOnTouchListener.java b/app/src/main/java/org/schabi/newpipe/util/CommentTextOnTouchListener.java index f48d39da0..51d8539f4 100644 --- a/app/src/main/java/org/schabi/newpipe/util/CommentTextOnTouchListener.java +++ b/app/src/main/java/org/schabi/newpipe/util/CommentTextOnTouchListener.java @@ -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) { 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 af7cafc15..0b8dbb751 100644 --- a/app/src/main/java/org/schabi/newpipe/util/ExtractorHelper.java +++ b/app/src/main/java/org/schabi/newpipe/util/ExtractorHelper.java @@ -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); } } diff --git a/app/src/main/java/org/schabi/newpipe/util/TextLinkifier.java b/app/src/main/java/org/schabi/newpipe/util/TextLinkifier.java index 4fc3608bf..37bc80f72 100644 --- a/app/src/main/java/org/schabi/newpipe/util/TextLinkifier.java +++ b/app/src/main/java/org/schabi/newpipe/util/TextLinkifier.java @@ -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. *

* 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. *

* 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. *

* 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. + *

+ * 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. *

- * 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. *

* 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()) diff --git a/app/src/main/java/org/schabi/newpipe/util/URLHandler.java b/app/src/main/java/org/schabi/newpipe/util/URLHandler.java index 761a91e50..17555f0f9 100644 --- a/app/src/main/java/org/schabi/newpipe/util/URLHandler.java +++ b/app/src/main/java/org/schabi/newpipe/util/URLHandler.java @@ -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;