1
0
mirror of https://github.com/TeamNewPipe/NewPipe synced 2024-11-23 02:35:21 +01:00

Migrate description fragment to Jetpack Compose

This commit is contained in:
Isira Seneviratne 2024-08-27 08:18:53 +05:30
parent 99996d940d
commit de6285b1e2
9 changed files with 540 additions and 136 deletions

View File

@ -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'

View File

@ -1,140 +1,36 @@
package org.schabi.newpipe.fragments.detail;
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.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
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<String> 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())));
}
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;
class DescriptionFragment : Fragment() {
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
) = content {
AppTheme {
Surface(color = MaterialTheme.colorScheme.background) {
VideoDescriptionSection(requireArguments().serializable(KEY_INFO)!!)
}
}
}
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)
}
}
}

View File

@ -946,7 +946,7 @@ public final class VideoDetailFragment
}
if (showDescription) {
pageAdapter.updateItem(DESCRIPTION_TAB_TAG, new DescriptionFragment(info));
pageAdapter.updateItem(DESCRIPTION_TAB_TAG, DescriptionFragment.getInstance(info));
}
binding.viewPager.setVisibility(View.VISIBLE);

View File

@ -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
)
}

View File

@ -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<Image>,
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<Image>) {
ImageStrategy.choosePreferredImage(images)?.let {
item {
ImageMetadataItem(title, images, it)
}
}
}
private fun convertImagesToLinks(
context: Context,
images: List<Image>,
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
)
}
}
}

View File

@ -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")
}
}
}
}

View File

@ -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<String>) {
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"))
}
}
}

View File

@ -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 <b>example</b> description", Description.HTML)
AppTheme {
Surface(color = MaterialTheme.colorScheme.background) {
VideoDescriptionSection(info)
}
}
}

View File

@ -755,7 +755,7 @@
<string name="select_night_theme_toast">You can select your favorite night theme below</string>
<string name="night_theme_available">This option is only available if %s is selected for Theme</string>
<string name="download_has_started">Download has started</string>
<string name="description_select_note">You can now select text inside the description. Note that the page may flicker and links may not be clickable while in selection mode.</string>
<string name="description_select_note">You can now select text inside the description.</string>
<string name="description_select_enable">Enable selecting text in the description</string>
<string name="description_select_disable">Disable selecting text in the description</string>
<string name="metadata_category">Category</string>