Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -335,7 +335,7 @@ private fun TestCommentSection(
}

else -> items(comments.itemCount) { index ->
Comment(comment = comments[index]!!) {}
Comment(comment = comments[index]!!, onCommentAuthorOpened = {})
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -20,7 +21,11 @@ class CommentsFragment : Fragment() {
) = content {
AppTheme {
Surface {
CommentSection()
CommentSection(
onScrollToPlayer = {
(parentFragment as? VideoDetailFragment)?.scrollToTop()
}
)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,45 +1,157 @@
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(
description: Description,
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
)
}

@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.
Comment thread
Ecomont marked this conversation as resolved.
// 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.
Comment thread
Ecomont marked this conversation as resolved.
Outdated
buildAnnotatedString {
append(baseString.text)
for (span in baseString.spanStyles) {
Comment thread
Ecomont marked this conversation as resolved.
Outdated
addStyle(span.item, span.start, span.end)
}
for (para in baseString.paragraphStyles) {
addStyle(para.item, para.start, para.end)
}

val handledRanges = mutableListOf<IntRange>()

for (link in baseString.getLinkAnnotations(0, baseString.length)) {
val ann = link.item
if (ann is LinkAnnotation.Url) {
Comment thread
Ecomont marked this conversation as resolved.
Outdated
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) {
Comment thread
Ecomont marked this conversation as resolved.
Outdated
null
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Comment thread
Ecomont marked this conversation as resolved.
Outdated
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
Expand Down Expand Up @@ -187,7 +191,8 @@ fun Comment(comment: CommentsInfoItem, onCommentAuthorOpened: () -> Unit) {
CommentRepliesDialog(
parentComment = comment,
onDismissRequest = { showReplies = false },
onCommentAuthorOpened = onCommentAuthorOpened
onCommentAuthorOpened = onCommentAuthorOpened,
onTimestampClick = onTimestampClick
)
}
}
Expand Down Expand Up @@ -265,7 +270,7 @@ private fun CommentPreview(
) {
AppTheme {
Surface {
Comment(commentsInfoItem) {}
Comment(commentsInfoItem, onCommentAuthorOpened = {})
}
}
}
Expand All @@ -277,7 +282,7 @@ private fun CommentListPreview() {
Surface {
Column {
for (comment in CommentPreviewProvider().values) {
Comment(comment) {}
Comment(comment, onCommentAuthorOpened = {})
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -56,7 +57,7 @@ fun CommentRepliesDialog(
.cachedIn(coroutineScope)
}

CommentRepliesDialog(parentComment, commentsFlow, onDismissRequest, onCommentAuthorOpened)
CommentRepliesDialog(parentComment, commentsFlow, onDismissRequest, onCommentAuthorOpened, onTimestampClick)
}

@OptIn(ExperimentalMaterial3Api::class)
Expand All @@ -65,7 +66,8 @@ private fun CommentRepliesDialog(
parentComment: CommentsInfoItem,
commentsFlow: Flow<PagingData<CommentsInfoItem>>,
onDismissRequest: () -> Unit,
onCommentAuthorOpened: () -> Unit
onCommentAuthorOpened: () -> Unit,
onTimestampClick: ((Int) -> Unit)? = null
) {
val comments = commentsFlow.collectAsLazyPagingItems()
val nestedScrollInterop = rememberNestedScrollInteropConnection()
Expand Down Expand Up @@ -93,7 +95,8 @@ private fun CommentRepliesDialog(
item {
CommentRepliesHeader(
comment = parentComment,
onCommentAuthorOpened = nestedOnCommentAuthorOpened
onCommentAuthorOpened = nestedOnCommentAuthorOpened,
onTimestampClick = onTimestampClick
)
HorizontalDivider(
thickness = 1.dp,
Expand Down Expand Up @@ -145,7 +148,8 @@ private fun CommentRepliesDialog(
items(comments.itemCount) {
Comment(
comment = comments[it]!!,
onCommentAuthorOpened = nestedOnCommentAuthorOpened
onCommentAuthorOpened = nestedOnCommentAuthorOpened,
onTimestampClick = onTimestampClick
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -126,7 +130,8 @@ fun CommentRepliesHeader(comment: CommentsInfoItem, onCommentAuthorOpened: () ->

DescriptionText(
description = comment.commentText,
style = MaterialTheme.typography.bodyMedium
style = MaterialTheme.typography.bodyMedium,
onTimestampClick = onTimestampClick
)
}
}
Expand All @@ -145,7 +150,7 @@ fun CommentRepliesHeaderPreview() {

AppTheme {
Surface {
CommentRepliesHeader(comment) {}
CommentRepliesHeader(comment, onCommentAuthorOpened = {})
}
}
}
Loading
Loading