From d0f5e1d4009b0fc9263a047d9dfb10d5aa15390b Mon Sep 17 00:00:00 2001 From: h Date: Mon, 13 Apr 2026 08:07:46 +0200 Subject: [PATCH 1/5] Parse timestamps in descriptions into clickable annotations --- .../ui/components/common/DescriptionText.kt | 128 ++++++++++++++++-- 1 file changed, 120 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/common/DescriptionText.kt b/app/src/main/java/org/schabi/newpipe/ui/components/common/DescriptionText.kt index 40c5903c5e8..d4219f5b623 100644 --- a/app/src/main/java/org/schabi/newpipe/ui/components/common/DescriptionText.kt +++ b/app/src/main/java/org/schabi/newpipe/ui/components/common/DescriptionText.kt @@ -1,18 +1,23 @@ package org.schabi.newpipe.ui.components.common +import android.net.Uri import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Modifier import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.LinkAnnotation import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.TextLinkStyles import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.fromHtml import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextOverflow import org.schabi.newpipe.extractor.stream.Description +import org.schabi.newpipe.util.text.TimestampExtractor @Composable fun DescriptionText( @@ -20,11 +25,12 @@ fun DescriptionText( modifier: Modifier = Modifier, overflow: TextOverflow = TextOverflow.Clip, maxLines: Int = Int.MAX_VALUE, - style: TextStyle = LocalTextStyle.current + style: TextStyle = LocalTextStyle.current, + onTimestampClick: ((Int) -> Unit)? = null ) { Text( modifier = modifier, - text = rememberParsedDescription(description), + text = rememberParsedDescription(description, onTimestampClick), maxLines = maxLines, style = style, overflow = overflow @@ -32,14 +38,120 @@ fun DescriptionText( } @Composable -fun rememberParsedDescription(description: Description): AnnotatedString { - // TODO: Handle links and hashtags, Markdown. - return remember(description) { +fun rememberParsedDescription( + description: Description, + onTimestampClick: ((Int) -> Unit)? = null +): AnnotatedString { + // TODO: Handle hashtags, Markdown. + // rememberUpdatedState lets the click handlers inside the AnnotatedString always invoke + // the latest callback without rebuilding the string on every recomposition. + val onTimestampClickState = rememberUpdatedState(onTimestampClick) + // Use a boolean key so the string is only rebuilt when handler presence changes, + // not on every lambda identity change. + val hasTimestampHandler = onTimestampClick != null + + return remember(description, hasTimestampHandler) { + val linkStyle = TextLinkStyles(SpanStyle(textDecoration = TextDecoration.Underline)) + if (description.type == Description.HTML) { - val styles = TextLinkStyles(SpanStyle(textDecoration = TextDecoration.Underline)) - AnnotatedString.fromHtml(description.content, styles) + val baseString = AnnotatedString.fromHtml(description.content, linkStyle) + if (!hasTimestampHandler) return@remember baseString + + // Rebuild the AnnotatedString, replacing YouTube timestamp URL annotations + // with Clickable ones so they seek the player instead of opening YouTube. + buildAnnotatedString { + append(baseString.text) + for (span in baseString.spanStyles) { + addStyle(span.item, span.start, span.end) + } + for (para in baseString.paragraphStyles) { + addStyle(para.item, para.start, para.end) + } + + val handledRanges = mutableListOf() + + for (link in baseString.getLinkAnnotations(0, baseString.length)) { + val ann = link.item + if (ann is LinkAnnotation.Url) { + val secs = getTimestampSecondsFromUrl(ann.url) + if (secs != null) { + addLink( + clickable = LinkAnnotation.Clickable( + tag = "timestamp", + styles = linkStyle, + linkInteractionListener = { + onTimestampClickState.value?.invoke(secs) + } + ), + start = link.start, + end = link.end + ) + handledRanges += link.start..link.end + continue + } + addLink(ann, link.start, link.end) + } else if (ann is LinkAnnotation.Clickable) { + addLink(ann, link.start, link.end) + } + } + + // Also add Clickables for plain-text timestamp patterns not already covered + val text = baseString.text + val matcher = TimestampExtractor.TIMESTAMPS_PATTERN.matcher(text) + while (matcher.find()) { + val dto = TimestampExtractor.getTimestampFromMatcher(matcher, text) ?: continue + if (handledRanges.none { dto.timestampStart() in it }) { + val secs = dto.seconds() + addLink( + clickable = LinkAnnotation.Clickable( + tag = "timestamp", + styles = linkStyle, + linkInteractionListener = { + onTimestampClickState.value?.invoke(secs) + } + ), + start = dto.timestampStart(), + end = dto.timestampEnd() + ) + } + } + } } else { - AnnotatedString(description.content) + // Plain text: scan for timestamp patterns + val baseString = AnnotatedString(description.content) + if (!hasTimestampHandler) return@remember baseString + + val text = baseString.text + val matcher = TimestampExtractor.TIMESTAMPS_PATTERN.matcher(text) + if (!matcher.find()) return@remember baseString + + buildAnnotatedString { + append(baseString) + matcher.reset() + while (matcher.find()) { + val dto = TimestampExtractor.getTimestampFromMatcher(matcher, text) ?: continue + val secs = dto.seconds() + addLink( + clickable = LinkAnnotation.Clickable( + tag = "timestamp", + styles = linkStyle, + linkInteractionListener = { + onTimestampClickState.value?.invoke(secs) + } + ), + start = dto.timestampStart(), + end = dto.timestampEnd() + ) + } + } } } } + +private fun getTimestampSecondsFromUrl(url: String): Int? { + return try { + Uri.parse(url).getQueryParameter("t")?.toIntOrNull() + } catch (e: Exception) { + null + } +} From aab0970f35f851834c85348007a680dd4533e2b5 Mon Sep 17 00:00:00 2001 From: h Date: Mon, 13 Apr 2026 08:07:53 +0200 Subject: [PATCH 2/5] Seek video and scroll back to player when a comment timestamp is tapped --- .../list/comments/CommentsFragment.kt | 7 +++- .../ui/components/video/comment/Comment.kt | 15 ++++--- .../video/comment/CommentRepliesDialog.kt | 14 ++++--- .../video/comment/CommentRepliesHeader.kt | 11 ++++-- .../video/comment/CommentSection.kt | 39 +++++++++++++++---- 5 files changed, 65 insertions(+), 21 deletions(-) diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/comments/CommentsFragment.kt b/app/src/main/java/org/schabi/newpipe/fragments/list/comments/CommentsFragment.kt index 26bfa7728e2..240c6e6b957 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/comments/CommentsFragment.kt +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/comments/CommentsFragment.kt @@ -7,6 +7,7 @@ import androidx.compose.material3.Surface import androidx.core.os.bundleOf import androidx.fragment.app.Fragment import androidx.fragment.compose.content +import org.schabi.newpipe.fragments.detail.VideoDetailFragment import org.schabi.newpipe.ui.components.video.comment.CommentSection import org.schabi.newpipe.ui.theme.AppTheme import org.schabi.newpipe.util.KEY_SERVICE_ID @@ -20,7 +21,11 @@ class CommentsFragment : Fragment() { ) = content { AppTheme { Surface { - CommentSection() + CommentSection( + onScrollToPlayer = { + (parentFragment as? VideoDetailFragment)?.scrollToTop() + } + ) } } } diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/video/comment/Comment.kt b/app/src/main/java/org/schabi/newpipe/ui/components/video/comment/Comment.kt index 8864f8253df..20e0649aa5f 100644 --- a/app/src/main/java/org/schabi/newpipe/ui/components/video/comment/Comment.kt +++ b/app/src/main/java/org/schabi/newpipe/ui/components/video/comment/Comment.kt @@ -55,11 +55,15 @@ import org.schabi.newpipe.util.image.ImageStrategy @OptIn(ExperimentalFoundationApi::class) @Composable -fun Comment(comment: CommentsInfoItem, onCommentAuthorOpened: () -> Unit) { +fun Comment( + comment: CommentsInfoItem, + onCommentAuthorOpened: () -> Unit, + onTimestampClick: ((Int) -> Unit)? = null +) { val context = LocalContext.current var isExpanded by rememberSaveable { mutableStateOf(false) } var showReplies by rememberSaveable { mutableStateOf(false) } - val parsedDescription = rememberParsedDescription(comment.commentText) + val parsedDescription = rememberParsedDescription(comment.commentText, onTimestampClick) Row( modifier = Modifier @@ -187,7 +191,8 @@ fun Comment(comment: CommentsInfoItem, onCommentAuthorOpened: () -> Unit) { CommentRepliesDialog( parentComment = comment, onDismissRequest = { showReplies = false }, - onCommentAuthorOpened = onCommentAuthorOpened + onCommentAuthorOpened = onCommentAuthorOpened, + onTimestampClick = onTimestampClick ) } } @@ -265,7 +270,7 @@ private fun CommentPreview( ) { AppTheme { Surface { - Comment(commentsInfoItem) {} + Comment(commentsInfoItem, onCommentAuthorOpened = {}) } } } @@ -277,7 +282,7 @@ private fun CommentListPreview() { Surface { Column { for (comment in CommentPreviewProvider().values) { - Comment(comment) {} + Comment(comment, onCommentAuthorOpened = {}) } } } diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/video/comment/CommentRepliesDialog.kt b/app/src/main/java/org/schabi/newpipe/ui/components/video/comment/CommentRepliesDialog.kt index 445178c60a1..91257f6fba7 100644 --- a/app/src/main/java/org/schabi/newpipe/ui/components/video/comment/CommentRepliesDialog.kt +++ b/app/src/main/java/org/schabi/newpipe/ui/components/video/comment/CommentRepliesDialog.kt @@ -45,7 +45,8 @@ import org.schabi.newpipe.ui.theme.AppTheme fun CommentRepliesDialog( parentComment: CommentsInfoItem, onDismissRequest: () -> Unit, - onCommentAuthorOpened: () -> Unit + onCommentAuthorOpened: () -> Unit, + onTimestampClick: ((Int) -> Unit)? = null ) { val coroutineScope = rememberCoroutineScope() val commentsFlow = remember { @@ -56,7 +57,7 @@ fun CommentRepliesDialog( .cachedIn(coroutineScope) } - CommentRepliesDialog(parentComment, commentsFlow, onDismissRequest, onCommentAuthorOpened) + CommentRepliesDialog(parentComment, commentsFlow, onDismissRequest, onCommentAuthorOpened, onTimestampClick) } @OptIn(ExperimentalMaterial3Api::class) @@ -65,7 +66,8 @@ private fun CommentRepliesDialog( parentComment: CommentsInfoItem, commentsFlow: Flow>, onDismissRequest: () -> Unit, - onCommentAuthorOpened: () -> Unit + onCommentAuthorOpened: () -> Unit, + onTimestampClick: ((Int) -> Unit)? = null ) { val comments = commentsFlow.collectAsLazyPagingItems() val nestedScrollInterop = rememberNestedScrollInteropConnection() @@ -93,7 +95,8 @@ private fun CommentRepliesDialog( item { CommentRepliesHeader( comment = parentComment, - onCommentAuthorOpened = nestedOnCommentAuthorOpened + onCommentAuthorOpened = nestedOnCommentAuthorOpened, + onTimestampClick = onTimestampClick ) HorizontalDivider( thickness = 1.dp, @@ -145,7 +148,8 @@ private fun CommentRepliesDialog( items(comments.itemCount) { Comment( comment = comments[it]!!, - onCommentAuthorOpened = nestedOnCommentAuthorOpened + onCommentAuthorOpened = nestedOnCommentAuthorOpened, + onTimestampClick = onTimestampClick ) } } diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/video/comment/CommentRepliesHeader.kt b/app/src/main/java/org/schabi/newpipe/ui/components/video/comment/CommentRepliesHeader.kt index 4530ebca6c0..4c392e9b069 100644 --- a/app/src/main/java/org/schabi/newpipe/ui/components/video/comment/CommentRepliesHeader.kt +++ b/app/src/main/java/org/schabi/newpipe/ui/components/video/comment/CommentRepliesHeader.kt @@ -39,7 +39,11 @@ import org.schabi.newpipe.util.NavigationHelper import org.schabi.newpipe.util.image.ImageStrategy @Composable -fun CommentRepliesHeader(comment: CommentsInfoItem, onCommentAuthorOpened: () -> Unit) { +fun CommentRepliesHeader( + comment: CommentsInfoItem, + onCommentAuthorOpened: () -> Unit, + onTimestampClick: ((Int) -> Unit)? = null +) { val context = LocalContext.current Column( @@ -126,7 +130,8 @@ fun CommentRepliesHeader(comment: CommentsInfoItem, onCommentAuthorOpened: () -> DescriptionText( description = comment.commentText, - style = MaterialTheme.typography.bodyMedium + style = MaterialTheme.typography.bodyMedium, + onTimestampClick = onTimestampClick ) } } @@ -145,7 +150,7 @@ fun CommentRepliesHeaderPreview() { AppTheme { Surface { - CommentRepliesHeader(comment) {} + CommentRepliesHeader(comment, onCommentAuthorOpened = {}) } } } diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/video/comment/CommentSection.kt b/app/src/main/java/org/schabi/newpipe/ui/components/video/comment/CommentSection.kt index 4e49676ef3c..c289512d87b 100644 --- a/app/src/main/java/org/schabi/newpipe/ui/components/video/comment/CommentSection.kt +++ b/app/src/main/java/org/schabi/newpipe/ui/components/video/comment/CommentSection.kt @@ -13,9 +13,12 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.rememberNestedScrollInteropConnection import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.tooling.preview.Preview @@ -30,6 +33,7 @@ import kotlinx.coroutines.flow.flowOf import org.schabi.newpipe.R import org.schabi.newpipe.error.ErrorInfo import org.schabi.newpipe.error.UserAction +import org.schabi.newpipe.extractor.NewPipe import org.schabi.newpipe.extractor.Page import org.schabi.newpipe.extractor.comments.CommentsInfoItem import org.schabi.newpipe.extractor.stream.Description @@ -39,20 +43,38 @@ import org.schabi.newpipe.ui.components.common.LoadingIndicator import org.schabi.newpipe.ui.emptystate.EmptyStateComposable import org.schabi.newpipe.ui.emptystate.EmptyStateSpec import org.schabi.newpipe.ui.theme.AppTheme +import org.schabi.newpipe.util.text.InternalUrlsHandler import org.schabi.newpipe.viewmodels.CommentsViewModel import org.schabi.newpipe.viewmodels.util.Resource @Composable -fun CommentSection(commentsViewModel: CommentsViewModel = viewModel()) { +fun CommentSection( + commentsViewModel: CommentsViewModel = viewModel(), + onScrollToPlayer: () -> Unit = {} +) { val state by commentsViewModel.uiState.collectAsStateWithLifecycle() - CommentSection(state, commentsViewModel.comments) + CommentSection(state, commentsViewModel.comments, onScrollToPlayer) } @Composable private fun CommentSection( uiState: Resource, - commentsFlow: Flow> + commentsFlow: Flow>, + onScrollToPlayer: () -> Unit = {} ) { + val context = LocalContext.current + val commentInfo = (uiState as? Resource.Success)?.data + val onScrollToPlayerState = rememberUpdatedState(onScrollToPlayer) + val onTimestampClick: ((Int) -> Unit)? = remember(commentInfo?.serviceId, commentInfo?.url) { + if (commentInfo == null) return@remember null + runCatching { NewPipe.getService(commentInfo.serviceId) }.getOrNull() + ?.let { service -> + { seconds: Int -> + InternalUrlsHandler.playOnPopup(context, commentInfo.url, service, seconds) + onScrollToPlayerState.value() + } + } + } val comments = commentsFlow.collectAsLazyPagingItems() val nestedScrollInterop = rememberNestedScrollInteropConnection() val state = rememberLazyListState() @@ -72,10 +94,9 @@ private fun CommentSection( } is Resource.Success -> { - val commentInfo = uiState.data - val count = commentInfo.commentCount + val count = uiState.data.commentCount - if (commentInfo.isCommentsDisabled) { + if (uiState.data.isCommentsDisabled) { item { EmptyStateComposable( spec = EmptyStateSpec.DisabledComments, @@ -137,7 +158,11 @@ private fun CommentSection( else -> { items(comments.itemCount) { - Comment(comment = comments[it]!!) {} + Comment( + comment = comments[it]!!, + onCommentAuthorOpened = {}, + onTimestampClick = onTimestampClick + ) } } } From 4ff5d175ebca72206e93b6a631b6b574305a4359 Mon Sep 17 00:00:00 2001 From: h Date: Mon, 13 Apr 2026 14:23:46 +0200 Subject: [PATCH 3/5] Update Comment component to handle author click events --- .../components/video/comment/CommentSectionInstrumentedTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/androidTest/java/org/schabi/newpipe/ui/components/video/comment/CommentSectionInstrumentedTest.kt b/app/src/androidTest/java/org/schabi/newpipe/ui/components/video/comment/CommentSectionInstrumentedTest.kt index 78088f4a217..9b0c258fdb8 100644 --- a/app/src/androidTest/java/org/schabi/newpipe/ui/components/video/comment/CommentSectionInstrumentedTest.kt +++ b/app/src/androidTest/java/org/schabi/newpipe/ui/components/video/comment/CommentSectionInstrumentedTest.kt @@ -335,7 +335,7 @@ private fun TestCommentSection( } else -> items(comments.itemCount) { index -> - Comment(comment = comments[index]!!) {} + Comment(comment = comments[index]!!, onCommentAuthorOpened = {}) } } } From 2e9528d1382b2de526143a93590d3cb696719b45 Mon Sep 17 00:00:00 2001 From: h Date: Fri, 17 Apr 2026 08:24:41 +0200 Subject: [PATCH 4/5] Address PR review comments for timestamp link handling --- .../ui/components/common/DescriptionText.kt | 65 ++++++++++++------- .../ui/components/video/comment/Comment.kt | 2 +- 2 files changed, 41 insertions(+), 26 deletions(-) diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/common/DescriptionText.kt b/app/src/main/java/org/schabi/newpipe/ui/components/common/DescriptionText.kt index d4219f5b623..3c509a2c605 100644 --- a/app/src/main/java/org/schabi/newpipe/ui/components/common/DescriptionText.kt +++ b/app/src/main/java/org/schabi/newpipe/ui/components/common/DescriptionText.kt @@ -7,6 +7,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.LinkAnnotation import androidx.compose.ui.text.SpanStyle @@ -17,6 +18,7 @@ import androidx.compose.ui.text.fromHtml import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextOverflow import org.schabi.newpipe.extractor.stream.Description +import org.schabi.newpipe.util.external_communication.ShareUtils import org.schabi.newpipe.util.text.TimestampExtractor @Composable @@ -46,6 +48,7 @@ fun rememberParsedDescription( // rememberUpdatedState lets the click handlers inside the AnnotatedString always invoke // the latest callback without rebuilding the string on every recomposition. val onTimestampClickState = rememberUpdatedState(onTimestampClick) + val contextState = rememberUpdatedState(LocalContext.current) // Use a boolean key so the string is only rebuilt when handler presence changes, // not on every lambda identity change. val hasTimestampHandler = onTimestampClick != null @@ -57,41 +60,53 @@ fun rememberParsedDescription( val baseString = AnnotatedString.fromHtml(description.content, linkStyle) if (!hasTimestampHandler) return@remember baseString - // Rebuild the AnnotatedString, replacing YouTube timestamp URL annotations - // with Clickable ones so they seek the player instead of opening YouTube. + // Rebuild the AnnotatedString, replacing timestamp URL annotations + // with Clickable ones so they seek the player instead of opening the browser. buildAnnotatedString { append(baseString.text) - for (span in baseString.spanStyles) { + baseString.spanStyles.forEach { span -> addStyle(span.item, span.start, span.end) } - for (para in baseString.paragraphStyles) { + baseString.paragraphStyles.forEach { para -> addStyle(para.item, para.start, para.end) } val handledRanges = mutableListOf() - for (link in baseString.getLinkAnnotations(0, baseString.length)) { + baseString.getLinkAnnotations(0, baseString.length).forEach { link -> val ann = link.item - if (ann is LinkAnnotation.Url) { - val secs = getTimestampSecondsFromUrl(ann.url) - if (secs != null) { - addLink( - clickable = LinkAnnotation.Clickable( - tag = "timestamp", - styles = linkStyle, - linkInteractionListener = { - onTimestampClickState.value?.invoke(secs) - } - ), - start = link.start, - end = link.end - ) - handledRanges += link.start..link.end - continue + when (ann) { + is LinkAnnotation.Url -> { + val secs = getTimestampSecondsFromUrl(ann.url) + if (secs != null) { + addLink( + clickable = LinkAnnotation.Clickable( + tag = "timestamp", + styles = linkStyle, + linkInteractionListener = { + onTimestampClickState.value?.invoke(secs) + } + ), + start = link.start, + end = link.end + ) + handledRanges += link.start..link.end + } else { + val url = ann.url + addLink( + clickable = LinkAnnotation.Clickable( + tag = "url", + styles = ann.styles, + linkInteractionListener = { + ShareUtils.openUrlInApp(contextState.value, url) + } + ), + start = link.start, + end = link.end + ) + } } - addLink(ann, link.start, link.end) - } else if (ann is LinkAnnotation.Clickable) { - addLink(ann, link.start, link.end) + is LinkAnnotation.Clickable -> addLink(ann, link.start, link.end) } } @@ -151,7 +166,7 @@ fun rememberParsedDescription( private fun getTimestampSecondsFromUrl(url: String): Int? { return try { Uri.parse(url).getQueryParameter("t")?.toIntOrNull() - } catch (e: Exception) { + } catch (_: Exception) { null } } diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/video/comment/Comment.kt b/app/src/main/java/org/schabi/newpipe/ui/components/video/comment/Comment.kt index 20e0649aa5f..78550e0e56b 100644 --- a/app/src/main/java/org/schabi/newpipe/ui/components/video/comment/Comment.kt +++ b/app/src/main/java/org/schabi/newpipe/ui/components/video/comment/Comment.kt @@ -57,7 +57,7 @@ import org.schabi.newpipe.util.image.ImageStrategy @Composable fun Comment( comment: CommentsInfoItem, - onCommentAuthorOpened: () -> Unit, + onCommentAuthorOpened: () -> Unit = {}, onTimestampClick: ((Int) -> Unit)? = null ) { val context = LocalContext.current From 1d89ad935a0d3d61898824112cf850e8ce4885fe Mon Sep 17 00:00:00 2001 From: h Date: Fri, 17 Apr 2026 17:06:59 +0200 Subject: [PATCH 5/5] 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 --- .../detail/BaseDescriptionFragment.java | 19 +++-- .../fragments/detail/DescriptionFragment.java | 12 +++ .../ui/components/common/DescriptionText.kt | 1 + .../newpipe/util/text/TextEllipsizer.java | 4 +- .../newpipe/util/text/TextLinkifier.java | 75 ++++++++++++++----- .../text/TimestampLongPressClickableSpan.kt | 11 ++- .../util/text/UrlLongPressClickableSpan.java | 16 +++- 7 files changed, 110 insertions(+), 28 deletions(-) diff --git a/app/src/main/java/org/schabi/newpipe/fragments/detail/BaseDescriptionFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/detail/BaseDescriptionFragment.java index 4789b02e65b..040fcc87794 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/detail/BaseDescriptionFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/detail/BaseDescriptionFragment.java @@ -20,8 +20,6 @@ import androidx.annotation.Nullable; import androidx.annotation.StringRes; import androidx.appcompat.widget.TooltipCompat; -import androidx.core.text.HtmlCompat; - import com.google.android.material.chip.Chip; import org.schabi.newpipe.BaseFragment; @@ -69,6 +67,17 @@ public void onDestroy() { @Nullable protected abstract Description getDescription(); + /** + * Optional action to run after a timestamp in the description is clicked. + * Subclasses can override to e.g. scroll the UI to the player. + * + * @return runnable to execute, or null if no action needed + */ + @Nullable + protected Runnable getAfterTimestampClickRunnable() { + return null; + } + /** * Get the streaming service. Used for generating description links. * @return streaming service @@ -140,9 +149,9 @@ private void disableDescriptionSelection() { final Description description = getDescription(); if (description != null) { TextLinkifier.fromDescription(binding.detailDescriptionView, - description, HtmlCompat.FROM_HTML_MODE_LEGACY, - getService(), getStreamUrl(), - descriptionDisposables, SET_LINK_MOVEMENT_METHOD); + description, getService(), getStreamUrl(), + descriptionDisposables, SET_LINK_MOVEMENT_METHOD, + getAfterTimestampClickRunnable()); } binding.detailDescriptionNoteView.setVisibility(View.GONE); 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 2b0d22a32ed..ae1ec07049d 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 @@ -41,6 +41,18 @@ protected Description getDescription() { return streamInfo.getDescription(); } + @Nullable + @Override + protected Runnable getAfterTimestampClickRunnable() { + return () -> { + final VideoDetailFragment parent = + (VideoDetailFragment) getParentFragment(); + if (parent != null) { + parent.scrollToTop(); + } + }; + } + @NonNull @Override protected StreamingService getService() { diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/common/DescriptionText.kt b/app/src/main/java/org/schabi/newpipe/ui/components/common/DescriptionText.kt index 3c509a2c605..bd074b25e8c 100644 --- a/app/src/main/java/org/schabi/newpipe/ui/components/common/DescriptionText.kt +++ b/app/src/main/java/org/schabi/newpipe/ui/components/common/DescriptionText.kt @@ -106,6 +106,7 @@ fun rememberParsedDescription( ) } } + is LinkAnnotation.Clickable -> addLink(ann, link.start, link.end) } } diff --git a/app/src/main/java/org/schabi/newpipe/util/text/TextEllipsizer.java b/app/src/main/java/org/schabi/newpipe/util/text/TextEllipsizer.java index 184b73304d8..da8e765fc96 100644 --- a/app/src/main/java/org/schabi/newpipe/util/text/TextEllipsizer.java +++ b/app/src/main/java/org/schabi/newpipe/util/text/TextEllipsizer.java @@ -7,8 +7,6 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import androidx.core.text.HtmlCompat; - import org.schabi.newpipe.extractor.StreamingService; import org.schabi.newpipe.extractor.stream.Description; @@ -164,7 +162,7 @@ private void linkifyContentView(final Consumer consumer) { final boolean oldState = isEllipsized; disposable.clear(); TextLinkifier.fromDescription(view, content, - HtmlCompat.FROM_HTML_MODE_LEGACY, streamingService, streamUrl, disposable, + streamingService, streamUrl, disposable, v -> { consumer.accept(v); notifyStateChangeListener(oldState); diff --git a/app/src/main/java/org/schabi/newpipe/util/text/TextLinkifier.java b/app/src/main/java/org/schabi/newpipe/util/text/TextLinkifier.java index 4221da39841..b7c50c44596 100644 --- a/app/src/main/java/org/schabi/newpipe/util/text/TextLinkifier.java +++ b/app/src/main/java/org/schabi/newpipe/util/text/TextLinkifier.java @@ -48,8 +48,6 @@ private TextLinkifier() { * * @param textView the TextView to set the htmlBlock linked * @param description the htmlBlock to be linked - * @param htmlCompatFlag the int flag to be set if {@link HtmlCompat#fromHtml(String, int)} - * will be called (not used for formats different than HTML) * @param relatedInfoService if given, handle hashtags to search for the term in the correct * service * @param relatedStreamUrl if given, used alongside {@code relatedInfoService} to handle @@ -62,23 +60,59 @@ private TextLinkifier() { */ public static void fromDescription(@NonNull final TextView textView, @NonNull final Description description, - final int htmlCompatFlag, @Nullable final StreamingService relatedInfoService, @Nullable final String relatedStreamUrl, @NonNull final CompositeDisposable disposables, @Nullable final Consumer onCompletion) { + fromDescription(textView, description, relatedInfoService, + relatedStreamUrl, disposables, onCompletion, null); + } + + /** + * Like {@link #fromDescription(TextView, Description, StreamingService, String, + * CompositeDisposable, Consumer)} but with an extra callback that fires after a timestamp + * link is tapped and the player seek has been dispatched. + * + * @param textView the TextView to set the linked description on + * @param description the description to be linked + * @param relatedInfoService if given, handle hashtags and timestamps for this service + * @param relatedStreamUrl if given, used to open the stream in the popup player at the + * time indicated by a timestamp + * @param disposables disposables created by the method are added here and their + * lifecycle should be handled by the calling class + * @param onCompletion will be run when setting text to the textView completes; use + * {@link #SET_LINK_MOVEMENT_METHOD} to make links clickable + * @param onAfterTimestampClick invoked after a timestamp click is handled; use this to e.g. + * scroll the UI so the player is visible. May be {@code null}. + */ + public static void fromDescription(@NonNull final TextView textView, + @NonNull final Description description, + @Nullable final StreamingService relatedInfoService, + @Nullable final String relatedStreamUrl, + @NonNull final CompositeDisposable disposables, + @Nullable final Consumer onCompletion, + @Nullable final Runnable onAfterTimestampClick) { switch (description.getType()) { case Description.HTML: - TextLinkifier.fromHtml(textView, description.getContent(), htmlCompatFlag, - relatedInfoService, relatedStreamUrl, disposables, onCompletion); + changeLinkIntents(textView, + HtmlCompat.fromHtml(description.getContent(), + HtmlCompat.FROM_HTML_MODE_LEGACY), + relatedInfoService, relatedStreamUrl, disposables, onCompletion, + onAfterTimestampClick); break; case Description.MARKDOWN: - TextLinkifier.fromMarkdown(textView, description.getContent(), - relatedInfoService, relatedStreamUrl, disposables, onCompletion); + changeLinkIntents(textView, + Markwon.builder(textView.getContext()) + .usePlugin(LinkifyPlugin.create()).build() + .toMarkdown(description.getContent()), + relatedInfoService, relatedStreamUrl, disposables, onCompletion, + onAfterTimestampClick); break; case Description.PLAIN_TEXT: default: - TextLinkifier.fromPlainText(textView, description.getContent(), - relatedInfoService, relatedStreamUrl, disposables, onCompletion); + textView.setAutoLinkMask(Linkify.WEB_URLS); + textView.setText(description.getContent(), TextView.BufferType.SPANNABLE); + changeLinkIntents(textView, textView.getText(), relatedInfoService, + relatedStreamUrl, disposables, onCompletion, onAfterTimestampClick); break; } } @@ -115,7 +149,7 @@ public static void fromHtml(@NonNull final TextView textView, @Nullable final Consumer onCompletion) { changeLinkIntents( textView, HtmlCompat.fromHtml(htmlBlock, htmlCompatFlag), relatedInfoService, - relatedStreamUrl, disposables, onCompletion); + relatedStreamUrl, disposables, onCompletion, null); } /** @@ -149,7 +183,7 @@ public static void fromPlainText(@NonNull final TextView textView, textView.setAutoLinkMask(Linkify.WEB_URLS); textView.setText(plainTextBlock, TextView.BufferType.SPANNABLE); changeLinkIntents(textView, textView.getText(), relatedInfoService, - relatedStreamUrl, disposables, onCompletion); + relatedStreamUrl, disposables, onCompletion, null); } /** @@ -182,7 +216,7 @@ public static void fromMarkdown(@NonNull final TextView textView, final Markwon markwon = Markwon.builder(textView.getContext()) .usePlugin(LinkifyPlugin.create()).build(); changeLinkIntents(textView, markwon.toMarkdown(markdownBlock), - relatedInfoService, relatedStreamUrl, disposables, onCompletion); + relatedInfoService, relatedStreamUrl, disposables, onCompletion, null); } /** @@ -221,13 +255,15 @@ public static void fromMarkdown(@NonNull final TextView textView, * lifecycle should be handled by the calling class * @param onCompletion will be run when setting text to the textView completes; use {@link * #SET_LINK_MOVEMENT_METHOD} to make links clickable and focusable + * @param onAfterTimestampClick will be run after a timestamp link click action is resolved */ private static void changeLinkIntents(@NonNull final TextView textView, @NonNull final CharSequence chars, @Nullable final StreamingService relatedInfoService, @Nullable final String relatedStreamUrl, @NonNull final CompositeDisposable disposables, - @Nullable final Consumer onCompletion) { + @Nullable final Consumer onCompletion, + @Nullable final Runnable onAfterTimestampClick) { disposables.add(Single.fromCallable(() -> { final Context context = textView.getContext(); @@ -240,7 +276,8 @@ private static void changeLinkIntents(@NonNull final TextView textView, for (final URLSpan span : urls) { final String url = span.getURL(); final LongPressClickableSpan longPressClickableSpan = - new UrlLongPressClickableSpan(context, url); + new UrlLongPressClickableSpan(context, url, + onAfterTimestampClick); textBlockLinked.setSpan(longPressClickableSpan, textBlockLinked.getSpanStart(span), @@ -254,7 +291,8 @@ private static void changeLinkIntents(@NonNull final TextView textView, if (relatedInfoService != null) { if (relatedStreamUrl != null) { addClickListenersOnTimestamps(context, textBlockLinked, - relatedInfoService, relatedStreamUrl, disposables); + relatedInfoService, relatedStreamUrl, disposables, + onAfterTimestampClick); } addClickListenersOnHashtags(context, textBlockLinked, relatedInfoService); } @@ -329,13 +367,15 @@ private static void addClickListenersOnHashtags( * @param relatedStreamUrl what to open in the popup player when timestamps are clicked * @param disposables disposables created by the method are added here and their * lifecycle should be handled by the calling class + * @param onAfterTimestampClick will be run after a timestamp link click action is resolved */ private static void addClickListenersOnTimestamps( @NonNull final Context context, @NonNull final SpannableStringBuilder spannableDescription, @NonNull final StreamingService relatedInfoService, @NonNull final String relatedStreamUrl, - @NonNull final CompositeDisposable disposables) { + @NonNull final CompositeDisposable disposables, + @Nullable final Runnable onAfterTimestampClick) { final String descriptionText = spannableDescription.toString(); final Matcher timestampsMatches = TimestampExtractor.TIMESTAMPS_PATTERN.matcher( descriptionText); @@ -350,7 +390,8 @@ private static void addClickListenersOnTimestamps( spannableDescription.setSpan( new TimestampLongPressClickableSpan(context, descriptionText, disposables, - relatedInfoService, relatedStreamUrl, timestampMatchDTO), + relatedInfoService, relatedStreamUrl, timestampMatchDTO, + onAfterTimestampClick), timestampMatchDTO.timestampStart(), timestampMatchDTO.timestampEnd(), 0); diff --git a/app/src/main/java/org/schabi/newpipe/util/text/TimestampLongPressClickableSpan.kt b/app/src/main/java/org/schabi/newpipe/util/text/TimestampLongPressClickableSpan.kt index c99e2f63970..44634d8aa4d 100644 --- a/app/src/main/java/org/schabi/newpipe/util/text/TimestampLongPressClickableSpan.kt +++ b/app/src/main/java/org/schabi/newpipe/util/text/TimestampLongPressClickableSpan.kt @@ -13,13 +13,21 @@ import org.schabi.newpipe.extractor.StreamingService import org.schabi.newpipe.util.external_communication.ShareUtils import org.schabi.newpipe.util.text.TimestampExtractor.TimestampMatchDTO +/** + * A [LongPressClickableSpan] that seeks the player to a timestamp on click and copies the + * timestamp URL to the clipboard on long-click. + * + * @param onAfterClick optional callback invoked after the seek intent is dispatched, e.g. to + * scroll the UI back to the player so the user can see the updated position. + */ class TimestampLongPressClickableSpan( private val context: Context, private val descriptionText: String, private val disposables: CompositeDisposable, private val relatedInfoService: StreamingService, private val relatedStreamUrl: String, - private val timestampMatchDTO: TimestampMatchDTO + private val timestampMatchDTO: TimestampMatchDTO, + private val onAfterClick: Runnable? = null ) : LongPressClickableSpan() { override fun onClick(view: View) { InternalUrlsHandler.playOnPopup( @@ -28,6 +36,7 @@ class TimestampLongPressClickableSpan( relatedInfoService, timestampMatchDTO.seconds() ) + onAfterClick?.run() } override fun onLongClick(view: View) { diff --git a/app/src/main/java/org/schabi/newpipe/util/text/UrlLongPressClickableSpan.java b/app/src/main/java/org/schabi/newpipe/util/text/UrlLongPressClickableSpan.java index ec3cefc622f..c4ea4db0a30 100644 --- a/app/src/main/java/org/schabi/newpipe/util/text/UrlLongPressClickableSpan.java +++ b/app/src/main/java/org/schabi/newpipe/util/text/UrlLongPressClickableSpan.java @@ -4,6 +4,7 @@ import android.view.View; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import org.schabi.newpipe.util.external_communication.ShareUtils; @@ -13,16 +14,27 @@ final class UrlLongPressClickableSpan extends LongPressClickableSpan { private final Context context; @NonNull private final String url; + @Nullable + private final Runnable onAfterTimestampClick; UrlLongPressClickableSpan(@NonNull final Context context, - @NonNull final String url) { + @NonNull final String url, + @Nullable final Runnable onAfterTimestampClick) { this.context = context; this.url = url; + this.onAfterTimestampClick = onAfterTimestampClick; } @Override public void onClick(@NonNull final View view) { - if (!InternalUrlsHandler.handleUrlDescriptionTimestamp(context, url)) { + // handleUrlDescriptionTimestamp returns true when the URL is a timestamp link and the + // player seek was dispatched; only then scroll back to the player. Regular URLs fall + // through to the browser/app chooser and should not trigger a scroll. + if (InternalUrlsHandler.handleUrlDescriptionTimestamp(context, url)) { + if (onAfterTimestampClick != null) { + onAfterTimestampClick.run(); + } + } else { ShareUtils.openUrlInApp(context, url); } }