Skip to content

Commit 1d89ad9

Browse files
committed
Scroll to player when tapping a timestamp in the description
When a timestamp link in the video description is tapped, expand the AppBar to reveal the player in addition to seeking. This mirrors the existing behaviour in the comments tab
1 parent 2e9528d commit 1d89ad9

File tree

7 files changed

+110
-28
lines changed

7 files changed

+110
-28
lines changed

app/src/main/java/org/schabi/newpipe/fragments/detail/BaseDescriptionFragment.java

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,6 @@
2020
import androidx.annotation.Nullable;
2121
import androidx.annotation.StringRes;
2222
import androidx.appcompat.widget.TooltipCompat;
23-
import androidx.core.text.HtmlCompat;
24-
2523
import com.google.android.material.chip.Chip;
2624

2725
import org.schabi.newpipe.BaseFragment;
@@ -69,6 +67,17 @@ public void onDestroy() {
6967
@Nullable
7068
protected abstract Description getDescription();
7169

70+
/**
71+
* Optional action to run after a timestamp in the description is clicked.
72+
* Subclasses can override to e.g. scroll the UI to the player.
73+
*
74+
* @return runnable to execute, or null if no action needed
75+
*/
76+
@Nullable
77+
protected Runnable getAfterTimestampClickRunnable() {
78+
return null;
79+
}
80+
7281
/**
7382
* Get the streaming service. Used for generating description links.
7483
* @return streaming service
@@ -140,9 +149,9 @@ private void disableDescriptionSelection() {
140149
final Description description = getDescription();
141150
if (description != null) {
142151
TextLinkifier.fromDescription(binding.detailDescriptionView,
143-
description, HtmlCompat.FROM_HTML_MODE_LEGACY,
144-
getService(), getStreamUrl(),
145-
descriptionDisposables, SET_LINK_MOVEMENT_METHOD);
152+
description, getService(), getStreamUrl(),
153+
descriptionDisposables, SET_LINK_MOVEMENT_METHOD,
154+
getAfterTimestampClickRunnable());
146155
}
147156

148157
binding.detailDescriptionNoteView.setVisibility(View.GONE);

app/src/main/java/org/schabi/newpipe/fragments/detail/DescriptionFragment.java

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,18 @@ protected Description getDescription() {
4141
return streamInfo.getDescription();
4242
}
4343

44+
@Nullable
45+
@Override
46+
protected Runnable getAfterTimestampClickRunnable() {
47+
return () -> {
48+
final VideoDetailFragment parent =
49+
(VideoDetailFragment) getParentFragment();
50+
if (parent != null) {
51+
parent.scrollToTop();
52+
}
53+
};
54+
}
55+
4456
@NonNull
4557
@Override
4658
protected StreamingService getService() {

app/src/main/java/org/schabi/newpipe/ui/components/common/DescriptionText.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ fun rememberParsedDescription(
106106
)
107107
}
108108
}
109+
109110
is LinkAnnotation.Clickable -> addLink(ann, link.start, link.end)
110111
}
111112
}

app/src/main/java/org/schabi/newpipe/util/text/TextEllipsizer.java

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,6 @@
77

88
import androidx.annotation.NonNull;
99
import androidx.annotation.Nullable;
10-
import androidx.core.text.HtmlCompat;
11-
1210
import org.schabi.newpipe.extractor.StreamingService;
1311
import org.schabi.newpipe.extractor.stream.Description;
1412

@@ -164,7 +162,7 @@ private void linkifyContentView(final Consumer<View> consumer) {
164162
final boolean oldState = isEllipsized;
165163
disposable.clear();
166164
TextLinkifier.fromDescription(view, content,
167-
HtmlCompat.FROM_HTML_MODE_LEGACY, streamingService, streamUrl, disposable,
165+
streamingService, streamUrl, disposable,
168166
v -> {
169167
consumer.accept(v);
170168
notifyStateChangeListener(oldState);

app/src/main/java/org/schabi/newpipe/util/text/TextLinkifier.java

Lines changed: 58 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,6 @@ private TextLinkifier() {
4848
*
4949
* @param textView the TextView to set the htmlBlock linked
5050
* @param description the htmlBlock to be linked
51-
* @param htmlCompatFlag the int flag to be set if {@link HtmlCompat#fromHtml(String, int)}
52-
* will be called (not used for formats different than HTML)
5351
* @param relatedInfoService if given, handle hashtags to search for the term in the correct
5452
* service
5553
* @param relatedStreamUrl if given, used alongside {@code relatedInfoService} to handle
@@ -62,23 +60,59 @@ private TextLinkifier() {
6260
*/
6361
public static void fromDescription(@NonNull final TextView textView,
6462
@NonNull final Description description,
65-
final int htmlCompatFlag,
6663
@Nullable final StreamingService relatedInfoService,
6764
@Nullable final String relatedStreamUrl,
6865
@NonNull final CompositeDisposable disposables,
6966
@Nullable final Consumer<TextView> onCompletion) {
67+
fromDescription(textView, description, relatedInfoService,
68+
relatedStreamUrl, disposables, onCompletion, null);
69+
}
70+
71+
/**
72+
* Like {@link #fromDescription(TextView, Description, StreamingService, String,
73+
* CompositeDisposable, Consumer)} but with an extra callback that fires after a timestamp
74+
* link is tapped and the player seek has been dispatched.
75+
*
76+
* @param textView the TextView to set the linked description on
77+
* @param description the description to be linked
78+
* @param relatedInfoService if given, handle hashtags and timestamps for this service
79+
* @param relatedStreamUrl if given, used to open the stream in the popup player at the
80+
* time indicated by a timestamp
81+
* @param disposables disposables created by the method are added here and their
82+
* lifecycle should be handled by the calling class
83+
* @param onCompletion will be run when setting text to the textView completes; use
84+
* {@link #SET_LINK_MOVEMENT_METHOD} to make links clickable
85+
* @param onAfterTimestampClick invoked after a timestamp click is handled; use this to e.g.
86+
* scroll the UI so the player is visible. May be {@code null}.
87+
*/
88+
public static void fromDescription(@NonNull final TextView textView,
89+
@NonNull final Description description,
90+
@Nullable final StreamingService relatedInfoService,
91+
@Nullable final String relatedStreamUrl,
92+
@NonNull final CompositeDisposable disposables,
93+
@Nullable final Consumer<TextView> onCompletion,
94+
@Nullable final Runnable onAfterTimestampClick) {
7095
switch (description.getType()) {
7196
case Description.HTML:
72-
TextLinkifier.fromHtml(textView, description.getContent(), htmlCompatFlag,
73-
relatedInfoService, relatedStreamUrl, disposables, onCompletion);
97+
changeLinkIntents(textView,
98+
HtmlCompat.fromHtml(description.getContent(),
99+
HtmlCompat.FROM_HTML_MODE_LEGACY),
100+
relatedInfoService, relatedStreamUrl, disposables, onCompletion,
101+
onAfterTimestampClick);
74102
break;
75103
case Description.MARKDOWN:
76-
TextLinkifier.fromMarkdown(textView, description.getContent(),
77-
relatedInfoService, relatedStreamUrl, disposables, onCompletion);
104+
changeLinkIntents(textView,
105+
Markwon.builder(textView.getContext())
106+
.usePlugin(LinkifyPlugin.create()).build()
107+
.toMarkdown(description.getContent()),
108+
relatedInfoService, relatedStreamUrl, disposables, onCompletion,
109+
onAfterTimestampClick);
78110
break;
79111
case Description.PLAIN_TEXT: default:
80-
TextLinkifier.fromPlainText(textView, description.getContent(),
81-
relatedInfoService, relatedStreamUrl, disposables, onCompletion);
112+
textView.setAutoLinkMask(Linkify.WEB_URLS);
113+
textView.setText(description.getContent(), TextView.BufferType.SPANNABLE);
114+
changeLinkIntents(textView, textView.getText(), relatedInfoService,
115+
relatedStreamUrl, disposables, onCompletion, onAfterTimestampClick);
82116
break;
83117
}
84118
}
@@ -115,7 +149,7 @@ public static void fromHtml(@NonNull final TextView textView,
115149
@Nullable final Consumer<TextView> onCompletion) {
116150
changeLinkIntents(
117151
textView, HtmlCompat.fromHtml(htmlBlock, htmlCompatFlag), relatedInfoService,
118-
relatedStreamUrl, disposables, onCompletion);
152+
relatedStreamUrl, disposables, onCompletion, null);
119153
}
120154

121155
/**
@@ -149,7 +183,7 @@ public static void fromPlainText(@NonNull final TextView textView,
149183
textView.setAutoLinkMask(Linkify.WEB_URLS);
150184
textView.setText(plainTextBlock, TextView.BufferType.SPANNABLE);
151185
changeLinkIntents(textView, textView.getText(), relatedInfoService,
152-
relatedStreamUrl, disposables, onCompletion);
186+
relatedStreamUrl, disposables, onCompletion, null);
153187
}
154188

155189
/**
@@ -182,7 +216,7 @@ public static void fromMarkdown(@NonNull final TextView textView,
182216
final Markwon markwon = Markwon.builder(textView.getContext())
183217
.usePlugin(LinkifyPlugin.create()).build();
184218
changeLinkIntents(textView, markwon.toMarkdown(markdownBlock),
185-
relatedInfoService, relatedStreamUrl, disposables, onCompletion);
219+
relatedInfoService, relatedStreamUrl, disposables, onCompletion, null);
186220
}
187221

188222
/**
@@ -221,13 +255,15 @@ public static void fromMarkdown(@NonNull final TextView textView,
221255
* lifecycle should be handled by the calling class
222256
* @param onCompletion will be run when setting text to the textView completes; use {@link
223257
* #SET_LINK_MOVEMENT_METHOD} to make links clickable and focusable
258+
* @param onAfterTimestampClick will be run after a timestamp link click action is resolved
224259
*/
225260
private static void changeLinkIntents(@NonNull final TextView textView,
226261
@NonNull final CharSequence chars,
227262
@Nullable final StreamingService relatedInfoService,
228263
@Nullable final String relatedStreamUrl,
229264
@NonNull final CompositeDisposable disposables,
230-
@Nullable final Consumer<TextView> onCompletion) {
265+
@Nullable final Consumer<TextView> onCompletion,
266+
@Nullable final Runnable onAfterTimestampClick) {
231267
disposables.add(Single.fromCallable(() -> {
232268
final Context context = textView.getContext();
233269

@@ -240,7 +276,8 @@ private static void changeLinkIntents(@NonNull final TextView textView,
240276
for (final URLSpan span : urls) {
241277
final String url = span.getURL();
242278
final LongPressClickableSpan longPressClickableSpan =
243-
new UrlLongPressClickableSpan(context, url);
279+
new UrlLongPressClickableSpan(context, url,
280+
onAfterTimestampClick);
244281

245282
textBlockLinked.setSpan(longPressClickableSpan,
246283
textBlockLinked.getSpanStart(span),
@@ -254,7 +291,8 @@ private static void changeLinkIntents(@NonNull final TextView textView,
254291
if (relatedInfoService != null) {
255292
if (relatedStreamUrl != null) {
256293
addClickListenersOnTimestamps(context, textBlockLinked,
257-
relatedInfoService, relatedStreamUrl, disposables);
294+
relatedInfoService, relatedStreamUrl, disposables,
295+
onAfterTimestampClick);
258296
}
259297
addClickListenersOnHashtags(context, textBlockLinked, relatedInfoService);
260298
}
@@ -329,13 +367,15 @@ private static void addClickListenersOnHashtags(
329367
* @param relatedStreamUrl what to open in the popup player when timestamps are clicked
330368
* @param disposables disposables created by the method are added here and their
331369
* lifecycle should be handled by the calling class
370+
* @param onAfterTimestampClick will be run after a timestamp link click action is resolved
332371
*/
333372
private static void addClickListenersOnTimestamps(
334373
@NonNull final Context context,
335374
@NonNull final SpannableStringBuilder spannableDescription,
336375
@NonNull final StreamingService relatedInfoService,
337376
@NonNull final String relatedStreamUrl,
338-
@NonNull final CompositeDisposable disposables) {
377+
@NonNull final CompositeDisposable disposables,
378+
@Nullable final Runnable onAfterTimestampClick) {
339379
final String descriptionText = spannableDescription.toString();
340380
final Matcher timestampsMatches = TimestampExtractor.TIMESTAMPS_PATTERN.matcher(
341381
descriptionText);
@@ -350,7 +390,8 @@ private static void addClickListenersOnTimestamps(
350390

351391
spannableDescription.setSpan(
352392
new TimestampLongPressClickableSpan(context, descriptionText, disposables,
353-
relatedInfoService, relatedStreamUrl, timestampMatchDTO),
393+
relatedInfoService, relatedStreamUrl, timestampMatchDTO,
394+
onAfterTimestampClick),
354395
timestampMatchDTO.timestampStart(),
355396
timestampMatchDTO.timestampEnd(),
356397
0);

app/src/main/java/org/schabi/newpipe/util/text/TimestampLongPressClickableSpan.kt

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,21 @@ import org.schabi.newpipe.extractor.StreamingService
1313
import org.schabi.newpipe.util.external_communication.ShareUtils
1414
import org.schabi.newpipe.util.text.TimestampExtractor.TimestampMatchDTO
1515

16+
/**
17+
* A [LongPressClickableSpan] that seeks the player to a timestamp on click and copies the
18+
* timestamp URL to the clipboard on long-click.
19+
*
20+
* @param onAfterClick optional callback invoked after the seek intent is dispatched, e.g. to
21+
* scroll the UI back to the player so the user can see the updated position.
22+
*/
1623
class TimestampLongPressClickableSpan(
1724
private val context: Context,
1825
private val descriptionText: String,
1926
private val disposables: CompositeDisposable,
2027
private val relatedInfoService: StreamingService,
2128
private val relatedStreamUrl: String,
22-
private val timestampMatchDTO: TimestampMatchDTO
29+
private val timestampMatchDTO: TimestampMatchDTO,
30+
private val onAfterClick: Runnable? = null
2331
) : LongPressClickableSpan() {
2432
override fun onClick(view: View) {
2533
InternalUrlsHandler.playOnPopup(
@@ -28,6 +36,7 @@ class TimestampLongPressClickableSpan(
2836
relatedInfoService,
2937
timestampMatchDTO.seconds()
3038
)
39+
onAfterClick?.run()
3140
}
3241

3342
override fun onLongClick(view: View) {

app/src/main/java/org/schabi/newpipe/util/text/UrlLongPressClickableSpan.java

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import android.view.View;
55

66
import androidx.annotation.NonNull;
7+
import androidx.annotation.Nullable;
78

89
import org.schabi.newpipe.util.external_communication.ShareUtils;
910

@@ -13,16 +14,27 @@ final class UrlLongPressClickableSpan extends LongPressClickableSpan {
1314
private final Context context;
1415
@NonNull
1516
private final String url;
17+
@Nullable
18+
private final Runnable onAfterTimestampClick;
1619

1720
UrlLongPressClickableSpan(@NonNull final Context context,
18-
@NonNull final String url) {
21+
@NonNull final String url,
22+
@Nullable final Runnable onAfterTimestampClick) {
1923
this.context = context;
2024
this.url = url;
25+
this.onAfterTimestampClick = onAfterTimestampClick;
2126
}
2227

2328
@Override
2429
public void onClick(@NonNull final View view) {
25-
if (!InternalUrlsHandler.handleUrlDescriptionTimestamp(context, url)) {
30+
// handleUrlDescriptionTimestamp returns true when the URL is a timestamp link and the
31+
// player seek was dispatched; only then scroll back to the player. Regular URLs fall
32+
// through to the browser/app chooser and should not trigger a scroll.
33+
if (InternalUrlsHandler.handleUrlDescriptionTimestamp(context, url)) {
34+
if (onAfterTimestampClick != null) {
35+
onAfterTimestampClick.run();
36+
}
37+
} else {
2638
ShareUtils.openUrlInApp(context, url);
2739
}
2840
}

0 commit comments

Comments
 (0)