mirror of
https://github.com/TeamNewPipe/NewPipe.git
synced 2024-11-26 13:02:34 +01:00
Merge pull request #7725 from AudricV/add-long-press-actions-on-hashtags-and-links-in-descriptions
Add long press action on hashtags and web links in descriptions
This commit is contained in:
commit
2db29187f4
@ -3,6 +3,7 @@ package org.schabi.newpipe.fragments.detail;
|
|||||||
import static android.text.TextUtils.isEmpty;
|
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 android.os.Bundle;
|
import android.os.Bundle;
|
||||||
import android.view.LayoutInflater;
|
import android.view.LayoutInflater;
|
||||||
@ -28,7 +29,7 @@ import org.schabi.newpipe.extractor.stream.StreamInfo;
|
|||||||
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.external_communication.ShareUtils;
|
import org.schabi.newpipe.util.external_communication.ShareUtils;
|
||||||
import org.schabi.newpipe.util.external_communication.TextLinkifier;
|
import org.schabi.newpipe.util.text.TextLinkifier;
|
||||||
|
|
||||||
import icepick.State;
|
import icepick.State;
|
||||||
import io.reactivex.rxjava3.disposables.CompositeDisposable;
|
import io.reactivex.rxjava3.disposables.CompositeDisposable;
|
||||||
@ -134,7 +135,8 @@ public class DescriptionFragment extends BaseFragment {
|
|||||||
TextLinkifier.createLinksFromMarkdownText(binding.detailDescriptionView,
|
TextLinkifier.createLinksFromMarkdownText(binding.detailDescriptionView,
|
||||||
description.getContent(), streamInfo, descriptionDisposables);
|
description.getContent(), streamInfo, descriptionDisposables);
|
||||||
break;
|
break;
|
||||||
case Description.PLAIN_TEXT: default:
|
case Description.PLAIN_TEXT:
|
||||||
|
default:
|
||||||
TextLinkifier.createLinksFromPlainText(binding.detailDescriptionView,
|
TextLinkifier.createLinksFromPlainText(binding.detailDescriptionView,
|
||||||
description.getContent(), streamInfo, descriptionDisposables);
|
description.getContent(), streamInfo, descriptionDisposables);
|
||||||
break;
|
break;
|
||||||
@ -144,30 +146,30 @@ public class DescriptionFragment extends BaseFragment {
|
|||||||
|
|
||||||
private void setupMetadata(final LayoutInflater inflater,
|
private void setupMetadata(final LayoutInflater inflater,
|
||||||
final LinearLayout layout) {
|
final LinearLayout layout) {
|
||||||
addMetadataItem(inflater, layout, false,
|
addMetadataItem(inflater, layout, false, R.string.metadata_category,
|
||||||
R.string.metadata_category, streamInfo.getCategory());
|
streamInfo.getCategory());
|
||||||
|
|
||||||
addMetadataItem(inflater, layout, false,
|
addMetadataItem(inflater, layout, false, R.string.metadata_licence,
|
||||||
R.string.metadata_licence, streamInfo.getLicence());
|
streamInfo.getLicence());
|
||||||
|
|
||||||
addPrivacyMetadataItem(inflater, layout);
|
addPrivacyMetadataItem(inflater, layout);
|
||||||
|
|
||||||
if (streamInfo.getAgeLimit() != NO_AGE_LIMIT) {
|
if (streamInfo.getAgeLimit() != NO_AGE_LIMIT) {
|
||||||
addMetadataItem(inflater, layout, false,
|
addMetadataItem(inflater, layout, false, R.string.metadata_age_limit,
|
||||||
R.string.metadata_age_limit, String.valueOf(streamInfo.getAgeLimit()));
|
String.valueOf(streamInfo.getAgeLimit()));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (streamInfo.getLanguageInfo() != null) {
|
if (streamInfo.getLanguageInfo() != null) {
|
||||||
addMetadataItem(inflater, layout, false,
|
addMetadataItem(inflater, layout, false, R.string.metadata_language,
|
||||||
R.string.metadata_language, streamInfo.getLanguageInfo().getDisplayLanguage());
|
streamInfo.getLanguageInfo().getDisplayLanguage(getAppLocale(getContext())));
|
||||||
}
|
}
|
||||||
|
|
||||||
addMetadataItem(inflater, layout, true,
|
addMetadataItem(inflater, layout, true, R.string.metadata_support,
|
||||||
R.string.metadata_support, streamInfo.getSupportInfo());
|
streamInfo.getSupportInfo());
|
||||||
addMetadataItem(inflater, layout, true,
|
addMetadataItem(inflater, layout, true, R.string.metadata_host,
|
||||||
R.string.metadata_host, streamInfo.getHost());
|
streamInfo.getHost());
|
||||||
addMetadataItem(inflater, layout, true,
|
addMetadataItem(inflater, layout, true, R.string.metadata_thumbnail_url,
|
||||||
R.string.metadata_thumbnail_url, streamInfo.getThumbnailUrl());
|
streamInfo.getThumbnailUrl());
|
||||||
|
|
||||||
addTagsMetadataItem(inflater, layout);
|
addTagsMetadataItem(inflater, layout);
|
||||||
}
|
}
|
||||||
@ -191,12 +193,14 @@ public class DescriptionFragment extends BaseFragment {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (linkifyContent) {
|
if (linkifyContent) {
|
||||||
TextLinkifier.createLinksFromPlainText(itemBinding.metadataContentView, content, null,
|
TextLinkifier.createLinksFromPlainText(itemBinding.metadataContentView, content,
|
||||||
descriptionDisposables);
|
null, descriptionDisposables);
|
||||||
} else {
|
} else {
|
||||||
itemBinding.metadataContentView.setText(content);
|
itemBinding.metadataContentView.setText(content);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
itemBinding.metadataContentView.setClickable(true);
|
||||||
|
|
||||||
layout.addView(itemBinding.getRoot());
|
layout.addView(itemBinding.getRoot());
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -245,14 +249,15 @@ public class DescriptionFragment extends BaseFragment {
|
|||||||
case INTERNAL:
|
case INTERNAL:
|
||||||
contentRes = R.string.metadata_privacy_internal;
|
contentRes = R.string.metadata_privacy_internal;
|
||||||
break;
|
break;
|
||||||
case OTHER: default:
|
case OTHER:
|
||||||
|
default:
|
||||||
contentRes = 0;
|
contentRes = 0;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (contentRes != 0) {
|
if (contentRes != 0) {
|
||||||
addMetadataItem(inflater, layout, false,
|
addMetadataItem(inflater, layout, false, R.string.metadata_privacy,
|
||||||
R.string.metadata_privacy, getString(contentRes));
|
getString(contentRes));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -20,13 +20,13 @@ import org.schabi.newpipe.extractor.InfoItem;
|
|||||||
import org.schabi.newpipe.extractor.comments.CommentsInfoItem;
|
import org.schabi.newpipe.extractor.comments.CommentsInfoItem;
|
||||||
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.CommentTextOnTouchListener;
|
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.external_communication.TimestampExtractor;
|
import org.schabi.newpipe.util.text.TimestampExtractor;
|
||||||
|
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
|
|
||||||
|
@ -51,7 +51,7 @@ import org.schabi.newpipe.extractor.search.SearchInfo;
|
|||||||
import org.schabi.newpipe.extractor.stream.StreamInfo;
|
import org.schabi.newpipe.extractor.stream.StreamInfo;
|
||||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem;
|
import org.schabi.newpipe.extractor.stream.StreamInfoItem;
|
||||||
import org.schabi.newpipe.extractor.suggestion.SuggestionExtractor;
|
import org.schabi.newpipe.extractor.suggestion.SuggestionExtractor;
|
||||||
import org.schabi.newpipe.util.external_communication.TextLinkifier;
|
import org.schabi.newpipe.util.text.TextLinkifier;
|
||||||
|
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
@ -313,11 +313,16 @@ public final class ShareUtils {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
clipboardManager.setPrimaryClip(ClipData.newPlainText(null, text));
|
clipboardManager.setPrimaryClip(ClipData.newPlainText(null, text));
|
||||||
if (Build.VERSION.SDK_INT < 33) {
|
if (Build.VERSION.SDK_INT < 33) {
|
||||||
// Android 13 has its own "copied to clipboard" dialog
|
// Android 13 has its own "copied to clipboard" dialog
|
||||||
Toast.makeText(context, R.string.msg_copied, Toast.LENGTH_SHORT).show();
|
Toast.makeText(context, R.string.msg_copied, Toast.LENGTH_SHORT).show();
|
||||||
}
|
}
|
||||||
|
} catch (final Exception e) {
|
||||||
|
Log.e(TAG, "Error when trying to copy text to clipboard", e);
|
||||||
|
Toast.makeText(context, R.string.msg_failed_to_copy, Toast.LENGTH_SHORT).show();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
package org.schabi.newpipe.util;
|
package org.schabi.newpipe.util.text;
|
||||||
|
|
||||||
|
import static org.schabi.newpipe.util.text.TouchUtils.getOffsetForHorizontalLine;
|
||||||
|
|
||||||
import android.text.Layout;
|
|
||||||
import android.text.Selection;
|
import android.text.Selection;
|
||||||
import android.text.Spannable;
|
import android.text.Spannable;
|
||||||
import android.text.Spanned;
|
import android.text.Spanned;
|
||||||
@ -11,7 +12,6 @@ import android.view.View;
|
|||||||
import android.widget.TextView;
|
import android.widget.TextView;
|
||||||
|
|
||||||
import org.schabi.newpipe.util.external_communication.ShareUtils;
|
import org.schabi.newpipe.util.external_communication.ShareUtils;
|
||||||
import org.schabi.newpipe.util.external_communication.InternalUrlsHandler;
|
|
||||||
|
|
||||||
import io.reactivex.rxjava3.disposables.CompositeDisposable;
|
import io.reactivex.rxjava3.disposables.CompositeDisposable;
|
||||||
|
|
||||||
@ -30,23 +30,9 @@ public class CommentTextOnTouchListener implements View.OnTouchListener {
|
|||||||
|
|
||||||
final int action = event.getAction();
|
final int action = event.getAction();
|
||||||
|
|
||||||
if (action == MotionEvent.ACTION_UP
|
if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_DOWN) {
|
||||||
|| action == MotionEvent.ACTION_DOWN) {
|
final int offset = getOffsetForHorizontalLine(widget, event);
|
||||||
int x = (int) event.getX();
|
final ClickableSpan[] link = buffer.getSpans(offset, offset, ClickableSpan.class);
|
||||||
int y = (int) event.getY();
|
|
||||||
|
|
||||||
x -= widget.getTotalPaddingLeft();
|
|
||||||
y -= widget.getTotalPaddingTop();
|
|
||||||
|
|
||||||
x += widget.getScrollX();
|
|
||||||
y += widget.getScrollY();
|
|
||||||
|
|
||||||
final Layout layout = widget.getLayout();
|
|
||||||
final int line = layout.getLineForVertical(y);
|
|
||||||
final int off = layout.getOffsetForHorizontal(line, x);
|
|
||||||
|
|
||||||
final ClickableSpan[] link = buffer.getSpans(off, off,
|
|
||||||
ClickableSpan.class);
|
|
||||||
|
|
||||||
if (link.length != 0) {
|
if (link.length != 0) {
|
||||||
if (action == MotionEvent.ACTION_UP) {
|
if (action == MotionEvent.ACTION_UP) {
|
||||||
@ -58,8 +44,7 @@ public class CommentTextOnTouchListener implements View.OnTouchListener {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (action == MotionEvent.ACTION_DOWN) {
|
} else if (action == MotionEvent.ACTION_DOWN) {
|
||||||
Selection.setSelection(buffer,
|
Selection.setSelection(buffer, buffer.getSpanStart(link[0]),
|
||||||
buffer.getSpanStart(link[0]),
|
|
||||||
buffer.getSpanEnd(link[0]));
|
buffer.getSpanEnd(link[0]));
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
@ -0,0 +1,38 @@
|
|||||||
|
package org.schabi.newpipe.util.text;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
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;
|
||||||
|
|
||||||
|
final class HashtagLongPressClickableSpan extends LongPressClickableSpan {
|
||||||
|
|
||||||
|
@NonNull
|
||||||
|
private final Context context;
|
||||||
|
@NonNull
|
||||||
|
private final String parsedHashtag;
|
||||||
|
@NonNull
|
||||||
|
private final Info relatedInfo;
|
||||||
|
|
||||||
|
HashtagLongPressClickableSpan(@NonNull final Context context,
|
||||||
|
@NonNull final String parsedHashtag,
|
||||||
|
@NonNull final Info relatedInfo) {
|
||||||
|
this.context = context;
|
||||||
|
this.parsedHashtag = parsedHashtag;
|
||||||
|
this.relatedInfo = relatedInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onClick(@NonNull final View view) {
|
||||||
|
NavigationHelper.openSearch(context, relatedInfo.getServiceId(), parsedHashtag);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onLongClick(@NonNull final View view) {
|
||||||
|
ShareUtils.copyToClipboard(context, parsedHashtag);
|
||||||
|
}
|
||||||
|
}
|
@ -1,4 +1,4 @@
|
|||||||
package org.schabi.newpipe.util.external_communication;
|
package org.schabi.newpipe.util.text;
|
||||||
|
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
import android.util.Log;
|
import android.util.Log;
|
@ -0,0 +1,12 @@
|
|||||||
|
package org.schabi.newpipe.util.text;
|
||||||
|
|
||||||
|
import android.text.style.ClickableSpan;
|
||||||
|
import android.view.View;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
|
||||||
|
public abstract class LongPressClickableSpan extends ClickableSpan {
|
||||||
|
|
||||||
|
public abstract void onLongClick(@NonNull View view);
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,77 @@
|
|||||||
|
package org.schabi.newpipe.util.text;
|
||||||
|
|
||||||
|
import static org.schabi.newpipe.util.text.TouchUtils.getOffsetForHorizontalLine;
|
||||||
|
|
||||||
|
import android.os.Handler;
|
||||||
|
import android.os.Looper;
|
||||||
|
import android.text.Selection;
|
||||||
|
import android.text.Spannable;
|
||||||
|
import android.text.method.LinkMovementMethod;
|
||||||
|
import android.text.method.MovementMethod;
|
||||||
|
import android.view.MotionEvent;
|
||||||
|
import android.view.ViewConfiguration;
|
||||||
|
import android.widget.TextView;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
|
||||||
|
// Class adapted from https://stackoverflow.com/a/31786969
|
||||||
|
|
||||||
|
public class LongPressLinkMovementMethod extends LinkMovementMethod {
|
||||||
|
|
||||||
|
private static final int LONG_PRESS_TIME = ViewConfiguration.getLongPressTimeout();
|
||||||
|
|
||||||
|
private static LongPressLinkMovementMethod instance;
|
||||||
|
|
||||||
|
private Handler longClickHandler;
|
||||||
|
private boolean isLongPressed = false;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean onTouchEvent(@NonNull final TextView widget,
|
||||||
|
@NonNull final Spannable buffer,
|
||||||
|
@NonNull final MotionEvent event) {
|
||||||
|
final int action = event.getAction();
|
||||||
|
|
||||||
|
if (action == MotionEvent.ACTION_CANCEL && longClickHandler != null) {
|
||||||
|
longClickHandler.removeCallbacksAndMessages(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_DOWN) {
|
||||||
|
final int offset = getOffsetForHorizontalLine(widget, event);
|
||||||
|
final LongPressClickableSpan[] link = buffer.getSpans(offset, offset,
|
||||||
|
LongPressClickableSpan.class);
|
||||||
|
|
||||||
|
if (link.length != 0) {
|
||||||
|
if (action == MotionEvent.ACTION_UP) {
|
||||||
|
if (longClickHandler != null) {
|
||||||
|
longClickHandler.removeCallbacksAndMessages(null);
|
||||||
|
}
|
||||||
|
if (!isLongPressed) {
|
||||||
|
link[0].onClick(widget);
|
||||||
|
}
|
||||||
|
isLongPressed = false;
|
||||||
|
} else {
|
||||||
|
Selection.setSelection(buffer, buffer.getSpanStart(link[0]),
|
||||||
|
buffer.getSpanEnd(link[0]));
|
||||||
|
if (longClickHandler != null) {
|
||||||
|
longClickHandler.postDelayed(() -> {
|
||||||
|
link[0].onLongClick(widget);
|
||||||
|
isLongPressed = true;
|
||||||
|
}, LONG_PRESS_TIME);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return super.onTouchEvent(widget, buffer, event);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static MovementMethod getInstance() {
|
||||||
|
if (instance == null) {
|
||||||
|
instance = new LongPressLinkMovementMethod();
|
||||||
|
instance.longClickHandler = new Handler(Looper.myLooper());
|
||||||
|
}
|
||||||
|
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
}
|
@ -1,9 +1,7 @@
|
|||||||
package org.schabi.newpipe.util.external_communication;
|
package org.schabi.newpipe.util.text;
|
||||||
|
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
import android.text.SpannableStringBuilder;
|
import android.text.SpannableStringBuilder;
|
||||||
import android.text.method.LinkMovementMethod;
|
|
||||||
import android.text.style.ClickableSpan;
|
|
||||||
import android.text.style.URLSpan;
|
import android.text.style.URLSpan;
|
||||||
import android.text.util.Linkify;
|
import android.text.util.Linkify;
|
||||||
import android.util.Log;
|
import android.util.Log;
|
||||||
@ -17,6 +15,7 @@ import androidx.core.text.HtmlCompat;
|
|||||||
import org.schabi.newpipe.extractor.Info;
|
import org.schabi.newpipe.extractor.Info;
|
||||||
import org.schabi.newpipe.extractor.stream.StreamInfo;
|
import org.schabi.newpipe.extractor.stream.StreamInfo;
|
||||||
import org.schabi.newpipe.util.NavigationHelper;
|
import org.schabi.newpipe.util.NavigationHelper;
|
||||||
|
import org.schabi.newpipe.util.external_communication.ShareUtils;
|
||||||
|
|
||||||
import java.util.regex.Matcher;
|
import java.util.regex.Matcher;
|
||||||
import java.util.regex.Pattern;
|
import java.util.regex.Pattern;
|
||||||
@ -28,27 +27,26 @@ import io.reactivex.rxjava3.core.Single;
|
|||||||
import io.reactivex.rxjava3.disposables.CompositeDisposable;
|
import io.reactivex.rxjava3.disposables.CompositeDisposable;
|
||||||
import io.reactivex.rxjava3.schedulers.Schedulers;
|
import io.reactivex.rxjava3.schedulers.Schedulers;
|
||||||
|
|
||||||
import static org.schabi.newpipe.util.external_communication.InternalUrlsHandler.playOnPopup;
|
|
||||||
|
|
||||||
public final class TextLinkifier {
|
public final class TextLinkifier {
|
||||||
public static final String TAG = TextLinkifier.class.getSimpleName();
|
public static final String TAG = TextLinkifier.class.getSimpleName();
|
||||||
|
|
||||||
// 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 =
|
private static final Pattern HASHTAGS_PATTERN = Pattern.compile("(#[\\p{L}0-9_]+)");
|
||||||
Pattern.compile("(#[\\p{L}0-9_]+)");
|
|
||||||
|
|
||||||
private TextLinkifier() {
|
private TextLinkifier() {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create web links for contents with an HTML description.
|
* Create links for contents with an HTML description.
|
||||||
* <p>
|
|
||||||
* This will call {@link TextLinkifier#changeIntentsOfDescriptionLinks(TextView, CharSequence,
|
|
||||||
* Info, CompositeDisposable)} after having linked the URLs with
|
|
||||||
* {@link HtmlCompat#fromHtml(String, int)}.
|
|
||||||
*
|
*
|
||||||
* @param textView the TextView to set the htmlBlock linked
|
* <p>
|
||||||
* @param htmlBlock the htmlBlock to be linked
|
* This method will call {@link #changeIntentsOfDescriptionLinks(TextView, CharSequence, Info,
|
||||||
|
* CompositeDisposable)} 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)}
|
* @param htmlCompatFlag the int flag to be set when {@link HtmlCompat#fromHtml(String, int)}
|
||||||
* will be called
|
* will be called
|
||||||
* @param relatedInfo if given, handle timestamps to open the stream in the popup player at
|
* @param relatedInfo if given, handle timestamps to open the stream in the popup player at
|
||||||
@ -58,50 +56,54 @@ public final class TextLinkifier {
|
|||||||
* should be handled by the calling class
|
* should be handled by the calling class
|
||||||
*/
|
*/
|
||||||
public static void createLinksFromHtmlBlock(@NonNull final TextView textView,
|
public static void createLinksFromHtmlBlock(@NonNull final TextView textView,
|
||||||
final String htmlBlock,
|
@NonNull final String htmlBlock,
|
||||||
final int htmlCompatFlag,
|
final int htmlCompatFlag,
|
||||||
@Nullable final Info relatedInfo,
|
@Nullable final Info relatedInfo,
|
||||||
final CompositeDisposable disposables) {
|
@NonNull final CompositeDisposable disposables) {
|
||||||
changeIntentsOfDescriptionLinks(
|
changeIntentsOfDescriptionLinks(textView, HtmlCompat.fromHtml(htmlBlock, htmlCompatFlag),
|
||||||
textView, HtmlCompat.fromHtml(htmlBlock, htmlCompatFlag), relatedInfo, disposables);
|
relatedInfo, disposables);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create web links for contents with a plain text description.
|
* Create links for contents with a plain text description.
|
||||||
* <p>
|
|
||||||
* This will call {@link TextLinkifier#changeIntentsOfDescriptionLinks(TextView, CharSequence,
|
|
||||||
* Info, CompositeDisposable)} after having linked the URLs with
|
|
||||||
* {@link TextView#setAutoLinkMask(int)} and
|
|
||||||
* {@link TextView#setText(CharSequence, TextView.BufferType)}.
|
|
||||||
*
|
*
|
||||||
* @param textView the TextView to set the plain text block linked
|
* <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)}.
|
||||||
|
* </p>
|
||||||
|
*
|
||||||
|
* @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 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
|
* 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 disposables disposables created by the method are added here and their lifecycle
|
||||||
* should be handled by the calling class
|
* should be handled by the calling class
|
||||||
*/
|
*/
|
||||||
public static void createLinksFromPlainText(@NonNull final TextView textView,
|
public static void createLinksFromPlainText(@NonNull final TextView textView,
|
||||||
final String plainTextBlock,
|
@NonNull final String plainTextBlock,
|
||||||
@Nullable final Info relatedInfo,
|
@Nullable final Info relatedInfo,
|
||||||
final CompositeDisposable disposables) {
|
@NonNull final CompositeDisposable disposables) {
|
||||||
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);
|
changeIntentsOfDescriptionLinks(textView, textView.getText(), relatedInfo, disposables);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create web links for contents with a markdown description.
|
* Create links for contents with a markdown description.
|
||||||
* <p>
|
|
||||||
* This will call {@link TextLinkifier#changeIntentsOfDescriptionLinks(TextView, CharSequence,
|
|
||||||
* Info, CompositeDisposable)} after creating an {@link Markwon} object and using
|
|
||||||
* {@link Markwon#setMarkdown(TextView, String)}.
|
|
||||||
*
|
*
|
||||||
* @param textView the TextView to set the plain text block linked
|
* <p>
|
||||||
|
* This method will call {@link #changeIntentsOfDescriptionLinks(TextView, CharSequence, Info,
|
||||||
|
* CompositeDisposable)} 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 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 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
|
* 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
|
* @param disposables disposables created by the method are added here and their lifecycle
|
||||||
* should be handled by the calling class
|
* should be handled by the calling class
|
||||||
*/
|
*/
|
||||||
@ -115,156 +117,73 @@ public final class TextLinkifier {
|
|||||||
disposables);
|
disposables);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Add click listeners which opens a search on hashtags 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
|
|
||||||
* {@link NavigationHelper#openSearch(Context, int, String)} and makes a search on the hashtag,
|
|
||||||
* in the service of the content.
|
|
||||||
*
|
|
||||||
* @param context the context to use
|
|
||||||
* @param spannableDescription the SpannableStringBuilder with the text of the
|
|
||||||
* content description
|
|
||||||
* @param relatedInfo used to search for the term in the correct service
|
|
||||||
*/
|
|
||||||
private static void addClickListenersOnHashtags(final Context context,
|
|
||||||
@NonNull final SpannableStringBuilder
|
|
||||||
spannableDescription,
|
|
||||||
final Info relatedInfo) {
|
|
||||||
final String descriptionText = spannableDescription.toString();
|
|
||||||
final Matcher hashtagsMatches = HASHTAGS_PATTERN.matcher(descriptionText);
|
|
||||||
|
|
||||||
while (hashtagsMatches.find()) {
|
|
||||||
final int hashtagStart = hashtagsMatches.start(1);
|
|
||||||
final int hashtagEnd = hashtagsMatches.end(1);
|
|
||||||
final String parsedHashtag = descriptionText.substring(hashtagStart, hashtagEnd);
|
|
||||||
|
|
||||||
// don't add a ClickableSpan if there is already one, which should be a part of an URL,
|
|
||||||
// already parsed before
|
|
||||||
if (spannableDescription.getSpans(hashtagStart, hashtagEnd,
|
|
||||||
ClickableSpan.class).length == 0) {
|
|
||||||
spannableDescription.setSpan(new ClickableSpan() {
|
|
||||||
@Override
|
|
||||||
public void onClick(@NonNull final View view) {
|
|
||||||
NavigationHelper.openSearch(context, relatedInfo.getServiceId(),
|
|
||||||
parsedHashtag);
|
|
||||||
}
|
|
||||||
}, hashtagStart, hashtagEnd, 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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 relatedInfo 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(final Context context,
|
|
||||||
@NonNull final SpannableStringBuilder
|
|
||||||
spannableDescription,
|
|
||||||
final Info relatedInfo,
|
|
||||||
final CompositeDisposable disposables) {
|
|
||||||
final String descriptionText = spannableDescription.toString();
|
|
||||||
final Matcher timestampsMatches =
|
|
||||||
TimestampExtractor.TIMESTAMPS_PATTERN.matcher(descriptionText);
|
|
||||||
|
|
||||||
while (timestampsMatches.find()) {
|
|
||||||
final TimestampExtractor.TimestampMatchDTO timestampMatchDTO =
|
|
||||||
TimestampExtractor.getTimestampFromMatcher(
|
|
||||||
timestampsMatches,
|
|
||||||
descriptionText);
|
|
||||||
|
|
||||||
if (timestampMatchDTO == null) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
spannableDescription.setSpan(
|
|
||||||
new ClickableSpan() {
|
|
||||||
@Override
|
|
||||||
public void onClick(@NonNull final View view) {
|
|
||||||
playOnPopup(
|
|
||||||
context,
|
|
||||||
relatedInfo.getUrl(),
|
|
||||||
relatedInfo.getService(),
|
|
||||||
timestampMatchDTO.seconds(),
|
|
||||||
disposables);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
timestampMatchDTO.timestampStart(),
|
|
||||||
timestampMatchDTO.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.
|
* and add click listeners on timestamps in this description.
|
||||||
|
*
|
||||||
* <p>
|
* <p>
|
||||||
* Instead of using an {@link android.content.Intent#ACTION_VIEW} intent in the description of
|
* 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
|
* a content, this method will parse the {@link CharSequence} and replace all current web links
|
||||||
* with {@link ShareUtils#openUrlInBrowser(Context, String, boolean)}.
|
* with {@link ShareUtils#openUrlInBrowser(Context, String, boolean)}.
|
||||||
|
* </p>
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
* 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, Info,
|
* {@link TextLinkifier#addClickListenersOnTimestamps(Context, SpannableStringBuilder,
|
||||||
* CompositeDisposable)} method and click listeners on hashtags, by using
|
* StreamInfo, CompositeDisposable)} method and click listeners on hashtags, by using
|
||||||
* {@link TextLinkifier#addClickListenersOnHashtags(Context, SpannableStringBuilder, Info)},
|
* {@link TextLinkifier#addClickListenersOnHashtags(Context, SpannableStringBuilder, Info)})},
|
||||||
* which will open a search on the current service with the hashtag.
|
* which will open a search on the current service with the hashtag.
|
||||||
|
* </p>
|
||||||
|
*
|
||||||
* <p>
|
* <p>
|
||||||
* This method is required in order to intercept links and e.g. show a confirmation dialog
|
* This method is required in order to intercept links and e.g. show a confirmation dialog
|
||||||
* before opening a web link.
|
* before opening a web link.
|
||||||
|
* </p>
|
||||||
*
|
*
|
||||||
* @param textView the TextView in which the converted CharSequence will be applied
|
* @param textView the {@link TextView} in which the converted {@link CharSequence} will be
|
||||||
* @param chars the CharSequence to be parsed
|
* applied
|
||||||
* @param relatedInfo if given, handle timestamps to open the stream in the popup player at
|
* @param chars the {@link CharSequence} to be parsed
|
||||||
* the specific time, and hashtags to search for the term in the correct
|
* @param relatedInfo if given, handle timestamps to open the stream in the popup player at the
|
||||||
* service
|
* 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
|
* @param disposables disposables created by the method are added here and their lifecycle
|
||||||
* should be handled by the calling class
|
* should be handled by the calling class
|
||||||
*/
|
*/
|
||||||
private static void changeIntentsOfDescriptionLinks(final TextView textView,
|
private static void changeIntentsOfDescriptionLinks(
|
||||||
final CharSequence chars,
|
@NonNull final TextView textView,
|
||||||
|
@NonNull final CharSequence chars,
|
||||||
@Nullable final Info relatedInfo,
|
@Nullable final Info relatedInfo,
|
||||||
final CompositeDisposable disposables) {
|
@NonNull final CompositeDisposable disposables) {
|
||||||
|
textView.setMovementMethod(LongPressLinkMovementMethod.getInstance());
|
||||||
disposables.add(Single.fromCallable(() -> {
|
disposables.add(Single.fromCallable(() -> {
|
||||||
final Context context = textView.getContext();
|
final Context context = textView.getContext();
|
||||||
|
|
||||||
// add custom click actions on web links
|
// add custom click actions on web links
|
||||||
final SpannableStringBuilder textBlockLinked = new SpannableStringBuilder(chars);
|
final SpannableStringBuilder textBlockLinked =
|
||||||
final URLSpan[] urls = textBlockLinked.getSpans(0, chars.length(), URLSpan.class);
|
new SpannableStringBuilder(chars);
|
||||||
|
final URLSpan[] urls = textBlockLinked.getSpans(0, chars.length(),
|
||||||
|
URLSpan.class);
|
||||||
|
|
||||||
for (final URLSpan span : urls) {
|
for (final URLSpan span : urls) {
|
||||||
final String url = span.getURL();
|
final String url = span.getURL();
|
||||||
final ClickableSpan clickableSpan = new ClickableSpan() {
|
final LongPressClickableSpan longPressClickableSpan =
|
||||||
public void onClick(@NonNull final View view) {
|
new UrlLongPressClickableSpan(context, disposables, url);
|
||||||
if (!InternalUrlsHandler.handleUrlDescriptionTimestamp(
|
|
||||||
new CompositeDisposable(), context, url)) {
|
|
||||||
ShareUtils.openUrlInBrowser(context, url, false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
textBlockLinked.setSpan(clickableSpan, textBlockLinked.getSpanStart(span),
|
textBlockLinked.setSpan(longPressClickableSpan,
|
||||||
textBlockLinked.getSpanEnd(span), textBlockLinked.getSpanFlags(span));
|
textBlockLinked.getSpanStart(span),
|
||||||
|
textBlockLinked.getSpanEnd(span),
|
||||||
|
textBlockLinked.getSpanFlags(span));
|
||||||
textBlockLinked.removeSpan(span);
|
textBlockLinked.removeSpan(span);
|
||||||
}
|
}
|
||||||
|
|
||||||
// add click actions on plain text timestamps only for description of contents,
|
|
||||||
// unneeded for meta-info or other TextViews
|
|
||||||
if (relatedInfo != null) {
|
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) {
|
if (relatedInfo instanceof StreamInfo) {
|
||||||
addClickListenersOnTimestamps(context, textBlockLinked, relatedInfo,
|
addClickListenersOnTimestamps(context, textBlockLinked,
|
||||||
disposables);
|
(StreamInfo) relatedInfo, disposables);
|
||||||
}
|
}
|
||||||
|
|
||||||
addClickListenersOnHashtags(context, textBlockLinked, relatedInfo);
|
addClickListenersOnHashtags(context, textBlockLinked, relatedInfo);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -280,10 +199,90 @@ public final class TextLinkifier {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add click listeners which opens a search on hashtags 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 LongPressClickableSpan} which opens
|
||||||
|
* {@link NavigationHelper#openSearch(Context, int, String)} and makes a search on the hashtag,
|
||||||
|
* in the service of the content when pressed, and copy the hashtag to clipboard when
|
||||||
|
* long-pressed, if allowed by the caller method (parameter {@code addLongClickCopyListener}).
|
||||||
|
* </p>
|
||||||
|
*
|
||||||
|
* @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
|
||||||
|
*/
|
||||||
|
private static void addClickListenersOnHashtags(
|
||||||
|
@NonNull final Context context,
|
||||||
|
@NonNull final SpannableStringBuilder spannableDescription,
|
||||||
|
@NonNull final Info relatedInfo) {
|
||||||
|
final String descriptionText = spannableDescription.toString();
|
||||||
|
final Matcher hashtagsMatches = HASHTAGS_PATTERN.matcher(descriptionText);
|
||||||
|
|
||||||
|
while (hashtagsMatches.find()) {
|
||||||
|
final int hashtagStart = hashtagsMatches.start(1);
|
||||||
|
final int hashtagEnd = hashtagsMatches.end(1);
|
||||||
|
final String parsedHashtag = descriptionText.substring(hashtagStart, hashtagEnd);
|
||||||
|
|
||||||
|
// Don't add a LongPressClickableSpan if there is already one, which should be a part
|
||||||
|
// of an URL, already parsed before
|
||||||
|
if (spannableDescription.getSpans(hashtagStart, hashtagEnd,
|
||||||
|
LongPressClickableSpan.class).length == 0) {
|
||||||
|
spannableDescription.setSpan(
|
||||||
|
new HashtagLongPressClickableSpan(context, parsedHashtag, relatedInfo),
|
||||||
|
hashtagStart, hashtagEnd, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 LongPressClickableSpan} which opens the
|
||||||
|
* popup player at the time indicated in the timestamps and copy the timestamp in clipboard
|
||||||
|
* when long-pressed.
|
||||||
|
* </p>
|
||||||
|
*
|
||||||
|
* @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 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 CompositeDisposable disposables) {
|
||||||
|
final String descriptionText = spannableDescription.toString();
|
||||||
|
final Matcher timestampsMatches = TimestampExtractor.TIMESTAMPS_PATTERN.matcher(
|
||||||
|
descriptionText);
|
||||||
|
|
||||||
|
while (timestampsMatches.find()) {
|
||||||
|
final TimestampExtractor.TimestampMatchDTO timestampMatchDTO =
|
||||||
|
TimestampExtractor.getTimestampFromMatcher(timestampsMatches, descriptionText);
|
||||||
|
|
||||||
|
if (timestampMatchDTO == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
spannableDescription.setSpan(new TimestampLongPressClickableSpan(
|
||||||
|
context, descriptionText, disposables, streamInfo, timestampMatchDTO),
|
||||||
|
timestampMatchDTO.timestampStart(),
|
||||||
|
timestampMatchDTO.timestampEnd(),
|
||||||
|
0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static void setTextViewCharSequence(@NonNull final TextView textView,
|
private static void setTextViewCharSequence(@NonNull final TextView textView,
|
||||||
final CharSequence charSequence) {
|
@Nullable final CharSequence charSequence) {
|
||||||
textView.setText(charSequence);
|
textView.setText(charSequence);
|
||||||
textView.setMovementMethod(LinkMovementMethod.getInstance());
|
|
||||||
textView.setVisibility(View.VISIBLE);
|
textView.setVisibility(View.VISIBLE);
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -1,4 +1,7 @@
|
|||||||
package org.schabi.newpipe.util.external_communication;
|
package org.schabi.newpipe.util.text;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
import androidx.annotation.Nullable;
|
||||||
|
|
||||||
import java.util.regex.Matcher;
|
import java.util.regex.Matcher;
|
||||||
import java.util.regex.Pattern;
|
import java.util.regex.Pattern;
|
||||||
@ -15,17 +18,18 @@ public final class TimestampExtractor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get's a single timestamp from a matcher.
|
* Gets a single timestamp from a matcher.
|
||||||
*
|
*
|
||||||
* @param timestampMatches The matcher which was created using {@link #TIMESTAMPS_PATTERN}
|
* @param timestampMatches the matcher which was created using {@link #TIMESTAMPS_PATTERN}
|
||||||
* @param baseText The text where the pattern was applied to /
|
* @param baseText the text where the pattern was applied to / where the matcher is
|
||||||
* where the matcher is based upon
|
* based upon
|
||||||
* @return If a match occurred: a {@link TimestampMatchDTO} filled with information.<br/>
|
* @return if a match occurred, a {@link TimestampMatchDTO} filled with information, otherwise
|
||||||
* If not <code>null</code>.
|
* {@code null}.
|
||||||
*/
|
*/
|
||||||
|
@Nullable
|
||||||
public static TimestampMatchDTO getTimestampFromMatcher(
|
public static TimestampMatchDTO getTimestampFromMatcher(
|
||||||
final Matcher timestampMatches,
|
@NonNull final Matcher timestampMatches,
|
||||||
final String baseText) {
|
@NonNull final String baseText) {
|
||||||
int timestampStart = timestampMatches.start(1);
|
int timestampStart = timestampMatches.start(1);
|
||||||
if (timestampStart == -1) {
|
if (timestampStart == -1) {
|
||||||
timestampStart = timestampMatches.start(2);
|
timestampStart = timestampMatches.start(2);
|
@ -0,0 +1,75 @@
|
|||||||
|
package org.schabi.newpipe.util.text;
|
||||||
|
|
||||||
|
import static org.schabi.newpipe.util.text.InternalUrlsHandler.playOnPopup;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import android.view.View;
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
final class TimestampLongPressClickableSpan extends LongPressClickableSpan {
|
||||||
|
|
||||||
|
@NonNull
|
||||||
|
private final Context context;
|
||||||
|
@NonNull
|
||||||
|
private final String descriptionText;
|
||||||
|
@NonNull
|
||||||
|
private final CompositeDisposable disposables;
|
||||||
|
@NonNull
|
||||||
|
private final StreamInfo streamInfo;
|
||||||
|
@NonNull
|
||||||
|
private final TimestampExtractor.TimestampMatchDTO timestampMatchDTO;
|
||||||
|
|
||||||
|
TimestampLongPressClickableSpan(
|
||||||
|
@NonNull final Context context,
|
||||||
|
@NonNull final String descriptionText,
|
||||||
|
@NonNull final CompositeDisposable disposables,
|
||||||
|
@NonNull final StreamInfo streamInfo,
|
||||||
|
@NonNull final TimestampExtractor.TimestampMatchDTO timestampMatchDTO) {
|
||||||
|
this.context = context;
|
||||||
|
this.descriptionText = descriptionText;
|
||||||
|
this.disposables = disposables;
|
||||||
|
this.streamInfo = streamInfo;
|
||||||
|
this.timestampMatchDTO = timestampMatchDTO;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onClick(@NonNull final View view) {
|
||||||
|
playOnPopup(context, streamInfo.getUrl(), streamInfo.getService(),
|
||||||
|
timestampMatchDTO.seconds(), disposables);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onLongClick(@NonNull final View view) {
|
||||||
|
ShareUtils.copyToClipboard(context,
|
||||||
|
getTimestampTextToCopy(streamInfo, descriptionText, timestampMatchDTO));
|
||||||
|
}
|
||||||
|
|
||||||
|
@NonNull
|
||||||
|
private static String getTimestampTextToCopy(
|
||||||
|
@NonNull final StreamInfo relatedInfo,
|
||||||
|
@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();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return timestamp text for other services
|
||||||
|
return descriptionText.subSequence(timestampMatchDTO.timestampStart(),
|
||||||
|
timestampMatchDTO.timestampEnd()).toString();
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,38 @@
|
|||||||
|
package org.schabi.newpipe.util.text;
|
||||||
|
|
||||||
|
import android.text.Layout;
|
||||||
|
import android.view.MotionEvent;
|
||||||
|
import android.widget.TextView;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
|
||||||
|
public final class TouchUtils {
|
||||||
|
|
||||||
|
private TouchUtils() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the character offset on the closest line to the position pressed by the user of a
|
||||||
|
* {@link TextView} from a {@link MotionEvent} which was fired on this {@link TextView}.
|
||||||
|
*
|
||||||
|
* @param textView the {@link TextView} on which the {@link MotionEvent} was fired
|
||||||
|
* @param event the {@link MotionEvent} which was fired
|
||||||
|
* @return the character offset on the closest line to the position pressed by the user
|
||||||
|
*/
|
||||||
|
public static int getOffsetForHorizontalLine(@NonNull final TextView textView,
|
||||||
|
@NonNull final MotionEvent event) {
|
||||||
|
|
||||||
|
int x = (int) event.getX();
|
||||||
|
int y = (int) event.getY();
|
||||||
|
|
||||||
|
x -= textView.getTotalPaddingLeft();
|
||||||
|
y -= textView.getTotalPaddingTop();
|
||||||
|
|
||||||
|
x += textView.getScrollX();
|
||||||
|
y += textView.getScrollY();
|
||||||
|
|
||||||
|
final Layout layout = textView.getLayout();
|
||||||
|
final int line = layout.getLineForVertical(y);
|
||||||
|
return layout.getOffsetForHorizontal(line, x);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,41 @@
|
|||||||
|
package org.schabi.newpipe.util.text;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import android.view.View;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
|
||||||
|
import org.schabi.newpipe.util.external_communication.ShareUtils;
|
||||||
|
|
||||||
|
import io.reactivex.rxjava3.disposables.CompositeDisposable;
|
||||||
|
|
||||||
|
final class UrlLongPressClickableSpan extends LongPressClickableSpan {
|
||||||
|
|
||||||
|
@NonNull
|
||||||
|
private final Context context;
|
||||||
|
@NonNull
|
||||||
|
private final CompositeDisposable disposables;
|
||||||
|
@NonNull
|
||||||
|
private final String url;
|
||||||
|
|
||||||
|
UrlLongPressClickableSpan(@NonNull final Context context,
|
||||||
|
@NonNull final CompositeDisposable disposables,
|
||||||
|
@NonNull final String url) {
|
||||||
|
this.context = context;
|
||||||
|
this.disposables = disposables;
|
||||||
|
this.url = url;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onClick(@NonNull final View view) {
|
||||||
|
if (!InternalUrlsHandler.handleUrlDescriptionTimestamp(
|
||||||
|
disposables, context, url)) {
|
||||||
|
ShareUtils.openUrlInBrowser(context, url, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onLongClick(@NonNull final View view) {
|
||||||
|
ShareUtils.copyToClipboard(context, url);
|
||||||
|
}
|
||||||
|
}
|
@ -13,9 +13,10 @@ import org.schabi.newpipe.util.external_communication.ShareUtils;
|
|||||||
/**
|
/**
|
||||||
* An {@link AppCompatEditText} which uses {@link ShareUtils#shareText(Context, String, String)}
|
* An {@link AppCompatEditText} which uses {@link ShareUtils#shareText(Context, String, String)}
|
||||||
* when sharing selected text by using the {@code Share} command of the floating actions.
|
* when sharing selected text by using the {@code Share} command of the floating actions.
|
||||||
|
*
|
||||||
* <p>
|
* <p>
|
||||||
* This allows NewPipe to show Android share sheet instead of EMUI share sheet when sharing text
|
* This class allows NewPipe to show Android share sheet instead of EMUI share sheet when sharing
|
||||||
* from {@link AppCompatEditText} on EMUI devices.
|
* text from {@link AppCompatEditText} on EMUI devices.
|
||||||
* </p>
|
* </p>
|
||||||
*/
|
*/
|
||||||
public class NewPipeEditText extends AppCompatEditText {
|
public class NewPipeEditText extends AppCompatEditText {
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
package org.schabi.newpipe.views;
|
package org.schabi.newpipe.views;
|
||||||
|
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
|
import android.text.method.MovementMethod;
|
||||||
import android.util.AttributeSet;
|
import android.util.AttributeSet;
|
||||||
|
|
||||||
import androidx.annotation.NonNull;
|
import androidx.annotation.NonNull;
|
||||||
@ -13,9 +14,11 @@ import org.schabi.newpipe.util.external_communication.ShareUtils;
|
|||||||
/**
|
/**
|
||||||
* An {@link AppCompatTextView} which uses {@link ShareUtils#shareText(Context, String, String)}
|
* An {@link AppCompatTextView} which uses {@link ShareUtils#shareText(Context, String, String)}
|
||||||
* when sharing selected text by using the {@code Share} command of the floating actions.
|
* when sharing selected text by using the {@code Share} command of the floating actions.
|
||||||
|
*
|
||||||
* <p>
|
* <p>
|
||||||
* This allows NewPipe to show Android share sheet instead of EMUI share sheet when sharing text
|
* This class allows NewPipe to show Android share sheet instead of EMUI share sheet when sharing
|
||||||
* from {@link AppCompatTextView} on EMUI devices.
|
* text from {@link AppCompatTextView} on EMUI devices and also to keep movement method set when a
|
||||||
|
* text change occurs, if the text cannot be selected and text links are clickable.
|
||||||
* </p>
|
* </p>
|
||||||
*/
|
*/
|
||||||
public class NewPipeTextView extends AppCompatTextView {
|
public class NewPipeTextView extends AppCompatTextView {
|
||||||
@ -34,6 +37,16 @@ public class NewPipeTextView extends AppCompatTextView {
|
|||||||
super(context, attrs, defStyleAttr);
|
super(context, attrs, defStyleAttr);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setText(final CharSequence text, final BufferType type) {
|
||||||
|
// We need to set again the movement method after a text change because Android resets the
|
||||||
|
// movement method to the default one in the case where the text cannot be selected and
|
||||||
|
// text links are clickable (which is the default case in NewPipe).
|
||||||
|
final MovementMethod movementMethod = this.getMovementMethod();
|
||||||
|
super.setText(text, type);
|
||||||
|
setMovementMethod(movementMethod);
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public boolean onTextContextMenuItem(final int id) {
|
public boolean onTextContextMenuItem(final int id) {
|
||||||
if (id == android.R.id.shareText) {
|
if (id == android.R.id.shareText) {
|
||||||
|
@ -6,7 +6,7 @@
|
|||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:paddingVertical="6dp">
|
android:paddingVertical="6dp">
|
||||||
|
|
||||||
<TextView
|
<org.schabi.newpipe.views.NewPipeTextView
|
||||||
android:id="@+id/metadata_type_view"
|
android:id="@+id/metadata_type_view"
|
||||||
android:layout_width="96dp"
|
android:layout_width="96dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
@ -19,7 +19,7 @@
|
|||||||
app:layout_constraintTop_toTopOf="parent"
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
tools:text="Licence" />
|
tools:text="Licence" />
|
||||||
|
|
||||||
<TextView
|
<org.schabi.newpipe.views.NewPipeTextView
|
||||||
android:id="@+id/metadata_content_view"
|
android:id="@+id/metadata_content_view"
|
||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
|
@ -741,4 +741,5 @@
|
|||||||
<string name="import_subscriptions_hint">Importer ou exporter des abonnements à partir du menu</string>
|
<string name="import_subscriptions_hint">Importer ou exporter des abonnements à partir du menu</string>
|
||||||
<string name="app_update_unavailable_toast">Vous utilisez la dernière version de NewPipe</string>
|
<string name="app_update_unavailable_toast">Vous utilisez la dernière version de NewPipe</string>
|
||||||
<string name="app_update_available_notification_text">Appuyez pour télécharger %s</string>
|
<string name="app_update_available_notification_text">Appuyez pour télécharger %s</string>
|
||||||
|
<string name="msg_failed_to_copy">Échec de la copie dans le presse-papiers</string>
|
||||||
</resources>
|
</resources>
|
@ -327,6 +327,7 @@
|
|||||||
<string name="msg_calculating_hash">Calculating hash</string>
|
<string name="msg_calculating_hash">Calculating hash</string>
|
||||||
<string name="msg_wait">Please wait…</string>
|
<string name="msg_wait">Please wait…</string>
|
||||||
<string name="msg_copied">Copied to clipboard</string>
|
<string name="msg_copied">Copied to clipboard</string>
|
||||||
|
<string name="msg_failed_to_copy">Failed to copy to clipboard</string>
|
||||||
<string name="no_available_dir">Please define a download folder later in settings</string>
|
<string name="no_available_dir">Please define a download folder later in settings</string>
|
||||||
<string name="no_dir_yet">No download folder set yet, choose the default download folder now</string>
|
<string name="no_dir_yet">No download folder set yet, choose the default download folder now</string>
|
||||||
<string name="msg_popup_permission">This permission is needed to\nopen in popup mode</string>
|
<string name="msg_popup_permission">This permission is needed to\nopen in popup mode</string>
|
||||||
|
@ -3,6 +3,7 @@ package org.schabi.newpipe.util.external_communication;
|
|||||||
import org.junit.Test;
|
import org.junit.Test;
|
||||||
import org.junit.runner.RunWith;
|
import org.junit.runner.RunWith;
|
||||||
import org.junit.runners.Parameterized;
|
import org.junit.runners.Parameterized;
|
||||||
|
import org.schabi.newpipe.util.text.TimestampExtractor;
|
||||||
|
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
|
Loading…
Reference in New Issue
Block a user