Skip to content
Open
Show file tree
Hide file tree
Changes from all 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 @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
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,173 @@
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.platform.LocalContext
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.external_communication.ShareUtils
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)
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

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 timestamp URL annotations
// with Clickable ones so they seek the player instead of opening the browser.
buildAnnotatedString {
append(baseString.text)
baseString.spanStyles.forEach { span ->
addStyle(span.item, span.start, span.end)
}
baseString.paragraphStyles.forEach { para ->
addStyle(para.item, para.start, para.end)
}

val handledRanges = mutableListOf<IntRange>()

baseString.getLinkAnnotations(0, baseString.length).forEach { link ->
val ann = link.item
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
)
}
}

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 (_: Exception) {
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 = {},
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