diff --git a/app/build.gradle b/app/build.gradle index 00e3ad65913..bfd87ba45d1 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -298,6 +298,9 @@ dependencies { // Coroutines interop implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-rx3:1.8.1' + // Custom browser tab + implementation 'androidx.browser:browser:1.8.0' + /** Debugging **/ // Memory leak detection debugImplementation "com.squareup.leakcanary:leakcanary-object-watcher-android:${leakCanaryVersion}" diff --git a/app/src/main/java/org/schabi/newpipe/fragments/detail/DescriptionFragment.kt b/app/src/main/java/org/schabi/newpipe/fragments/detail/DescriptionFragment.kt index a00059f6508..c32a80fd2ef 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/detail/DescriptionFragment.kt +++ b/app/src/main/java/org/schabi/newpipe/fragments/detail/DescriptionFragment.kt @@ -10,7 +10,7 @@ import androidx.fragment.app.Fragment import androidx.fragment.compose.content import org.schabi.newpipe.extractor.stream.StreamInfo import org.schabi.newpipe.ktx.serializable -import org.schabi.newpipe.ui.components.video.VideoDescriptionSection +import org.schabi.newpipe.ui.components.video.StreamDescriptionSection import org.schabi.newpipe.ui.theme.AppTheme import org.schabi.newpipe.util.KEY_INFO @@ -22,7 +22,7 @@ class DescriptionFragment : Fragment() { ) = content { AppTheme { Surface(color = MaterialTheme.colorScheme.background) { - VideoDescriptionSection(requireArguments().serializable(KEY_INFO)!!) + StreamDescriptionSection(requireArguments().serializable(KEY_INFO)!!) } } } 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 deleted file mode 100644 index 9c79f1a9574..00000000000 --- a/app/src/main/java/org/schabi/newpipe/ui/components/common/DescriptionText.kt +++ /dev/null @@ -1,45 +0,0 @@ -package org.schabi.newpipe.ui.components.common - -import androidx.compose.material3.LocalTextStyle -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.TextLayoutResult -import androidx.compose.ui.text.TextLinkStyles -import androidx.compose.ui.text.TextStyle -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 - -@Composable -fun DescriptionText( - description: Description, - modifier: Modifier = Modifier, - overflow: TextOverflow = TextOverflow.Clip, - maxLines: Int = Int.MAX_VALUE, - onTextLayout: (TextLayoutResult) -> Unit = {}, - style: TextStyle = LocalTextStyle.current -) { - // TODO: Handle links and hashtags, Markdown. - val parsedDescription = remember(description) { - if (description.type == Description.HTML) { - val styles = TextLinkStyles(SpanStyle(textDecoration = TextDecoration.Underline)) - AnnotatedString.fromHtml(description.content, styles) - } else { - AnnotatedString(description.content) - } - } - - Text( - modifier = modifier, - text = parsedDescription, - maxLines = maxLines, - style = style, - overflow = overflow, - onTextLayout = onTextLayout - ) -} diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/common/Markdown.kt b/app/src/main/java/org/schabi/newpipe/ui/components/common/Markdown.kt new file mode 100644 index 00000000000..57bfce7fd3a --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/ui/components/common/Markdown.kt @@ -0,0 +1,165 @@ +package org.schabi.newpipe.ui.components.common + +import android.graphics.Typeface +import android.text.Layout +import android.text.Spanned +import android.text.style.AbsoluteSizeSpan +import android.text.style.AlignmentSpan +import android.text.style.BackgroundColorSpan +import android.text.style.ForegroundColorSpan +import android.text.style.RelativeSizeSpan +import android.text.style.StrikethroughSpan +import android.text.style.StyleSpan +import android.text.style.SubscriptSpan +import android.text.style.SuperscriptSpan +import android.text.style.TypefaceSpan +import android.text.style.URLSpan +import android.text.style.UnderlineSpan +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.LinkInteractionListener +import androidx.compose.ui.text.ParagraphStyle +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLinkStyles +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.BaselineShift +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.unit.em +import androidx.core.text.getSpans + +// The code below is copied from Html.android.kt in the Compose Text library, with some minor +// changes. + +internal fun Spanned.toAnnotatedString( + linkStyles: TextLinkStyles? = null, + linkInteractionListener: LinkInteractionListener? = null +): AnnotatedString { + return AnnotatedString.Builder(capacity = length) + .append(this) + .also { + it.addSpans(this, linkStyles, linkInteractionListener) + } + .toAnnotatedString() +} + +private fun AnnotatedString.Builder.addSpans( + spanned: Spanned, + linkStyles: TextLinkStyles?, + linkInteractionListener: LinkInteractionListener? +) { + spanned.getSpans().forEach { span -> + addSpan( + span, + spanned.getSpanStart(span), + spanned.getSpanEnd(span), + linkStyles, + linkInteractionListener + ) + } +} + +private fun AnnotatedString.Builder.addSpan( + span: Any, + start: Int, + end: Int, + linkStyles: TextLinkStyles?, + linkInteractionListener: LinkInteractionListener? +) { + when (span) { + is AbsoluteSizeSpan -> { + // TODO: Add Compose's implementation when it is available. + } + + is AlignmentSpan -> { + addStyle(span.toParagraphStyle(), start, end) + } + + is BackgroundColorSpan -> { + addStyle(SpanStyle(background = Color(span.backgroundColor)), start, end) + } + + is ForegroundColorSpan -> { + addStyle(SpanStyle(color = Color(span.foregroundColor)), start, end) + } + + is RelativeSizeSpan -> { + addStyle(SpanStyle(fontSize = span.sizeChange.em), start, end) + } + + is StrikethroughSpan -> { + addStyle(SpanStyle(textDecoration = TextDecoration.LineThrough), start, end) + } + + is StyleSpan -> { + span.toSpanStyle()?.let { addStyle(it, start, end) } + } + + is SubscriptSpan -> { + addStyle(SpanStyle(baselineShift = BaselineShift.Subscript), start, end) + } + + is SuperscriptSpan -> { + addStyle(SpanStyle(baselineShift = BaselineShift.Superscript), start, end) + } + + is TypefaceSpan -> { + addStyle(span.toSpanStyle(), start, end) + } + + is UnderlineSpan -> { + addStyle(SpanStyle(textDecoration = TextDecoration.Underline), start, end) + } + + is URLSpan -> { + span.url?.let { url -> + val link = LinkAnnotation.Url(url, linkStyles, linkInteractionListener) + addLink(link, start, end) + } + } + } +} + +private fun AlignmentSpan.toParagraphStyle(): ParagraphStyle { + val alignment = when (this.alignment) { + Layout.Alignment.ALIGN_NORMAL -> TextAlign.Start + Layout.Alignment.ALIGN_CENTER -> TextAlign.Center + Layout.Alignment.ALIGN_OPPOSITE -> TextAlign.End + else -> TextAlign.Unspecified + } + return ParagraphStyle(textAlign = alignment) +} + +private fun StyleSpan.toSpanStyle(): SpanStyle? { + return when (style) { + Typeface.BOLD -> SpanStyle(fontWeight = FontWeight.Bold) + Typeface.ITALIC -> SpanStyle(fontStyle = FontStyle.Italic) + Typeface.BOLD_ITALIC -> SpanStyle(fontWeight = FontWeight.Bold, fontStyle = FontStyle.Italic) + else -> null + } +} + +private fun TypefaceSpan.toSpanStyle(): SpanStyle { + val fontFamily = when (family) { + FontFamily.Cursive.name -> FontFamily.Cursive + FontFamily.Monospace.name -> FontFamily.Monospace + FontFamily.SansSerif.name -> FontFamily.SansSerif + FontFamily.Serif.name -> FontFamily.Serif + else -> { + optionalFontFamilyFromName(family) + } + } + return SpanStyle(fontFamily = fontFamily) +} + +private fun optionalFontFamilyFromName(familyName: String?): FontFamily? { + if (familyName.isNullOrEmpty()) return null + val typeface = Typeface.create(familyName, Typeface.NORMAL) + return typeface.takeIf { + typeface != Typeface.DEFAULT && + typeface != Typeface.create(Typeface.DEFAULT, Typeface.NORMAL) + }?.let { FontFamily(it) } +} diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/common/ParseDescription.kt b/app/src/main/java/org/schabi/newpipe/ui/components/common/ParseDescription.kt new file mode 100644 index 00000000000..a2b279f9e29 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/ui/components/common/ParseDescription.kt @@ -0,0 +1,72 @@ +package org.schabi.newpipe.ui.components.common + +import android.content.res.Configuration +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLinkStyles +import androidx.compose.ui.text.fromHtml +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.noties.markwon.Markwon +import io.noties.markwon.linkify.LinkifyPlugin +import org.schabi.newpipe.extractor.ServiceList +import org.schabi.newpipe.extractor.stream.Description +import org.schabi.newpipe.ui.components.common.link.YouTubeLinkHandler +import org.schabi.newpipe.ui.theme.AppTheme +import org.schabi.newpipe.util.NO_SERVICE_ID + +@Composable +fun parseDescription(description: Description, serviceId: Int): AnnotatedString { + val context = LocalContext.current + val linkHandler = remember(serviceId) { + if (serviceId == ServiceList.YouTube.serviceId) { + YouTubeLinkHandler(context) + } else { + null + } + } + val styles = TextLinkStyles(SpanStyle(textDecoration = TextDecoration.Underline)) + + return remember(description) { + when (description.type) { + Description.HTML -> AnnotatedString.fromHtml(description.content, styles, linkHandler) + Description.MARKDOWN -> { + Markwon.builder(context) + .usePlugin(LinkifyPlugin.create()) + .build() + .toMarkdown(description.content) + .toAnnotatedString(styles, linkHandler) + } + else -> AnnotatedString(description.content) + } + } +} + +private class DescriptionPreviewProvider : PreviewParameterProvider { + override val values = sequenceOf( + Description("This is a description.", Description.PLAIN_TEXT), + Description("This is a bold description.", Description.HTML), + Description("This is a [link](https://example.com).", Description.MARKDOWN), + ) +} + +@Preview(name = "Light mode", uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(name = "Dark mode", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun ParseDescriptionPreview( + @PreviewParameter(DescriptionPreviewProvider::class) description: Description +) { + AppTheme { + Surface(color = MaterialTheme.colorScheme.background) { + Text(text = parseDescription(description, NO_SERVICE_ID)) + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/common/link/YouTubeLinkHandler.kt b/app/src/main/java/org/schabi/newpipe/ui/components/common/link/YouTubeLinkHandler.kt new file mode 100644 index 00000000000..33ae226032d --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/ui/components/common/link/YouTubeLinkHandler.kt @@ -0,0 +1,25 @@ +package org.schabi.newpipe.ui.components.common.link + +import android.content.Context +import androidx.browser.customtabs.CustomTabsIntent +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.LinkInteractionListener +import androidx.core.net.toUri +import org.schabi.newpipe.extractor.ServiceList +import org.schabi.newpipe.util.NavigationHelper + +class YouTubeLinkHandler(private val context: Context) : LinkInteractionListener { + override fun onClick(link: LinkAnnotation) { + val uri = (link as LinkAnnotation.Url).url.toUri() + + // TODO: Handle other links in NewPipe as well. + if ("hashtag" in uri.pathSegments) { + NavigationHelper.openSearch( + context, ServiceList.YouTube.serviceId, "#${uri.lastPathSegment}" + ) + } else { + // Open link in custom browser tab. + CustomTabsIntent.Builder().build().launchUrl(context, uri) + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/metadata/MetadataItem.kt b/app/src/main/java/org/schabi/newpipe/ui/components/metadata/MetadataItem.kt index 9f18e37b3e4..18c990c77d7 100644 --- a/app/src/main/java/org/schabi/newpipe/ui/components/metadata/MetadataItem.kt +++ b/app/src/main/java/org/schabi/newpipe/ui/components/metadata/MetadataItem.kt @@ -37,7 +37,7 @@ fun MetadataItem(@StringRes title: Int, value: AnnotatedString) { Text( modifier = Modifier.width(96.dp), textAlign = TextAlign.End, - text = stringResource(title), + text = stringResource(title).uppercase(), fontWeight = FontWeight.Bold ) diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/metadata/TagsSection.kt b/app/src/main/java/org/schabi/newpipe/ui/components/metadata/TagsSection.kt index dc3649cae4d..2357c69818b 100644 --- a/app/src/main/java/org/schabi/newpipe/ui/components/metadata/TagsSection.kt +++ b/app/src/main/java/org/schabi/newpipe/ui/components/metadata/TagsSection.kt @@ -7,8 +7,8 @@ import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ElevatedSuggestionChip import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.SuggestionChip import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -34,14 +34,14 @@ fun TagsSection(serviceId: Int, tags: List) { Column(modifier = Modifier.padding(4.dp)) { Text( modifier = Modifier.fillMaxWidth(), - text = stringResource(R.string.metadata_tags), + text = stringResource(R.string.metadata_tags).uppercase(), fontWeight = FontWeight.Bold, textAlign = TextAlign.Center ) FlowRow(horizontalArrangement = Arrangement.spacedBy(4.dp)) { for (tag in sortedTags) { - SuggestionChip( + ElevatedSuggestionChip( onClick = { NavigationHelper.openSearchFragment( (context as FragmentActivity).supportFragmentManager, serviceId, tag diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/video/VideoDescriptionSection.kt b/app/src/main/java/org/schabi/newpipe/ui/components/video/StreamDescriptionSection.kt similarity index 95% rename from app/src/main/java/org/schabi/newpipe/ui/components/video/VideoDescriptionSection.kt rename to app/src/main/java/org/schabi/newpipe/ui/components/video/StreamDescriptionSection.kt index a4bbee9c654..7805a3ada5e 100644 --- a/app/src/main/java/org/schabi/newpipe/ui/components/video/VideoDescriptionSection.kt +++ b/app/src/main/java/org/schabi/newpipe/ui/components/video/StreamDescriptionSection.kt @@ -50,7 +50,7 @@ import org.schabi.newpipe.extractor.stream.Description import org.schabi.newpipe.extractor.stream.StreamExtractor import org.schabi.newpipe.extractor.stream.StreamInfo import org.schabi.newpipe.extractor.stream.StreamType -import org.schabi.newpipe.ui.components.common.DescriptionText +import org.schabi.newpipe.ui.components.common.parseDescription import org.schabi.newpipe.ui.components.metadata.MetadataItem import org.schabi.newpipe.ui.components.metadata.TagsSection import org.schabi.newpipe.ui.components.metadata.imageMetadataItem @@ -61,7 +61,7 @@ import java.time.OffsetDateTime @OptIn(ExperimentalMaterial3Api::class) @Composable -fun VideoDescriptionSection(streamInfo: StreamInfo) { +fun StreamDescriptionSection(streamInfo: StreamInfo) { var isSelectable by rememberSaveable { mutableStateOf(false) } val hasDescription = streamInfo.description != Description.EMPTY_DESCRIPTION val lazyListState = rememberLazyListState() @@ -131,12 +131,14 @@ fun VideoDescriptionSection(streamInfo: StreamInfo) { if (hasDescription) { item { + val description = parseDescription(streamInfo.description, streamInfo.serviceId) + if (isSelectable) { SelectionContainer { - DescriptionText(description = streamInfo.description) + Text(text = description) } } else { - DescriptionText(description = streamInfo.description) + Text(text = description) } } } @@ -212,14 +214,14 @@ private fun LazyListScope.metadataItem(@StringRes title: Int, value: String) { @Preview(name = "Light mode", uiMode = Configuration.UI_MODE_NIGHT_NO) @Preview(name = "Dark mode", uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable -private fun VideoDescriptionSectionPreview() { +private fun StreamDescriptionSectionPreview() { val info = StreamInfo(NO_SERVICE_ID, "", "", StreamType.VIDEO_STREAM, "", "", 0) info.uploadDate = DateWrapper(OffsetDateTime.now()) info.description = Description("This is an example description", Description.HTML) AppTheme { Surface(color = MaterialTheme.colorScheme.background) { - VideoDescriptionSection(info) + StreamDescriptionSection(info) } } }