From 99996d940d82a21a75499a6090735c199a9cef38 Mon Sep 17 00:00:00 2001 From: Isira Seneviratne Date: Tue, 27 Aug 2024 08:18:52 +0530 Subject: [PATCH 01/14] Rename .java to .kt --- .../detail/{DescriptionFragment.java => DescriptionFragment.kt} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename app/src/main/java/org/schabi/newpipe/fragments/detail/{DescriptionFragment.java => DescriptionFragment.kt} (100%) diff --git a/app/src/main/java/org/schabi/newpipe/fragments/detail/DescriptionFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/detail/DescriptionFragment.kt similarity index 100% rename from app/src/main/java/org/schabi/newpipe/fragments/detail/DescriptionFragment.java rename to app/src/main/java/org/schabi/newpipe/fragments/detail/DescriptionFragment.kt From de6285b1e2d3cd5ca4078c6736b181d2dc0ec243 Mon Sep 17 00:00:00 2001 From: Isira Seneviratne Date: Tue, 27 Aug 2024 08:18:53 +0530 Subject: [PATCH 02/14] Migrate description fragment to Jetpack Compose --- app/build.gradle | 2 +- .../fragments/detail/DescriptionFragment.kt | 164 +++---------- .../fragments/detail/VideoDetailFragment.java | 2 +- .../ui/components/common/DescriptionText.kt | 45 ++++ .../components/metadata/ImageMetadataItem.kt | 106 +++++++++ .../ui/components/metadata/MetadataItem.kt | 71 ++++++ .../ui/components/metadata/TagsSection.kt | 68 ++++++ .../video/VideoDescriptionSection.kt | 218 ++++++++++++++++++ app/src/main/res/values/strings.xml | 2 +- 9 files changed, 541 insertions(+), 137 deletions(-) create mode 100644 app/src/main/java/org/schabi/newpipe/ui/components/common/DescriptionText.kt create mode 100644 app/src/main/java/org/schabi/newpipe/ui/components/metadata/ImageMetadataItem.kt create mode 100644 app/src/main/java/org/schabi/newpipe/ui/components/metadata/MetadataItem.kt create mode 100644 app/src/main/java/org/schabi/newpipe/ui/components/metadata/TagsSection.kt create mode 100644 app/src/main/java/org/schabi/newpipe/ui/components/video/VideoDescriptionSection.kt diff --git a/app/build.gradle b/app/build.gradle index 92fd2a7e960..76251f63eac 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -216,7 +216,7 @@ dependencies { implementation 'androidx.constraintlayout:constraintlayout:2.1.4' implementation 'androidx.core:core-ktx:1.12.0' implementation 'androidx.documentfile:documentfile:1.0.1' - implementation 'androidx.fragment:fragment-ktx:1.6.2' + implementation 'androidx.fragment:fragment-compose:1.8.2' implementation "androidx.lifecycle:lifecycle-livedata-ktx:${androidxLifecycleVersion}" implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:${androidxLifecycleVersion}" implementation 'androidx.localbroadcastmanager:localbroadcastmanager:1.1.0' 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 581e5415656..a00059f6508 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 @@ -1,140 +1,36 @@ -package org.schabi.newpipe.fragments.detail; - -import static org.schabi.newpipe.extractor.stream.StreamExtractor.NO_AGE_LIMIT; -import static org.schabi.newpipe.util.Localization.getAppLocale; - -import android.view.LayoutInflater; -import android.view.View; -import android.widget.LinearLayout; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.annotation.StringRes; - -import org.schabi.newpipe.R; -import org.schabi.newpipe.extractor.StreamingService; -import org.schabi.newpipe.extractor.stream.Description; -import org.schabi.newpipe.extractor.stream.StreamInfo; -import org.schabi.newpipe.util.Localization; - -import java.util.List; - -import icepick.State; - -public class DescriptionFragment extends BaseDescriptionFragment { - - @State - StreamInfo streamInfo; - - public DescriptionFragment(final StreamInfo streamInfo) { - this.streamInfo = streamInfo; - } - - public DescriptionFragment() { - // keep empty constructor for IcePick when resuming fragment from memory - } - - - @Nullable - @Override - protected Description getDescription() { - return streamInfo.getDescription(); - } - - @NonNull - @Override - protected StreamingService getService() { - return streamInfo.getService(); - } - - @Override - protected int getServiceId() { - return streamInfo.getServiceId(); - } - - @NonNull - @Override - protected String getStreamUrl() { - return streamInfo.getUrl(); - } - - @NonNull - @Override - public List getTags() { - return streamInfo.getTags(); - } - - @Override - protected void setupMetadata(final LayoutInflater inflater, - final LinearLayout layout) { - if (streamInfo != null && streamInfo.getUploadDate() != null) { - binding.detailUploadDateView.setText(Localization - .localizeUploadDate(activity, streamInfo.getUploadDate().offsetDateTime())); - } else { - binding.detailUploadDateView.setVisibility(View.GONE); - } - - if (streamInfo == null) { - return; - } - - addMetadataItem(inflater, layout, false, R.string.metadata_category, - streamInfo.getCategory()); - - addMetadataItem(inflater, layout, false, R.string.metadata_licence, - streamInfo.getLicence()); - - addPrivacyMetadataItem(inflater, layout); - - if (streamInfo.getAgeLimit() != NO_AGE_LIMIT) { - addMetadataItem(inflater, layout, false, R.string.metadata_age_limit, - String.valueOf(streamInfo.getAgeLimit())); - } - - if (streamInfo.getLanguageInfo() != null) { - addMetadataItem(inflater, layout, false, R.string.metadata_language, - streamInfo.getLanguageInfo().getDisplayLanguage(getAppLocale(getContext()))); +package org.schabi.newpipe.fragments.detail + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.core.os.bundleOf +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.theme.AppTheme +import org.schabi.newpipe.util.KEY_INFO + +class DescriptionFragment : Fragment() { + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ) = content { + AppTheme { + Surface(color = MaterialTheme.colorScheme.background) { + VideoDescriptionSection(requireArguments().serializable(KEY_INFO)!!) + } } - - addMetadataItem(inflater, layout, true, R.string.metadata_support, - streamInfo.getSupportInfo()); - addMetadataItem(inflater, layout, true, R.string.metadata_host, - streamInfo.getHost()); - - addImagesMetadataItem(inflater, layout, R.string.metadata_thumbnails, - streamInfo.getThumbnails()); - addImagesMetadataItem(inflater, layout, R.string.metadata_uploader_avatars, - streamInfo.getUploaderAvatars()); - addImagesMetadataItem(inflater, layout, R.string.metadata_subchannel_avatars, - streamInfo.getSubChannelAvatars()); } - private void addPrivacyMetadataItem(final LayoutInflater inflater, final LinearLayout layout) { - if (streamInfo.getPrivacy() != null) { - @StringRes final int contentRes; - switch (streamInfo.getPrivacy()) { - case PUBLIC: - contentRes = R.string.metadata_privacy_public; - break; - case UNLISTED: - contentRes = R.string.metadata_privacy_unlisted; - break; - case PRIVATE: - contentRes = R.string.metadata_privacy_private; - break; - case INTERNAL: - contentRes = R.string.metadata_privacy_internal; - break; - case OTHER: - default: - contentRes = 0; - break; - } - - if (contentRes != 0) { - addMetadataItem(inflater, layout, false, R.string.metadata_privacy, - getString(contentRes)); - } + companion object { + @JvmStatic + fun getInstance(streamInfo: StreamInfo) = DescriptionFragment().apply { + arguments = bundleOf(KEY_INFO to streamInfo) } } } diff --git a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java index 11a315d691f..abcbc302301 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java @@ -946,7 +946,7 @@ private void updateTabs(@NonNull final StreamInfo info) { } if (showDescription) { - pageAdapter.updateItem(DESCRIPTION_TAB_TAG, new DescriptionFragment(info)); + pageAdapter.updateItem(DESCRIPTION_TAB_TAG, DescriptionFragment.getInstance(info)); } binding.viewPager.setVisibility(View.VISIBLE); 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 new file mode 100644 index 00000000000..9c79f1a9574 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/ui/components/common/DescriptionText.kt @@ -0,0 +1,45 @@ +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/metadata/ImageMetadataItem.kt b/app/src/main/java/org/schabi/newpipe/ui/components/metadata/ImageMetadataItem.kt new file mode 100644 index 00000000000..dce287f55ed --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/ui/components/metadata/ImageMetadataItem.kt @@ -0,0 +1,106 @@ +package org.schabi.newpipe.ui.components.metadata + +import android.content.Context +import android.content.res.Configuration +import androidx.annotation.StringRes +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +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.LinkAnnotation +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLinkStyles +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.withLink +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.tooling.preview.Preview +import org.schabi.newpipe.R +import org.schabi.newpipe.extractor.Image +import org.schabi.newpipe.extractor.Image.ResolutionLevel +import org.schabi.newpipe.ui.theme.AppTheme +import org.schabi.newpipe.util.image.ImageStrategy +import org.schabi.newpipe.util.image.PreferredImageQuality + +@Composable +fun ImageMetadataItem( + @StringRes title: Int, + images: List, + preferredUrl: String? = ImageStrategy.choosePreferredImage(images) +) { + val context = LocalContext.current + val imageLinks = remember { convertImagesToLinks(context, images, preferredUrl) } + + MetadataItem(title = title, value = imageLinks) +} + +fun LazyListScope.imageMetadataItem(@StringRes title: Int, images: List) { + ImageStrategy.choosePreferredImage(images)?.let { + item { + ImageMetadataItem(title, images, it) + } + } +} + +private fun convertImagesToLinks( + context: Context, + images: List, + preferredUrl: String? +): AnnotatedString { + fun imageSizeToText(size: Int): String { + return if (size == Image.HEIGHT_UNKNOWN) context.getString(R.string.question_mark) + else size.toString() + } + + return buildAnnotatedString { + for (image in images) { + if (length != 0) { + append(", ") + } + + val linkStyle = TextLinkStyles(SpanStyle(textDecoration = TextDecoration.Underline)) + withLink(LinkAnnotation.Url(image.url, linkStyle)) { + val weight = if (image.url == preferredUrl) FontWeight.Bold else FontWeight.Normal + + withStyle(SpanStyle(fontWeight = weight)) { + // if even the resolution level is unknown, ?x? will be shown + if (image.height != Image.HEIGHT_UNKNOWN || image.width != Image.WIDTH_UNKNOWN || + image.estimatedResolutionLevel == ResolutionLevel.UNKNOWN + ) { + append("${imageSizeToText(image.width)}x${imageSizeToText(image.height)}") + } else if (image.estimatedResolutionLevel == ResolutionLevel.LOW) { + append(context.getString(R.string.image_quality_low)) + } else if (image.estimatedResolutionLevel == ResolutionLevel.MEDIUM) { + append(context.getString(R.string.image_quality_medium)) + } else if (image.estimatedResolutionLevel == ResolutionLevel.HIGH) { + append(context.getString(R.string.image_quality_high)) + } + } + } + } + } +} + +@Preview(name = "Light mode", uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(name = "Dark mode", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun ImageMetadataItemPreview() { + ImageStrategy.setPreferredImageQuality(PreferredImageQuality.MEDIUM) + val images = listOf( + Image("https://example.com/image_low.png", 16, 16, ResolutionLevel.LOW), + Image("https://example.com/image_mid.png", 32, 32, ResolutionLevel.MEDIUM) + ) + + AppTheme { + Surface(color = MaterialTheme.colorScheme.background) { + ImageMetadataItem( + title = R.string.metadata_uploader_avatars, + images = images + ) + } + } +} 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 new file mode 100644 index 00000000000..29ead79156a --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/ui/components/metadata/MetadataItem.kt @@ -0,0 +1,71 @@ +package org.schabi.newpipe.ui.components.metadata + +import android.content.res.Configuration +import androidx.annotation.StringRes +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import org.schabi.newpipe.R +import org.schabi.newpipe.ui.theme.AppTheme + +@Composable +fun MetadataItem(@StringRes title: Int, value: String) { + MetadataItem(title = title, value = AnnotatedString(value)) +} + +@Composable +fun MetadataItem(@StringRes title: Int, value: AnnotatedString) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + modifier = Modifier.weight(0.3f), + textAlign = TextAlign.End, + text = stringResource(title).uppercase(), + fontWeight = FontWeight.Bold + ) + + Text( + modifier = Modifier.weight(0.7f), + text = value + ) + } +} + +fun LazyListScope.metadataItem(@StringRes title: Int, value: String) { + if (value.isNotEmpty()) { + item { + MetadataItem(title, value) + } + } +} + +@Preview(name = "Light mode", uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(name = "Dark mode", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun MetadataItemPreview() { + AppTheme { + Surface(color = MaterialTheme.colorScheme.background) { + Column { + MetadataItem(title = R.string.metadata_category, value = "Entertainment") + MetadataItem(title = R.string.metadata_age_limit, value = "18") + } + } + } +} 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 new file mode 100644 index 00000000000..cb355eebdae --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/ui/components/metadata/TagsSection.kt @@ -0,0 +1,68 @@ +package org.schabi.newpipe.ui.components.metadata + +import android.content.res.Configuration +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +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.foundation.layout.wrapContentSize +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 +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.fragment.app.FragmentActivity +import org.schabi.newpipe.R +import org.schabi.newpipe.ui.theme.AppTheme +import org.schabi.newpipe.util.NavigationHelper + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun TagsSection(serviceId: Int, tags: List) { + val context = LocalContext.current + val sortedTags = remember(tags) { tags.sortedWith(String.CASE_INSENSITIVE_ORDER) } + + Column(modifier = Modifier.padding(4.dp)) { + Text( + modifier = Modifier + .fillMaxWidth() + .wrapContentSize(Alignment.Center), + text = stringResource(R.string.metadata_tags), + fontWeight = FontWeight.Bold + ) + + FlowRow(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + for (tag in sortedTags) { + SuggestionChip( + onClick = { + NavigationHelper.openSearchFragment( + (context as FragmentActivity).supportFragmentManager, serviceId, tag + ) + }, + label = { Text(text = tag) } + ) + } + } + } +} + +@Preview(name = "Light mode", uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(name = "Dark mode", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun TagsSectionPreview() { + AppTheme { + Surface(color = MaterialTheme.colorScheme.background) { + TagsSection(serviceId = 1, tags = listOf("Tag 1", "Tag 2")) + } + } +} 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/VideoDescriptionSection.kt new file mode 100644 index 00000000000..122e09017db --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/ui/components/video/VideoDescriptionSection.kt @@ -0,0 +1,218 @@ +package org.schabi.newpipe.ui.components.video + +import android.content.res.Configuration +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.PlainTooltip +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TooltipBox +import androidx.compose.material3.TooltipDefaults +import androidx.compose.material3.rememberTooltipState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +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.LocalDensity +import androidx.compose.ui.platform.rememberNestedScrollInteropConnection +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import my.nanihadesuka.compose.LazyColumnScrollbar +import org.schabi.newpipe.R +import org.schabi.newpipe.extractor.localization.DateWrapper +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.metadata.MetadataItem +import org.schabi.newpipe.ui.components.metadata.TagsSection +import org.schabi.newpipe.ui.components.metadata.imageMetadataItem +import org.schabi.newpipe.ui.components.metadata.metadataItem +import org.schabi.newpipe.ui.theme.AppTheme +import org.schabi.newpipe.util.Localization +import org.schabi.newpipe.util.NO_SERVICE_ID +import java.time.OffsetDateTime + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun VideoDescriptionSection(streamInfo: StreamInfo) { + var isSelectable by rememberSaveable { mutableStateOf(false) } + val hasDescription = streamInfo.description != Description.EMPTY_DESCRIPTION + val lazyListState = rememberLazyListState() + + LazyColumnScrollbar(state = lazyListState) { + LazyColumn( + modifier = Modifier + .padding(start = 12.dp, end = 12.dp) + .nestedScroll(rememberNestedScrollInteropConnection()), + state = lazyListState, + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + item { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = if (streamInfo.uploadDate != null) Arrangement.SpaceBetween else Arrangement.End, + ) { + streamInfo.uploadDate?.let { + val date = Localization.formatDate(LocalContext.current, it.offsetDateTime()) + Text( + text = stringResource(R.string.upload_date_text, date), + fontWeight = FontWeight.Bold + ) + } + + if (hasDescription) { + TooltipBox( + positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(), + tooltip = { + val tooltip = stringResource( + if (isSelectable) R.string.description_select_disable + else R.string.description_select_enable + ) + PlainTooltip { Text(text = tooltip) } + }, + state = rememberTooltipState() + ) { + val res = if (isSelectable) R.drawable.ic_close else R.drawable.ic_select_all + Image( + modifier = Modifier.clickable { isSelectable = !isSelectable }, + painter = painterResource(res), + contentDescription = null + ) + } + } + } + + val density = LocalDensity.current + AnimatedVisibility( + visible = isSelectable, + enter = slideInVertically { + with(density) { -40.dp.roundToPx() } + } + expandVertically( + expandFrom = Alignment.Top + ) + fadeIn( + initialAlpha = 0.3f + ), + exit = slideOutVertically() + shrinkVertically() + fadeOut() + ) { + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.description_select_note), + style = MaterialTheme.typography.bodySmall + ) + } + } + + if (hasDescription) { + item { + if (isSelectable) { + SelectionContainer { + DescriptionText(description = streamInfo.description) + } + } else { + DescriptionText(description = streamInfo.description) + } + } + } + + metadataItem(title = R.string.metadata_category, value = streamInfo.category) + + metadataItem(title = R.string.metadata_licence, value = streamInfo.licence) + + val privacy = streamInfo.privacy ?: StreamExtractor.Privacy.OTHER + if (privacy != StreamExtractor.Privacy.OTHER) { + item { + val message = when (privacy) { + StreamExtractor.Privacy.PUBLIC -> R.string.metadata_privacy_public + StreamExtractor.Privacy.UNLISTED -> R.string.metadata_privacy_unlisted + StreamExtractor.Privacy.PRIVATE -> R.string.metadata_privacy_private + StreamExtractor.Privacy.INTERNAL -> R.string.metadata_privacy_internal + else -> 0 // Never reached + } + MetadataItem(title = R.string.metadata_privacy, value = stringResource(message)) + } + } + + if (streamInfo.ageLimit != StreamExtractor.NO_AGE_LIMIT) { + item { + MetadataItem( + title = R.string.metadata_age_limit, + value = streamInfo.ageLimit.toString() + ) + } + } + + streamInfo.languageInfo?.let { + item { + val locale = Localization.getAppLocale(LocalContext.current) + MetadataItem( + title = R.string.metadata_language, + value = it.getDisplayLanguage(locale) + ) + } + } + + metadataItem(title = R.string.metadata_support, value = streamInfo.supportInfo) + + metadataItem(title = R.string.metadata_host, value = streamInfo.host) + + imageMetadataItem(title = R.string.metadata_thumbnails, images = streamInfo.thumbnails) + + imageMetadataItem( + title = R.string.metadata_uploader_avatars, + images = streamInfo.uploaderAvatars + ) + + imageMetadataItem( + title = R.string.metadata_subchannel_avatars, + images = streamInfo.subChannelAvatars + ) + + if (streamInfo.tags.isNotEmpty()) { + item { + TagsSection(serviceId = streamInfo.serviceId, tags = streamInfo.tags) + } + } + } + } +} + +@Preview(name = "Light mode", uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(name = "Dark mode", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun VideoDescriptionSectionPreview() { + 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) + } + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 938a2497d00..6a7fe277964 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -755,7 +755,7 @@ You can select your favorite night theme below This option is only available if %s is selected for Theme Download has started - You can now select text inside the description. Note that the page may flicker and links may not be clickable while in selection mode. + You can now select text inside the description. Enable selecting text in the description Disable selecting text in the description Category From 2653787fe1577470b88ef2661b725e67503a95b5 Mon Sep 17 00:00:00 2001 From: Isira Seneviratne Date: Sun, 1 Sep 2024 16:54:12 +0530 Subject: [PATCH 03/14] Migrate about channel fragment to Jetpack Compose --- .../list/channel/AboutChannelFragment.kt | 36 +++++++ .../list/channel/ChannelAboutFragment.java | 94 ------------------- .../list/channel/ChannelFragment.java | 2 +- .../components/channel/AboutChannelSection.kt | 71 ++++++++++++++ .../components/metadata/ImageMetadataItem.kt | 9 -- .../ui/components/metadata/MetadataItem.kt | 19 +--- .../video/VideoDescriptionSection.kt | 23 ++++- 7 files changed, 133 insertions(+), 121 deletions(-) create mode 100644 app/src/main/java/org/schabi/newpipe/fragments/list/channel/AboutChannelFragment.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelAboutFragment.java create mode 100644 app/src/main/java/org/schabi/newpipe/ui/components/channel/AboutChannelSection.kt diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/channel/AboutChannelFragment.kt b/app/src/main/java/org/schabi/newpipe/fragments/list/channel/AboutChannelFragment.kt new file mode 100644 index 00000000000..a4c8ae3f5ce --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/channel/AboutChannelFragment.kt @@ -0,0 +1,36 @@ +package org.schabi.newpipe.fragments.list.channel + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.core.os.bundleOf +import androidx.fragment.app.Fragment +import androidx.fragment.compose.content +import org.schabi.newpipe.extractor.channel.ChannelInfo +import org.schabi.newpipe.ktx.serializable +import org.schabi.newpipe.ui.components.channel.AboutChannelSection +import org.schabi.newpipe.ui.theme.AppTheme +import org.schabi.newpipe.util.KEY_INFO + +class AboutChannelFragment : Fragment() { + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ) = content { + AppTheme { + Surface(color = MaterialTheme.colorScheme.background) { + AboutChannelSection(requireArguments().serializable(KEY_INFO)!!) + } + } + } + + companion object { + @JvmStatic + fun getInstance(channelInfo: ChannelInfo) = AboutChannelFragment().apply { + arguments = bundleOf(KEY_INFO to channelInfo) + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelAboutFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelAboutFragment.java deleted file mode 100644 index 0dc2fb65a34..00000000000 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelAboutFragment.java +++ /dev/null @@ -1,94 +0,0 @@ -package org.schabi.newpipe.fragments.list.channel; - -import static org.schabi.newpipe.extractor.stream.StreamExtractor.UNKNOWN_SUBSCRIBER_COUNT; - -import android.os.Bundle; -import android.view.LayoutInflater; -import android.view.View; -import android.widget.LinearLayout; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import org.schabi.newpipe.R; -import org.schabi.newpipe.extractor.StreamingService; -import org.schabi.newpipe.extractor.channel.ChannelInfo; -import org.schabi.newpipe.extractor.stream.Description; -import org.schabi.newpipe.fragments.detail.BaseDescriptionFragment; -import org.schabi.newpipe.util.DeviceUtils; -import org.schabi.newpipe.util.Localization; - -import java.util.List; - -import icepick.State; - -public class ChannelAboutFragment extends BaseDescriptionFragment { - @State - protected ChannelInfo channelInfo; - - ChannelAboutFragment(@NonNull final ChannelInfo channelInfo) { - this.channelInfo = channelInfo; - } - - public ChannelAboutFragment() { - // keep empty constructor for IcePick when resuming fragment from memory - } - - @Override - protected void initViews(final View rootView, final Bundle savedInstanceState) { - super.initViews(rootView, savedInstanceState); - binding.constraintLayout.setPadding(0, DeviceUtils.dpToPx(8, requireContext()), 0, 0); - } - - @Nullable - @Override - protected Description getDescription() { - return new Description(channelInfo.getDescription(), Description.PLAIN_TEXT); - } - - @NonNull - @Override - protected StreamingService getService() { - return channelInfo.getService(); - } - - @Override - protected int getServiceId() { - return channelInfo.getServiceId(); - } - - @Nullable - @Override - protected String getStreamUrl() { - return null; - } - - @NonNull - @Override - public List getTags() { - return channelInfo.getTags(); - } - - @Override - protected void setupMetadata(final LayoutInflater inflater, - final LinearLayout layout) { - // There is no upload date available for channels, so hide the relevant UI element - binding.detailUploadDateView.setVisibility(View.GONE); - - if (channelInfo == null) { - return; - } - - if (channelInfo.getSubscriberCount() != UNKNOWN_SUBSCRIBER_COUNT) { - addMetadataItem(inflater, layout, false, R.string.metadata_subscribers, - Localization.localizeNumber( - requireContext(), - channelInfo.getSubscriberCount())); - } - - addImagesMetadataItem(inflater, layout, R.string.metadata_avatars, - channelInfo.getAvatars()); - addImagesMetadataItem(inflater, layout, R.string.metadata_banners, - channelInfo.getBanners()); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelFragment.java index 3890e48659d..ae7c03ce214 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelFragment.java @@ -481,7 +481,7 @@ private void updateTabs() { if (ChannelTabHelper.showChannelTab( context, preferences, R.string.show_channel_tabs_about)) { tabAdapter.addFragment( - new ChannelAboutFragment(currentInfo), + AboutChannelFragment.getInstance(currentInfo), context.getString(R.string.channel_tab_about)); } } diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/channel/AboutChannelSection.kt b/app/src/main/java/org/schabi/newpipe/ui/components/channel/AboutChannelSection.kt new file mode 100644 index 00000000000..8fa3ca7c568 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/ui/components/channel/AboutChannelSection.kt @@ -0,0 +1,71 @@ +package org.schabi.newpipe.ui.components.channel + +import android.content.res.Configuration +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import org.schabi.newpipe.R +import org.schabi.newpipe.extractor.channel.ChannelInfo +import org.schabi.newpipe.ui.components.metadata.ImageMetadataItem +import org.schabi.newpipe.ui.components.metadata.MetadataItem +import org.schabi.newpipe.ui.theme.AppTheme +import org.schabi.newpipe.util.Localization +import org.schabi.newpipe.util.NO_SERVICE_ID +import org.schabi.newpipe.util.image.ImageStrategy + +@Composable +fun AboutChannelSection(channelInfo: ChannelInfo) { + // This tab currently holds little information, so a lazy column isn't needed here. + Column( + modifier = Modifier.padding(12.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + val description = channelInfo.description + if (!description.isNullOrEmpty()) { + Text(text = description) + } + + val count = channelInfo.subscriberCount + if (count != -1L) { + MetadataItem( + title = R.string.metadata_subscribers, + value = Localization.shortCount(LocalContext.current, count) + ) + } + + ImageStrategy.choosePreferredImage(channelInfo.avatars)?.let { + ImageMetadataItem(R.string.metadata_avatars, channelInfo.avatars, it) + } + + ImageStrategy.choosePreferredImage(channelInfo.banners)?.let { + ImageMetadataItem(R.string.metadata_banners, channelInfo.banners, it) + } + + if (channelInfo.tags.isNotEmpty()) { + TagsSection(channelInfo.serviceId, channelInfo.tags) + } + } +} + +@Preview(name = "Light mode", uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(name = "Dark mode", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun AboutChannelSectionPreview() { + val info = ChannelInfo(NO_SERVICE_ID, "", "", "", "") + info.description = "This is an example description" + info.subscriberCount = 10 + + AppTheme { + Surface(color = MaterialTheme.colorScheme.background) { + AboutChannelSection(info) + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/metadata/ImageMetadataItem.kt b/app/src/main/java/org/schabi/newpipe/ui/components/metadata/ImageMetadataItem.kt index dce287f55ed..3df8c8cb196 100644 --- a/app/src/main/java/org/schabi/newpipe/ui/components/metadata/ImageMetadataItem.kt +++ b/app/src/main/java/org/schabi/newpipe/ui/components/metadata/ImageMetadataItem.kt @@ -3,7 +3,6 @@ package org.schabi.newpipe.ui.components.metadata import android.content.Context import android.content.res.Configuration import androidx.annotation.StringRes -import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.runtime.Composable @@ -38,14 +37,6 @@ fun ImageMetadataItem( MetadataItem(title = title, value = imageLinks) } -fun LazyListScope.imageMetadataItem(@StringRes title: Int, images: List) { - ImageStrategy.choosePreferredImage(images)?.let { - item { - ImageMetadataItem(title, images, it) - } - } -} - private fun convertImagesToLinks( context: Context, images: List, 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 29ead79156a..9f18e37b3e4 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 @@ -6,7 +6,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.layout.width import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -35,24 +35,13 @@ fun MetadataItem(@StringRes title: Int, value: AnnotatedString) { verticalAlignment = Alignment.CenterVertically ) { Text( - modifier = Modifier.weight(0.3f), + modifier = Modifier.width(96.dp), textAlign = TextAlign.End, - text = stringResource(title).uppercase(), + text = stringResource(title), fontWeight = FontWeight.Bold ) - Text( - modifier = Modifier.weight(0.7f), - text = value - ) - } -} - -fun LazyListScope.metadataItem(@StringRes title: Int, value: String) { - if (value.isNotEmpty()) { - item { - MetadataItem(title, value) - } + Text(text = value) } } 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/VideoDescriptionSection.kt index 122e09017db..1d0903c8647 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/VideoDescriptionSection.kt @@ -1,6 +1,7 @@ package org.schabi.newpipe.ui.components.video import android.content.res.Configuration +import androidx.annotation.StringRes import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.expandVertically import androidx.compose.animation.fadeIn @@ -15,6 +16,7 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material3.ExperimentalMaterial3Api @@ -43,19 +45,20 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import my.nanihadesuka.compose.LazyColumnScrollbar import org.schabi.newpipe.R +import org.schabi.newpipe.extractor.Image import org.schabi.newpipe.extractor.localization.DateWrapper 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.metadata.ImageMetadataItem import org.schabi.newpipe.ui.components.metadata.MetadataItem import org.schabi.newpipe.ui.components.metadata.TagsSection -import org.schabi.newpipe.ui.components.metadata.imageMetadataItem -import org.schabi.newpipe.ui.components.metadata.metadataItem import org.schabi.newpipe.ui.theme.AppTheme import org.schabi.newpipe.util.Localization import org.schabi.newpipe.util.NO_SERVICE_ID +import org.schabi.newpipe.util.image.ImageStrategy import java.time.OffsetDateTime @OptIn(ExperimentalMaterial3Api::class) @@ -202,6 +205,22 @@ fun VideoDescriptionSection(streamInfo: StreamInfo) { } } +private fun LazyListScope.metadataItem(@StringRes title: Int, value: String) { + if (value.isNotEmpty()) { + item { + MetadataItem(title, value) + } + } +} + +private fun LazyListScope.imageMetadataItem(@StringRes title: Int, images: List) { + ImageStrategy.choosePreferredImage(images)?.let { + item { + ImageMetadataItem(title, images, it) + } + } +} + @Preview(name = "Light mode", uiMode = Configuration.UI_MODE_NIGHT_NO) @Preview(name = "Dark mode", uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable From 507b3b4108e5371acb5b53927b585ada8f9de5e8 Mon Sep 17 00:00:00 2001 From: Isira Seneviratne Date: Sun, 1 Sep 2024 17:43:06 +0530 Subject: [PATCH 04/14] Show image tags even if image loading is disabled --- .../ui/components/channel/AboutChannelSection.kt | 10 +++++----- .../ui/components/metadata/ImageMetadataItem.kt | 16 +++++----------- .../components/video/VideoDescriptionSection.kt | 13 +++++-------- 3 files changed, 15 insertions(+), 24 deletions(-) diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/channel/AboutChannelSection.kt b/app/src/main/java/org/schabi/newpipe/ui/components/channel/AboutChannelSection.kt index 8fa3ca7c568..15c7d44469f 100644 --- a/app/src/main/java/org/schabi/newpipe/ui/components/channel/AboutChannelSection.kt +++ b/app/src/main/java/org/schabi/newpipe/ui/components/channel/AboutChannelSection.kt @@ -16,10 +16,10 @@ import org.schabi.newpipe.R import org.schabi.newpipe.extractor.channel.ChannelInfo import org.schabi.newpipe.ui.components.metadata.ImageMetadataItem import org.schabi.newpipe.ui.components.metadata.MetadataItem +import org.schabi.newpipe.ui.components.metadata.TagsSection import org.schabi.newpipe.ui.theme.AppTheme import org.schabi.newpipe.util.Localization import org.schabi.newpipe.util.NO_SERVICE_ID -import org.schabi.newpipe.util.image.ImageStrategy @Composable fun AboutChannelSection(channelInfo: ChannelInfo) { @@ -41,12 +41,12 @@ fun AboutChannelSection(channelInfo: ChannelInfo) { ) } - ImageStrategy.choosePreferredImage(channelInfo.avatars)?.let { - ImageMetadataItem(R.string.metadata_avatars, channelInfo.avatars, it) + if (channelInfo.avatars.isNotEmpty()) { + ImageMetadataItem(R.string.metadata_avatars, channelInfo.avatars) } - ImageStrategy.choosePreferredImage(channelInfo.banners)?.let { - ImageMetadataItem(R.string.metadata_banners, channelInfo.banners, it) + if (channelInfo.banners.isNotEmpty()) { + ImageMetadataItem(R.string.metadata_banners, channelInfo.banners) } if (channelInfo.tags.isNotEmpty()) { diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/metadata/ImageMetadataItem.kt b/app/src/main/java/org/schabi/newpipe/ui/components/metadata/ImageMetadataItem.kt index 3df8c8cb196..66cd5c5bd7f 100644 --- a/app/src/main/java/org/schabi/newpipe/ui/components/metadata/ImageMetadataItem.kt +++ b/app/src/main/java/org/schabi/newpipe/ui/components/metadata/ImageMetadataItem.kt @@ -26,22 +26,16 @@ import org.schabi.newpipe.util.image.ImageStrategy import org.schabi.newpipe.util.image.PreferredImageQuality @Composable -fun ImageMetadataItem( - @StringRes title: Int, - images: List, - preferredUrl: String? = ImageStrategy.choosePreferredImage(images) -) { +fun ImageMetadataItem(@StringRes title: Int, images: List) { val context = LocalContext.current - val imageLinks = remember { convertImagesToLinks(context, images, preferredUrl) } + val imageLinks = remember(images) { convertImagesToLinks(context, images) } MetadataItem(title = title, value = imageLinks) } -private fun convertImagesToLinks( - context: Context, - images: List, - preferredUrl: String? -): AnnotatedString { +private fun convertImagesToLinks(context: Context, images: List): AnnotatedString { + val preferredUrl = ImageStrategy.choosePreferredImage(images) + fun imageSizeToText(size: Int): String { return if (size == Image.HEIGHT_UNKNOWN) context.getString(R.string.question_mark) else size.toString() 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/VideoDescriptionSection.kt index 1d0903c8647..62d4c1dc563 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/VideoDescriptionSection.kt @@ -58,7 +58,6 @@ import org.schabi.newpipe.ui.components.metadata.TagsSection import org.schabi.newpipe.ui.theme.AppTheme import org.schabi.newpipe.util.Localization import org.schabi.newpipe.util.NO_SERVICE_ID -import org.schabi.newpipe.util.image.ImageStrategy import java.time.OffsetDateTime @OptIn(ExperimentalMaterial3Api::class) @@ -161,12 +160,10 @@ fun VideoDescriptionSection(streamInfo: StreamInfo) { } } - if (streamInfo.ageLimit != StreamExtractor.NO_AGE_LIMIT) { + val ageLimit = streamInfo.ageLimit + if (ageLimit != StreamExtractor.NO_AGE_LIMIT) { item { - MetadataItem( - title = R.string.metadata_age_limit, - value = streamInfo.ageLimit.toString() - ) + MetadataItem(title = R.string.metadata_age_limit, value = ageLimit.toString()) } } @@ -214,9 +211,9 @@ private fun LazyListScope.metadataItem(@StringRes title: Int, value: String) { } private fun LazyListScope.imageMetadataItem(@StringRes title: Int, images: List) { - ImageStrategy.choosePreferredImage(images)?.let { + if (images.isNotEmpty()) { item { - ImageMetadataItem(title, images, it) + ImageMetadataItem(title, images) } } } From 45aa445b5032715f4432d6f670c3c54d742e9e65 Mon Sep 17 00:00:00 2001 From: Isira Seneviratne Date: Sun, 1 Sep 2024 18:30:18 +0530 Subject: [PATCH 05/14] Fix crash when minimizing the channel about tab --- .../detail/BaseDescriptionFragment.java | 281 ------------------ .../list/channel/AboutChannelFragment.kt | 7 +- .../java/org/schabi/newpipe/ktx/Bundle.kt | 4 + .../components/channel/AboutChannelSection.kt | 38 ++- .../channel/ParcelableChannelInfo.kt | 21 ++ .../components/metadata/ImageMetadataItem.kt | 2 - 6 files changed, 53 insertions(+), 300 deletions(-) delete mode 100644 app/src/main/java/org/schabi/newpipe/fragments/detail/BaseDescriptionFragment.java create mode 100644 app/src/main/java/org/schabi/newpipe/ui/components/channel/ParcelableChannelInfo.kt diff --git a/app/src/main/java/org/schabi/newpipe/fragments/detail/BaseDescriptionFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/detail/BaseDescriptionFragment.java deleted file mode 100644 index 4789b02e65b..00000000000 --- a/app/src/main/java/org/schabi/newpipe/fragments/detail/BaseDescriptionFragment.java +++ /dev/null @@ -1,281 +0,0 @@ -package org.schabi.newpipe.fragments.detail; - -import static android.text.TextUtils.isEmpty; -import static org.schabi.newpipe.extractor.utils.Utils.isBlank; -import static org.schabi.newpipe.util.text.TextLinkifier.SET_LINK_MOVEMENT_METHOD; - -import android.graphics.Typeface; -import android.os.Bundle; -import android.text.SpannableStringBuilder; -import android.text.Spanned; -import android.text.method.LinkMovementMethod; -import android.text.style.ClickableSpan; -import android.text.style.StyleSpan; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.LinearLayout; - -import androidx.annotation.NonNull; -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; -import org.schabi.newpipe.R; -import org.schabi.newpipe.databinding.FragmentDescriptionBinding; -import org.schabi.newpipe.databinding.ItemMetadataBinding; -import org.schabi.newpipe.databinding.ItemMetadataTagsBinding; -import org.schabi.newpipe.extractor.Image; -import org.schabi.newpipe.extractor.StreamingService; -import org.schabi.newpipe.extractor.stream.Description; -import org.schabi.newpipe.util.NavigationHelper; -import org.schabi.newpipe.util.external_communication.ShareUtils; -import org.schabi.newpipe.util.image.ImageStrategy; -import org.schabi.newpipe.util.text.TextLinkifier; - -import java.util.List; - -import io.reactivex.rxjava3.disposables.CompositeDisposable; - -public abstract class BaseDescriptionFragment extends BaseFragment { - private final CompositeDisposable descriptionDisposables = new CompositeDisposable(); - protected FragmentDescriptionBinding binding; - - @Override - public View onCreateView(@NonNull final LayoutInflater inflater, - @Nullable final ViewGroup container, - @Nullable final Bundle savedInstanceState) { - binding = FragmentDescriptionBinding.inflate(inflater, container, false); - setupDescription(); - setupMetadata(inflater, binding.detailMetadataLayout); - addTagsMetadataItem(inflater, binding.detailMetadataLayout); - return binding.getRoot(); - } - - @Override - public void onDestroy() { - descriptionDisposables.clear(); - super.onDestroy(); - } - - /** - * Get the description to display. - * @return description object, if available - */ - @Nullable - protected abstract Description getDescription(); - - /** - * Get the streaming service. Used for generating description links. - * @return streaming service - */ - @NonNull - protected abstract StreamingService getService(); - - /** - * Get the streaming service ID. Used for tag links. - * @return service ID - */ - protected abstract int getServiceId(); - - /** - * Get the URL of the described video or audio, used to generate description links. - * @return stream URL - */ - @Nullable - protected abstract String getStreamUrl(); - - /** - * Get the list of tags to display below the description. - * @return tag list - */ - @NonNull - public abstract List getTags(); - - /** - * Add additional metadata to display. - * @param inflater LayoutInflater - * @param layout detailMetadataLayout - */ - protected abstract void setupMetadata(LayoutInflater inflater, LinearLayout layout); - - private void setupDescription() { - final Description description = getDescription(); - if (description == null || isEmpty(description.getContent()) - || description == Description.EMPTY_DESCRIPTION) { - binding.detailDescriptionView.setVisibility(View.GONE); - binding.detailSelectDescriptionButton.setVisibility(View.GONE); - return; - } - - // start with disabled state. This also loads description content (!) - disableDescriptionSelection(); - - binding.detailSelectDescriptionButton.setOnClickListener(v -> { - if (binding.detailDescriptionNoteView.getVisibility() == View.VISIBLE) { - disableDescriptionSelection(); - } else { - // enable selection only when button is clicked to prevent flickering - enableDescriptionSelection(); - } - }); - } - - private void enableDescriptionSelection() { - binding.detailDescriptionNoteView.setVisibility(View.VISIBLE); - binding.detailDescriptionView.setTextIsSelectable(true); - - final String buttonLabel = getString(R.string.description_select_disable); - binding.detailSelectDescriptionButton.setContentDescription(buttonLabel); - TooltipCompat.setTooltipText(binding.detailSelectDescriptionButton, buttonLabel); - binding.detailSelectDescriptionButton.setImageResource(R.drawable.ic_close); - } - - private void disableDescriptionSelection() { - // show description content again, otherwise some links are not clickable - final Description description = getDescription(); - if (description != null) { - TextLinkifier.fromDescription(binding.detailDescriptionView, - description, HtmlCompat.FROM_HTML_MODE_LEGACY, - getService(), getStreamUrl(), - descriptionDisposables, SET_LINK_MOVEMENT_METHOD); - } - - binding.detailDescriptionNoteView.setVisibility(View.GONE); - binding.detailDescriptionView.setTextIsSelectable(false); - - final String buttonLabel = getString(R.string.description_select_enable); - binding.detailSelectDescriptionButton.setContentDescription(buttonLabel); - TooltipCompat.setTooltipText(binding.detailSelectDescriptionButton, buttonLabel); - binding.detailSelectDescriptionButton.setImageResource(R.drawable.ic_select_all); - } - - protected void addMetadataItem(final LayoutInflater inflater, - final LinearLayout layout, - final boolean linkifyContent, - @StringRes final int type, - @NonNull final String content) { - if (isBlank(content)) { - return; - } - - final ItemMetadataBinding itemBinding = - ItemMetadataBinding.inflate(inflater, layout, false); - - itemBinding.metadataTypeView.setText(type); - itemBinding.metadataTypeView.setOnLongClickListener(v -> { - ShareUtils.copyToClipboard(requireContext(), content); - return true; - }); - - if (linkifyContent) { - TextLinkifier.fromPlainText(itemBinding.metadataContentView, content, null, null, - descriptionDisposables, SET_LINK_MOVEMENT_METHOD); - } else { - itemBinding.metadataContentView.setText(content); - } - - itemBinding.metadataContentView.setClickable(true); - - layout.addView(itemBinding.getRoot()); - } - - private String imageSizeToText(final int heightOrWidth) { - if (heightOrWidth < 0) { - return getString(R.string.question_mark); - } else { - return String.valueOf(heightOrWidth); - } - } - - protected void addImagesMetadataItem(final LayoutInflater inflater, - final LinearLayout layout, - @StringRes final int type, - final List images) { - final String preferredImageUrl = ImageStrategy.choosePreferredImage(images); - if (preferredImageUrl == null) { - return; // null will be returned in case there is no image - } - - final ItemMetadataBinding itemBinding = - ItemMetadataBinding.inflate(inflater, layout, false); - itemBinding.metadataTypeView.setText(type); - - final SpannableStringBuilder urls = new SpannableStringBuilder(); - for (final Image image : images) { - if (urls.length() != 0) { - urls.append(", "); - } - final int entryBegin = urls.length(); - - if (image.getHeight() != Image.HEIGHT_UNKNOWN - || image.getWidth() != Image.WIDTH_UNKNOWN - // if even the resolution level is unknown, ?x? will be shown - || image.getEstimatedResolutionLevel() == Image.ResolutionLevel.UNKNOWN) { - urls.append(imageSizeToText(image.getHeight())); - urls.append('x'); - urls.append(imageSizeToText(image.getWidth())); - } else { - switch (image.getEstimatedResolutionLevel()) { - case LOW -> urls.append(getString(R.string.image_quality_low)); - case MEDIUM -> urls.append(getString(R.string.image_quality_medium)); - case HIGH -> urls.append(getString(R.string.image_quality_high)); - default -> { - // unreachable, Image.ResolutionLevel.UNKNOWN is already filtered out - } - } - } - - urls.setSpan(new ClickableSpan() { - @Override - public void onClick(@NonNull final View widget) { - ShareUtils.openUrlInBrowser(requireContext(), image.getUrl()); - } - }, entryBegin, urls.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); - - if (preferredImageUrl.equals(image.getUrl())) { - urls.setSpan(new StyleSpan(Typeface.BOLD), entryBegin, urls.length(), - Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); - } - } - - itemBinding.metadataContentView.setText(urls); - itemBinding.metadataContentView.setMovementMethod(LinkMovementMethod.getInstance()); - layout.addView(itemBinding.getRoot()); - } - - private void addTagsMetadataItem(final LayoutInflater inflater, final LinearLayout layout) { - final List tags = getTags(); - - if (!tags.isEmpty()) { - final var itemBinding = ItemMetadataTagsBinding.inflate(inflater, layout, false); - - tags.stream().sorted(String.CASE_INSENSITIVE_ORDER).forEach(tag -> { - final Chip chip = (Chip) inflater.inflate(R.layout.chip, - itemBinding.metadataTagsChips, false); - chip.setText(tag); - chip.setOnClickListener(this::onTagClick); - chip.setOnLongClickListener(this::onTagLongClick); - itemBinding.metadataTagsChips.addView(chip); - }); - - layout.addView(itemBinding.getRoot()); - } - } - - private void onTagClick(final View chip) { - if (getParentFragment() != null) { - NavigationHelper.openSearchFragment(getParentFragment().getParentFragmentManager(), - getServiceId(), ((Chip) chip).getText().toString()); - } - } - - private boolean onTagLongClick(final View chip) { - ShareUtils.copyToClipboard(requireContext(), ((Chip) chip).getText().toString()); - return true; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/channel/AboutChannelFragment.kt b/app/src/main/java/org/schabi/newpipe/fragments/list/channel/AboutChannelFragment.kt index a4c8ae3f5ce..510a940be37 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/channel/AboutChannelFragment.kt +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/channel/AboutChannelFragment.kt @@ -9,8 +9,9 @@ import androidx.core.os.bundleOf import androidx.fragment.app.Fragment import androidx.fragment.compose.content import org.schabi.newpipe.extractor.channel.ChannelInfo -import org.schabi.newpipe.ktx.serializable +import org.schabi.newpipe.ktx.parcelable import org.schabi.newpipe.ui.components.channel.AboutChannelSection +import org.schabi.newpipe.ui.components.channel.ParcelableChannelInfo import org.schabi.newpipe.ui.theme.AppTheme import org.schabi.newpipe.util.KEY_INFO @@ -22,7 +23,7 @@ class AboutChannelFragment : Fragment() { ) = content { AppTheme { Surface(color = MaterialTheme.colorScheme.background) { - AboutChannelSection(requireArguments().serializable(KEY_INFO)!!) + AboutChannelSection(requireArguments().parcelable(KEY_INFO)!!) } } } @@ -30,7 +31,7 @@ class AboutChannelFragment : Fragment() { companion object { @JvmStatic fun getInstance(channelInfo: ChannelInfo) = AboutChannelFragment().apply { - arguments = bundleOf(KEY_INFO to channelInfo) + arguments = bundleOf(KEY_INFO to ParcelableChannelInfo(channelInfo)) } } } diff --git a/app/src/main/java/org/schabi/newpipe/ktx/Bundle.kt b/app/src/main/java/org/schabi/newpipe/ktx/Bundle.kt index e248b8b6c63..22fd87142c5 100644 --- a/app/src/main/java/org/schabi/newpipe/ktx/Bundle.kt +++ b/app/src/main/java/org/schabi/newpipe/ktx/Bundle.kt @@ -5,6 +5,10 @@ import android.os.Parcelable import androidx.core.os.BundleCompat import java.io.Serializable +inline fun Bundle.parcelable(key: String?): T? { + return BundleCompat.getParcelable(this, key, T::class.java) +} + inline fun Bundle.parcelableArrayList(key: String?): ArrayList? { return BundleCompat.getParcelableArrayList(this, key, T::class.java) } diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/channel/AboutChannelSection.kt b/app/src/main/java/org/schabi/newpipe/ui/components/channel/AboutChannelSection.kt index 15c7d44469f..a37d7711eb3 100644 --- a/app/src/main/java/org/schabi/newpipe/ui/components/channel/AboutChannelSection.kt +++ b/app/src/main/java/org/schabi/newpipe/ui/components/channel/AboutChannelSection.kt @@ -13,7 +13,8 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import org.schabi.newpipe.R -import org.schabi.newpipe.extractor.channel.ChannelInfo +import org.schabi.newpipe.extractor.Image +import org.schabi.newpipe.extractor.Image.ResolutionLevel import org.schabi.newpipe.ui.components.metadata.ImageMetadataItem import org.schabi.newpipe.ui.components.metadata.MetadataItem import org.schabi.newpipe.ui.components.metadata.TagsSection @@ -22,18 +23,18 @@ import org.schabi.newpipe.util.Localization import org.schabi.newpipe.util.NO_SERVICE_ID @Composable -fun AboutChannelSection(channelInfo: ChannelInfo) { +fun AboutChannelSection(channelInfo: ParcelableChannelInfo) { // This tab currently holds little information, so a lazy column isn't needed here. Column( modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(4.dp) ) { - val description = channelInfo.description - if (!description.isNullOrEmpty()) { + val (serviceId, description, count, avatars, banners, tags) = channelInfo + + if (description.isNotEmpty()) { Text(text = description) } - val count = channelInfo.subscriberCount if (count != -1L) { MetadataItem( title = R.string.metadata_subscribers, @@ -41,16 +42,16 @@ fun AboutChannelSection(channelInfo: ChannelInfo) { ) } - if (channelInfo.avatars.isNotEmpty()) { - ImageMetadataItem(R.string.metadata_avatars, channelInfo.avatars) + if (avatars.isNotEmpty()) { + ImageMetadataItem(R.string.metadata_avatars, avatars) } - if (channelInfo.banners.isNotEmpty()) { - ImageMetadataItem(R.string.metadata_banners, channelInfo.banners) + if (banners.isNotEmpty()) { + ImageMetadataItem(R.string.metadata_banners, banners) } - if (channelInfo.tags.isNotEmpty()) { - TagsSection(channelInfo.serviceId, channelInfo.tags) + if (tags.isNotEmpty()) { + TagsSection(serviceId, tags) } } } @@ -59,9 +60,18 @@ fun AboutChannelSection(channelInfo: ChannelInfo) { @Preview(name = "Dark mode", uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun AboutChannelSectionPreview() { - val info = ChannelInfo(NO_SERVICE_ID, "", "", "", "") - info.description = "This is an example description" - info.subscriberCount = 10 + val images = listOf( + Image("https://example.com/image_low.png", 16, 16, ResolutionLevel.LOW), + Image("https://example.com/image_mid.png", 32, 32, ResolutionLevel.MEDIUM) + ) + val info = ParcelableChannelInfo( + serviceId = NO_SERVICE_ID, + description = "This is an example description", + subscriberCount = 10, + avatars = images, + banners = images, + tags = listOf("Tag 1", "Tag 2") + ) AppTheme { Surface(color = MaterialTheme.colorScheme.background) { diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/channel/ParcelableChannelInfo.kt b/app/src/main/java/org/schabi/newpipe/ui/components/channel/ParcelableChannelInfo.kt new file mode 100644 index 00000000000..f8f118c0502 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/ui/components/channel/ParcelableChannelInfo.kt @@ -0,0 +1,21 @@ +package org.schabi.newpipe.ui.components.channel + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize +import org.schabi.newpipe.extractor.Image +import org.schabi.newpipe.extractor.channel.ChannelInfo + +@Parcelize +data class ParcelableChannelInfo( + val serviceId: Int, + val description: String, + val subscriberCount: Long, + val avatars: List, + val banners: List, + val tags: List +) : Parcelable { + constructor(channelInfo: ChannelInfo) : this( + channelInfo.serviceId, channelInfo.description, channelInfo.subscriberCount, + channelInfo.avatars, channelInfo.banners, channelInfo.tags + ) +} diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/metadata/ImageMetadataItem.kt b/app/src/main/java/org/schabi/newpipe/ui/components/metadata/ImageMetadataItem.kt index 66cd5c5bd7f..8d282c2345e 100644 --- a/app/src/main/java/org/schabi/newpipe/ui/components/metadata/ImageMetadataItem.kt +++ b/app/src/main/java/org/schabi/newpipe/ui/components/metadata/ImageMetadataItem.kt @@ -23,7 +23,6 @@ import org.schabi.newpipe.extractor.Image import org.schabi.newpipe.extractor.Image.ResolutionLevel import org.schabi.newpipe.ui.theme.AppTheme import org.schabi.newpipe.util.image.ImageStrategy -import org.schabi.newpipe.util.image.PreferredImageQuality @Composable fun ImageMetadataItem(@StringRes title: Int, images: List) { @@ -74,7 +73,6 @@ private fun convertImagesToLinks(context: Context, images: List): Annotat @Preview(name = "Dark mode", uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun ImageMetadataItemPreview() { - ImageStrategy.setPreferredImageQuality(PreferredImageQuality.MEDIUM) val images = listOf( Image("https://example.com/image_low.png", 16, 16, ResolutionLevel.LOW), Image("https://example.com/image_mid.png", 32, 32, ResolutionLevel.MEDIUM) From a094abd9efd7a35a218b0839acaef74db758e5bb Mon Sep 17 00:00:00 2001 From: Isira Seneviratne Date: Sun, 1 Sep 2024 19:16:17 +0530 Subject: [PATCH 06/14] Use constant --- .../newpipe/ui/components/channel/AboutChannelSection.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/channel/AboutChannelSection.kt b/app/src/main/java/org/schabi/newpipe/ui/components/channel/AboutChannelSection.kt index a37d7711eb3..71180969a8b 100644 --- a/app/src/main/java/org/schabi/newpipe/ui/components/channel/AboutChannelSection.kt +++ b/app/src/main/java/org/schabi/newpipe/ui/components/channel/AboutChannelSection.kt @@ -15,6 +15,7 @@ import androidx.compose.ui.unit.dp import org.schabi.newpipe.R import org.schabi.newpipe.extractor.Image import org.schabi.newpipe.extractor.Image.ResolutionLevel +import org.schabi.newpipe.extractor.stream.StreamExtractor import org.schabi.newpipe.ui.components.metadata.ImageMetadataItem import org.schabi.newpipe.ui.components.metadata.MetadataItem import org.schabi.newpipe.ui.components.metadata.TagsSection @@ -35,7 +36,7 @@ fun AboutChannelSection(channelInfo: ParcelableChannelInfo) { Text(text = description) } - if (count != -1L) { + if (count != StreamExtractor.UNKNOWN_SUBSCRIBER_COUNT) { MetadataItem( title = R.string.metadata_subscribers, value = Localization.shortCount(LocalContext.current, count) From bbbb7f574b48e076e95d27217556e2daa6ed2aba Mon Sep 17 00:00:00 2001 From: Isira Seneviratne Date: Sun, 1 Sep 2024 19:50:38 +0530 Subject: [PATCH 07/14] Use textAlign in TagsSection --- .../newpipe/ui/components/metadata/TagsSection.kt | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) 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 cb355eebdae..dc3649cae4d 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,18 +7,17 @@ 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.foundation.layout.wrapContentSize 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 import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.fragment.app.FragmentActivity @@ -34,11 +33,10 @@ fun TagsSection(serviceId: Int, tags: List) { Column(modifier = Modifier.padding(4.dp)) { Text( - modifier = Modifier - .fillMaxWidth() - .wrapContentSize(Alignment.Center), + modifier = Modifier.fillMaxWidth(), text = stringResource(R.string.metadata_tags), - fontWeight = FontWeight.Bold + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center ) FlowRow(horizontalArrangement = Arrangement.spacedBy(4.dp)) { From 387a4d5f56f6e9a18176241d917e1ee1057de02f Mon Sep 17 00:00:00 2001 From: Isira Seneviratne Date: Sun, 1 Sep 2024 20:59:52 +0530 Subject: [PATCH 08/14] Rm unused layouts --- app/src/main/res/layout/chip.xml | 11 -- .../main/res/layout/fragment_description.xml | 101 ------------------ app/src/main/res/layout/item_metadata.xml | 33 ------ .../main/res/layout/item_metadata_tags.xml | 31 ------ 4 files changed, 176 deletions(-) delete mode 100644 app/src/main/res/layout/chip.xml delete mode 100644 app/src/main/res/layout/fragment_description.xml delete mode 100644 app/src/main/res/layout/item_metadata.xml delete mode 100644 app/src/main/res/layout/item_metadata_tags.xml diff --git a/app/src/main/res/layout/chip.xml b/app/src/main/res/layout/chip.xml deleted file mode 100644 index 41e5223a982..00000000000 --- a/app/src/main/res/layout/chip.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_description.xml b/app/src/main/res/layout/fragment_description.xml deleted file mode 100644 index b20905d4ad2..00000000000 --- a/app/src/main/res/layout/fragment_description.xml +++ /dev/null @@ -1,101 +0,0 @@ - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/item_metadata.xml b/app/src/main/res/layout/item_metadata.xml deleted file mode 100644 index 251b9e83236..00000000000 --- a/app/src/main/res/layout/item_metadata.xml +++ /dev/null @@ -1,33 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/item_metadata_tags.xml b/app/src/main/res/layout/item_metadata_tags.xml deleted file mode 100644 index febe3ff4a7c..00000000000 --- a/app/src/main/res/layout/item_metadata_tags.xml +++ /dev/null @@ -1,31 +0,0 @@ - - - - - - - - \ No newline at end of file From bc875754100210e23d3019cf42c17e1d3129c0fc Mon Sep 17 00:00:00 2001 From: Isira Seneviratne Date: Mon, 2 Sep 2024 09:06:03 +0530 Subject: [PATCH 09/14] Allow scrolling of about channel section --- .../components/channel/AboutChannelSection.kt | 61 +++++++++++-------- .../components/metadata/ImageMetadataItem.kt | 9 +++ .../video/VideoDescriptionSection.kt | 11 +--- 3 files changed, 46 insertions(+), 35 deletions(-) diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/channel/AboutChannelSection.kt b/app/src/main/java/org/schabi/newpipe/ui/components/channel/AboutChannelSection.kt index 71180969a8b..381fa681cce 100644 --- a/app/src/main/java/org/schabi/newpipe/ui/components/channel/AboutChannelSection.kt +++ b/app/src/main/java/org/schabi/newpipe/ui/components/channel/AboutChannelSection.kt @@ -2,57 +2,68 @@ package org.schabi.newpipe.ui.components.channel import android.content.res.Configuration import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable 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.tooling.preview.Preview import androidx.compose.ui.unit.dp +import my.nanihadesuka.compose.LazyColumnScrollbar import org.schabi.newpipe.R import org.schabi.newpipe.extractor.Image import org.schabi.newpipe.extractor.Image.ResolutionLevel import org.schabi.newpipe.extractor.stream.StreamExtractor -import org.schabi.newpipe.ui.components.metadata.ImageMetadataItem import org.schabi.newpipe.ui.components.metadata.MetadataItem import org.schabi.newpipe.ui.components.metadata.TagsSection +import org.schabi.newpipe.ui.components.metadata.imageMetadataItem import org.schabi.newpipe.ui.theme.AppTheme import org.schabi.newpipe.util.Localization import org.schabi.newpipe.util.NO_SERVICE_ID @Composable fun AboutChannelSection(channelInfo: ParcelableChannelInfo) { - // This tab currently holds little information, so a lazy column isn't needed here. - Column( - modifier = Modifier.padding(12.dp), - verticalArrangement = Arrangement.spacedBy(4.dp) - ) { - val (serviceId, description, count, avatars, banners, tags) = channelInfo + val (serviceId, description, count, avatars, banners, tags) = channelInfo + val lazyListState = rememberLazyListState() - if (description.isNotEmpty()) { - Text(text = description) - } + LazyColumnScrollbar(state = lazyListState) { + LazyColumn( + modifier = Modifier + .padding(12.dp) + .nestedScroll(rememberNestedScrollInteropConnection()), + state = lazyListState, + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + if (description.isNotEmpty()) { + item { + Text(text = description) + } + } - if (count != StreamExtractor.UNKNOWN_SUBSCRIBER_COUNT) { - MetadataItem( - title = R.string.metadata_subscribers, - value = Localization.shortCount(LocalContext.current, count) - ) - } + if (count != StreamExtractor.UNKNOWN_SUBSCRIBER_COUNT) { + item { + MetadataItem( + title = R.string.metadata_subscribers, + value = Localization.shortCount(LocalContext.current, count) + ) + } + } - if (avatars.isNotEmpty()) { - ImageMetadataItem(R.string.metadata_avatars, avatars) - } + imageMetadataItem(R.string.metadata_avatars, avatars) - if (banners.isNotEmpty()) { - ImageMetadataItem(R.string.metadata_banners, banners) - } + imageMetadataItem(R.string.metadata_banners, banners) - if (tags.isNotEmpty()) { - TagsSection(serviceId, tags) + if (tags.isNotEmpty()) { + item { + TagsSection(serviceId, tags) + } + } } } } diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/metadata/ImageMetadataItem.kt b/app/src/main/java/org/schabi/newpipe/ui/components/metadata/ImageMetadataItem.kt index 8d282c2345e..86ea5e6c7a0 100644 --- a/app/src/main/java/org/schabi/newpipe/ui/components/metadata/ImageMetadataItem.kt +++ b/app/src/main/java/org/schabi/newpipe/ui/components/metadata/ImageMetadataItem.kt @@ -3,6 +3,7 @@ package org.schabi.newpipe.ui.components.metadata import android.content.Context import android.content.res.Configuration import androidx.annotation.StringRes +import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.runtime.Composable @@ -32,6 +33,14 @@ fun ImageMetadataItem(@StringRes title: Int, images: List) { MetadataItem(title = title, value = imageLinks) } +fun LazyListScope.imageMetadataItem(@StringRes title: Int, images: List) { + if (images.isNotEmpty()) { + item { + ImageMetadataItem(title, images) + } + } +} + private fun convertImagesToLinks(context: Context, images: List): AnnotatedString { val preferredUrl = ImageStrategy.choosePreferredImage(images) 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/VideoDescriptionSection.kt index 62d4c1dc563..a4bbee9c654 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/VideoDescriptionSection.kt @@ -45,16 +45,15 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import my.nanihadesuka.compose.LazyColumnScrollbar import org.schabi.newpipe.R -import org.schabi.newpipe.extractor.Image import org.schabi.newpipe.extractor.localization.DateWrapper 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.metadata.ImageMetadataItem import org.schabi.newpipe.ui.components.metadata.MetadataItem import org.schabi.newpipe.ui.components.metadata.TagsSection +import org.schabi.newpipe.ui.components.metadata.imageMetadataItem import org.schabi.newpipe.ui.theme.AppTheme import org.schabi.newpipe.util.Localization import org.schabi.newpipe.util.NO_SERVICE_ID @@ -210,14 +209,6 @@ private fun LazyListScope.metadataItem(@StringRes title: Int, value: String) { } } -private fun LazyListScope.imageMetadataItem(@StringRes title: Int, images: List) { - if (images.isNotEmpty()) { - item { - ImageMetadataItem(title, images) - } - } -} - @Preview(name = "Light mode", uiMode = Configuration.UI_MODE_NIGHT_NO) @Preview(name = "Dark mode", uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable From c4efd2fd44105ce47b3132bafc9c80836b54cef6 Mon Sep 17 00:00:00 2001 From: Isira Seneviratne Date: Tue, 3 Sep 2024 03:56:55 +0530 Subject: [PATCH 10/14] Update dependencies --- app/build.gradle | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 76251f63eac..0a2b2456517 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -286,12 +286,12 @@ dependencies { implementation "org.ocpsoft.prettytime:prettytime:5.0.8.Final" // Jetpack Compose - implementation(platform('androidx.compose:compose-bom:2024.06.00')) - implementation 'androidx.compose.material3:material3:1.3.0-beta05' - implementation 'androidx.compose.material3.adaptive:adaptive:1.0.0-beta04' + implementation(platform('androidx.compose:compose-bom:2024.08.00')) + implementation 'androidx.compose.material3:material3:1.3.0-rc01' + implementation 'androidx.compose.material3.adaptive:adaptive:1.0.0-rc01' implementation 'androidx.activity:activity-compose' implementation 'androidx.compose.ui:ui-tooling-preview' - implementation 'androidx.compose.ui:ui-text:1.7.0-beta07' // Needed for parsing HTML to AnnotatedString + implementation 'androidx.compose.ui:ui-text:1.7.0-rc01' // Needed for parsing HTML to AnnotatedString implementation 'androidx.lifecycle:lifecycle-viewmodel-compose' implementation 'androidx.paging:paging-compose:3.3.2' implementation 'com.github.nanihadesuka:LazyColumnScrollbar:2.2.0' From 31d164d116e6564154b283b0f03f4de386110f48 Mon Sep 17 00:00:00 2001 From: Isira Seneviratne Date: Thu, 5 Sep 2024 09:37:47 +0530 Subject: [PATCH 11/14] Update dependencies --- app/build.gradle | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 0a2b2456517..00e3ad65913 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -286,12 +286,11 @@ dependencies { implementation "org.ocpsoft.prettytime:prettytime:5.0.8.Final" // Jetpack Compose - implementation(platform('androidx.compose:compose-bom:2024.08.00')) - implementation 'androidx.compose.material3:material3:1.3.0-rc01' - implementation 'androidx.compose.material3.adaptive:adaptive:1.0.0-rc01' + implementation(platform('androidx.compose:compose-bom:2024.09.00')) + implementation 'androidx.compose.material3:material3' + implementation 'androidx.compose.material3.adaptive:adaptive' implementation 'androidx.activity:activity-compose' implementation 'androidx.compose.ui:ui-tooling-preview' - implementation 'androidx.compose.ui:ui-text:1.7.0-rc01' // Needed for parsing HTML to AnnotatedString implementation 'androidx.lifecycle:lifecycle-viewmodel-compose' implementation 'androidx.paging:paging-compose:3.3.2' implementation 'com.github.nanihadesuka:LazyColumnScrollbar:2.2.0' From 8f9faf3e53930642e2c2d6ff5d6e90114aad7996 Mon Sep 17 00:00:00 2001 From: Isira Seneviratne Date: Fri, 6 Sep 2024 19:51:32 +0530 Subject: [PATCH 12/14] Address some review comments --- app/build.gradle | 3 + .../fragments/detail/DescriptionFragment.kt | 4 +- .../ui/components/common/DescriptionText.kt | 45 ----- .../newpipe/ui/components/common/Markdown.kt | 165 ++++++++++++++++++ .../ui/components/common/ParseDescription.kt | 72 ++++++++ .../common/link/YouTubeLinkHandler.kt | 25 +++ .../ui/components/metadata/MetadataItem.kt | 2 +- .../ui/components/metadata/TagsSection.kt | 6 +- ...Section.kt => StreamDescriptionSection.kt} | 14 +- 9 files changed, 279 insertions(+), 57 deletions(-) delete mode 100644 app/src/main/java/org/schabi/newpipe/ui/components/common/DescriptionText.kt create mode 100644 app/src/main/java/org/schabi/newpipe/ui/components/common/Markdown.kt create mode 100644 app/src/main/java/org/schabi/newpipe/ui/components/common/ParseDescription.kt create mode 100644 app/src/main/java/org/schabi/newpipe/ui/components/common/link/YouTubeLinkHandler.kt rename app/src/main/java/org/schabi/newpipe/ui/components/video/{VideoDescriptionSection.kt => StreamDescriptionSection.kt} (95%) 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) } } } From 0d12cfc9833a890a02c574f97e70834d3348214e Mon Sep 17 00:00:00 2001 From: Isira Seneviratne Date: Sat, 7 Sep 2024 05:12:35 +0530 Subject: [PATCH 13/14] Remove AndroidX Browser, make scrollbars red --- app/build.gradle | 3 --- .../ui/components/channel/AboutChannelSection.kt | 3 ++- .../ui/components/common/link/YouTubeLinkHandler.kt | 10 ++++++---- .../ui/components/video/StreamDescriptionSection.kt | 3 ++- app/src/main/java/org/schabi/newpipe/ui/theme/Theme.kt | 7 +++++++ 5 files changed, 17 insertions(+), 9 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index bfd87ba45d1..00e3ad65913 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -298,9 +298,6 @@ 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/ui/components/channel/AboutChannelSection.kt b/app/src/main/java/org/schabi/newpipe/ui/components/channel/AboutChannelSection.kt index 381fa681cce..d3dc5fa7885 100644 --- a/app/src/main/java/org/schabi/newpipe/ui/components/channel/AboutChannelSection.kt +++ b/app/src/main/java/org/schabi/newpipe/ui/components/channel/AboutChannelSection.kt @@ -24,6 +24,7 @@ import org.schabi.newpipe.ui.components.metadata.MetadataItem import org.schabi.newpipe.ui.components.metadata.TagsSection import org.schabi.newpipe.ui.components.metadata.imageMetadataItem import org.schabi.newpipe.ui.theme.AppTheme +import org.schabi.newpipe.ui.theme.NewPipeScrollbarSettings import org.schabi.newpipe.util.Localization import org.schabi.newpipe.util.NO_SERVICE_ID @@ -32,7 +33,7 @@ fun AboutChannelSection(channelInfo: ParcelableChannelInfo) { val (serviceId, description, count, avatars, banners, tags) = channelInfo val lazyListState = rememberLazyListState() - LazyColumnScrollbar(state = lazyListState) { + LazyColumnScrollbar(state = lazyListState, settings = NewPipeScrollbarSettings) { LazyColumn( modifier = Modifier .padding(12.dp) 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 index 33ae226032d..649f3ef4ca9 100644 --- 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 @@ -1,7 +1,7 @@ package org.schabi.newpipe.ui.components.common.link import android.content.Context -import androidx.browser.customtabs.CustomTabsIntent +import androidx.compose.ui.platform.AndroidUriHandler import androidx.compose.ui.text.LinkAnnotation import androidx.compose.ui.text.LinkInteractionListener import androidx.core.net.toUri @@ -9,8 +9,11 @@ import org.schabi.newpipe.extractor.ServiceList import org.schabi.newpipe.util.NavigationHelper class YouTubeLinkHandler(private val context: Context) : LinkInteractionListener { + private val uriHandler = AndroidUriHandler(context) + override fun onClick(link: LinkAnnotation) { - val uri = (link as LinkAnnotation.Url).url.toUri() + val url = (link as LinkAnnotation.Url).url + val uri = url.toUri() // TODO: Handle other links in NewPipe as well. if ("hashtag" in uri.pathSegments) { @@ -18,8 +21,7 @@ class YouTubeLinkHandler(private val context: Context) : LinkInteractionListener context, ServiceList.YouTube.serviceId, "#${uri.lastPathSegment}" ) } else { - // Open link in custom browser tab. - CustomTabsIntent.Builder().build().launchUrl(context, uri) + uriHandler.openUri(url) } } } diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/video/StreamDescriptionSection.kt b/app/src/main/java/org/schabi/newpipe/ui/components/video/StreamDescriptionSection.kt index 7805a3ada5e..f2b71bd34cb 100644 --- a/app/src/main/java/org/schabi/newpipe/ui/components/video/StreamDescriptionSection.kt +++ b/app/src/main/java/org/schabi/newpipe/ui/components/video/StreamDescriptionSection.kt @@ -55,6 +55,7 @@ import org.schabi.newpipe.ui.components.metadata.MetadataItem import org.schabi.newpipe.ui.components.metadata.TagsSection import org.schabi.newpipe.ui.components.metadata.imageMetadataItem import org.schabi.newpipe.ui.theme.AppTheme +import org.schabi.newpipe.ui.theme.NewPipeScrollbarSettings import org.schabi.newpipe.util.Localization import org.schabi.newpipe.util.NO_SERVICE_ID import java.time.OffsetDateTime @@ -66,7 +67,7 @@ fun StreamDescriptionSection(streamInfo: StreamInfo) { val hasDescription = streamInfo.description != Description.EMPTY_DESCRIPTION val lazyListState = rememberLazyListState() - LazyColumnScrollbar(state = lazyListState) { + LazyColumnScrollbar(state = lazyListState, settings = NewPipeScrollbarSettings) { LazyColumn( modifier = Modifier .padding(start = 12.dp, end = 12.dp) diff --git a/app/src/main/java/org/schabi/newpipe/ui/theme/Theme.kt b/app/src/main/java/org/schabi/newpipe/ui/theme/Theme.kt index 846794d725c..609f096c087 100644 --- a/app/src/main/java/org/schabi/newpipe/ui/theme/Theme.kt +++ b/app/src/main/java/org/schabi/newpipe/ui/theme/Theme.kt @@ -5,6 +5,8 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.darkColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import my.nanihadesuka.compose.ScrollbarSettings private val LightColors = lightColorScheme( primary = md_theme_light_primary, @@ -70,6 +72,11 @@ private val DarkColors = darkColorScheme( scrim = md_theme_dark_scrim, ) +val NewPipeScrollbarSettings = ScrollbarSettings( + thumbSelectedColor = md_theme_dark_primary, + thumbUnselectedColor = Color.Red +) + @Composable fun AppTheme(useDarkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) { MaterialTheme( From 1f4298b6c2519cc69c644bbeb9c475abcd44a928 Mon Sep 17 00:00:00 2001 From: Isira Seneviratne Date: Wed, 11 Sep 2024 22:10:25 +0530 Subject: [PATCH 14/14] Avoid issues if context is a ContextWrapper --- app/src/main/java/org/schabi/newpipe/ktx/Context.kt | 13 +++++++++++++ .../schabi/newpipe/ui/components/items/ItemList.kt | 4 ++-- .../ui/components/items/stream/StreamMenu.kt | 12 +++++------- .../newpipe/ui/components/metadata/TagsSection.kt | 4 ++-- 4 files changed, 22 insertions(+), 11 deletions(-) create mode 100644 app/src/main/java/org/schabi/newpipe/ktx/Context.kt diff --git a/app/src/main/java/org/schabi/newpipe/ktx/Context.kt b/app/src/main/java/org/schabi/newpipe/ktx/Context.kt new file mode 100644 index 00000000000..f2f4e96139b --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/ktx/Context.kt @@ -0,0 +1,13 @@ +package org.schabi.newpipe.ktx + +import android.content.Context +import android.content.ContextWrapper +import androidx.fragment.app.FragmentActivity + +tailrec fun Context.findFragmentActivity(): FragmentActivity { + return when (this) { + is FragmentActivity -> this + is ContextWrapper -> baseContext.findFragmentActivity() + else -> throw IllegalStateException("Unable to find FragmentActivity") + } +} diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/items/ItemList.kt b/app/src/main/java/org/schabi/newpipe/ui/components/items/ItemList.kt index 506687d1276..2491a6c1c67 100644 --- a/app/src/main/java/org/schabi/newpipe/ui/components/items/ItemList.kt +++ b/app/src/main/java/org/schabi/newpipe/ui/components/items/ItemList.kt @@ -14,7 +14,6 @@ import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.rememberNestedScrollInteropConnection import androidx.compose.ui.res.stringResource -import androidx.fragment.app.FragmentActivity import androidx.preference.PreferenceManager import androidx.window.core.layout.WindowWidthSizeClass import my.nanihadesuka.compose.LazyColumnScrollbar @@ -23,6 +22,7 @@ import org.schabi.newpipe.extractor.InfoItem import org.schabi.newpipe.extractor.playlist.PlaylistInfoItem import org.schabi.newpipe.extractor.stream.StreamInfoItem import org.schabi.newpipe.info_list.ItemViewMode +import org.schabi.newpipe.ktx.findFragmentActivity import org.schabi.newpipe.ui.components.items.playlist.PlaylistListItem import org.schabi.newpipe.ui.components.items.stream.StreamListItem import org.schabi.newpipe.util.DependentPreferenceHelper @@ -37,7 +37,7 @@ fun ItemList( val context = LocalContext.current val onClick = remember { { item: InfoItem -> - val fragmentManager = (context as FragmentActivity).supportFragmentManager + val fragmentManager = context.findFragmentActivity().supportFragmentManager if (item is StreamInfoItem) { NavigationHelper.openVideoDetailFragment( context, fragmentManager, item.serviceId, item.url, item.name, null, false diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/items/stream/StreamMenu.kt b/app/src/main/java/org/schabi/newpipe/ui/components/items/stream/StreamMenu.kt index 2bb143db8e1..7aa0b032c10 100644 --- a/app/src/main/java/org/schabi/newpipe/ui/components/items/stream/StreamMenu.kt +++ b/app/src/main/java/org/schabi/newpipe/ui/components/items/stream/StreamMenu.kt @@ -8,12 +8,12 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource -import androidx.fragment.app.FragmentActivity import androidx.lifecycle.viewmodel.compose.viewModel import org.schabi.newpipe.R import org.schabi.newpipe.database.stream.model.StreamEntity import org.schabi.newpipe.download.DownloadDialog import org.schabi.newpipe.extractor.stream.StreamInfoItem +import org.schabi.newpipe.ktx.findFragmentActivity import org.schabi.newpipe.local.dialog.PlaylistAppendDialog import org.schabi.newpipe.local.dialog.PlaylistDialog import org.schabi.newpipe.player.helper.PlayerHolder @@ -84,7 +84,7 @@ fun StreamMenu( ) { info -> // TODO: Use an AlertDialog composable instead. val downloadDialog = DownloadDialog(context, info) - val fragmentManager = (context as FragmentActivity).supportFragmentManager + val fragmentManager = context.findFragmentActivity().supportFragmentManager downloadDialog.show(fragmentManager, "downloadDialog") } } @@ -96,10 +96,8 @@ fun StreamMenu( val list = listOf(StreamEntity(stream)) PlaylistDialog.createCorrespondingDialog(context, list) { dialog -> val tag = if (dialog is PlaylistAppendDialog) "append" else "create" - dialog.show( - (context as FragmentActivity).supportFragmentManager, - "StreamDialogEntry@${tag}_playlist" - ) + val fragmentManager = context.findFragmentActivity().supportFragmentManager + dialog.show(fragmentManager, "StreamDialogEntry@${tag}_playlist") } } ) @@ -131,7 +129,7 @@ fun StreamMenu( SparseItemUtil.fetchUploaderUrlIfSparse( context, stream.serviceId, stream.url, stream.uploaderUrl ) { url -> - NavigationHelper.openChannelFragment(context as FragmentActivity, stream, url) + NavigationHelper.openChannelFragment(context.findFragmentActivity(), stream, url) } } ) 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 2357c69818b..6254e9c51c2 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 @@ -20,8 +20,8 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.fragment.app.FragmentActivity import org.schabi.newpipe.R +import org.schabi.newpipe.ktx.findFragmentActivity import org.schabi.newpipe.ui.theme.AppTheme import org.schabi.newpipe.util.NavigationHelper @@ -44,7 +44,7 @@ fun TagsSection(serviceId: Int, tags: List) { ElevatedSuggestionChip( onClick = { NavigationHelper.openSearchFragment( - (context as FragmentActivity).supportFragmentManager, serviceId, tag + context.findFragmentActivity().supportFragmentManager, serviceId, tag ) }, label = { Text(text = tag) }