Skip to content

Commit d0f5e1d

Browse files
committed
Parse timestamps in descriptions into clickable annotations
1 parent 44bf345 commit d0f5e1d

1 file changed

Lines changed: 120 additions & 8 deletions

File tree

Lines changed: 120 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,45 +1,157 @@
11
package org.schabi.newpipe.ui.components.common
22

3+
import android.net.Uri
34
import androidx.compose.material3.LocalTextStyle
45
import androidx.compose.material3.Text
56
import androidx.compose.runtime.Composable
67
import androidx.compose.runtime.remember
8+
import androidx.compose.runtime.rememberUpdatedState
79
import androidx.compose.ui.Modifier
810
import androidx.compose.ui.text.AnnotatedString
11+
import androidx.compose.ui.text.LinkAnnotation
912
import androidx.compose.ui.text.SpanStyle
1013
import androidx.compose.ui.text.TextLinkStyles
1114
import androidx.compose.ui.text.TextStyle
15+
import androidx.compose.ui.text.buildAnnotatedString
1216
import androidx.compose.ui.text.fromHtml
1317
import androidx.compose.ui.text.style.TextDecoration
1418
import androidx.compose.ui.text.style.TextOverflow
1519
import org.schabi.newpipe.extractor.stream.Description
20+
import org.schabi.newpipe.util.text.TimestampExtractor
1621

1722
@Composable
1823
fun DescriptionText(
1924
description: Description,
2025
modifier: Modifier = Modifier,
2126
overflow: TextOverflow = TextOverflow.Clip,
2227
maxLines: Int = Int.MAX_VALUE,
23-
style: TextStyle = LocalTextStyle.current
28+
style: TextStyle = LocalTextStyle.current,
29+
onTimestampClick: ((Int) -> Unit)? = null
2430
) {
2531
Text(
2632
modifier = modifier,
27-
text = rememberParsedDescription(description),
33+
text = rememberParsedDescription(description, onTimestampClick),
2834
maxLines = maxLines,
2935
style = style,
3036
overflow = overflow
3137
)
3238
}
3339

3440
@Composable
35-
fun rememberParsedDescription(description: Description): AnnotatedString {
36-
// TODO: Handle links and hashtags, Markdown.
37-
return remember(description) {
41+
fun rememberParsedDescription(
42+
description: Description,
43+
onTimestampClick: ((Int) -> Unit)? = null
44+
): AnnotatedString {
45+
// TODO: Handle hashtags, Markdown.
46+
// rememberUpdatedState lets the click handlers inside the AnnotatedString always invoke
47+
// the latest callback without rebuilding the string on every recomposition.
48+
val onTimestampClickState = rememberUpdatedState(onTimestampClick)
49+
// Use a boolean key so the string is only rebuilt when handler presence changes,
50+
// not on every lambda identity change.
51+
val hasTimestampHandler = onTimestampClick != null
52+
53+
return remember(description, hasTimestampHandler) {
54+
val linkStyle = TextLinkStyles(SpanStyle(textDecoration = TextDecoration.Underline))
55+
3856
if (description.type == Description.HTML) {
39-
val styles = TextLinkStyles(SpanStyle(textDecoration = TextDecoration.Underline))
40-
AnnotatedString.fromHtml(description.content, styles)
57+
val baseString = AnnotatedString.fromHtml(description.content, linkStyle)
58+
if (!hasTimestampHandler) return@remember baseString
59+
60+
// Rebuild the AnnotatedString, replacing YouTube timestamp URL annotations
61+
// with Clickable ones so they seek the player instead of opening YouTube.
62+
buildAnnotatedString {
63+
append(baseString.text)
64+
for (span in baseString.spanStyles) {
65+
addStyle(span.item, span.start, span.end)
66+
}
67+
for (para in baseString.paragraphStyles) {
68+
addStyle(para.item, para.start, para.end)
69+
}
70+
71+
val handledRanges = mutableListOf<IntRange>()
72+
73+
for (link in baseString.getLinkAnnotations(0, baseString.length)) {
74+
val ann = link.item
75+
if (ann is LinkAnnotation.Url) {
76+
val secs = getTimestampSecondsFromUrl(ann.url)
77+
if (secs != null) {
78+
addLink(
79+
clickable = LinkAnnotation.Clickable(
80+
tag = "timestamp",
81+
styles = linkStyle,
82+
linkInteractionListener = {
83+
onTimestampClickState.value?.invoke(secs)
84+
}
85+
),
86+
start = link.start,
87+
end = link.end
88+
)
89+
handledRanges += link.start..link.end
90+
continue
91+
}
92+
addLink(ann, link.start, link.end)
93+
} else if (ann is LinkAnnotation.Clickable) {
94+
addLink(ann, link.start, link.end)
95+
}
96+
}
97+
98+
// Also add Clickables for plain-text timestamp patterns not already covered
99+
val text = baseString.text
100+
val matcher = TimestampExtractor.TIMESTAMPS_PATTERN.matcher(text)
101+
while (matcher.find()) {
102+
val dto = TimestampExtractor.getTimestampFromMatcher(matcher, text) ?: continue
103+
if (handledRanges.none { dto.timestampStart() in it }) {
104+
val secs = dto.seconds()
105+
addLink(
106+
clickable = LinkAnnotation.Clickable(
107+
tag = "timestamp",
108+
styles = linkStyle,
109+
linkInteractionListener = {
110+
onTimestampClickState.value?.invoke(secs)
111+
}
112+
),
113+
start = dto.timestampStart(),
114+
end = dto.timestampEnd()
115+
)
116+
}
117+
}
118+
}
41119
} else {
42-
AnnotatedString(description.content)
120+
// Plain text: scan for timestamp patterns
121+
val baseString = AnnotatedString(description.content)
122+
if (!hasTimestampHandler) return@remember baseString
123+
124+
val text = baseString.text
125+
val matcher = TimestampExtractor.TIMESTAMPS_PATTERN.matcher(text)
126+
if (!matcher.find()) return@remember baseString
127+
128+
buildAnnotatedString {
129+
append(baseString)
130+
matcher.reset()
131+
while (matcher.find()) {
132+
val dto = TimestampExtractor.getTimestampFromMatcher(matcher, text) ?: continue
133+
val secs = dto.seconds()
134+
addLink(
135+
clickable = LinkAnnotation.Clickable(
136+
tag = "timestamp",
137+
styles = linkStyle,
138+
linkInteractionListener = {
139+
onTimestampClickState.value?.invoke(secs)
140+
}
141+
),
142+
start = dto.timestampStart(),
143+
end = dto.timestampEnd()
144+
)
145+
}
146+
}
43147
}
44148
}
45149
}
150+
151+
private fun getTimestampSecondsFromUrl(url: String): Int? {
152+
return try {
153+
Uri.parse(url).getQueryParameter("t")?.toIntOrNull()
154+
} catch (e: Exception) {
155+
null
156+
}
157+
}

0 commit comments

Comments
 (0)