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/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 + } +} 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 + ) } } }