Merge 6872e7c6f6520d264779161ddaa2182efd259de8 into 61c25d458901e90bd02d35ec060f9217a4cdd251

This commit is contained in:
Isira Seneviratne 2026-01-21 05:58:31 +00:00 committed by GitHub
commit 02a0f04b24
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 917 additions and 699 deletions

View File

@ -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<String> 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<Image> 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<String> 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;
}
}

View File

@ -1,140 +0,0 @@
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 com.evernote.android.state.State;
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;
public class DescriptionFragment extends BaseDescriptionFragment {
@State
StreamInfo streamInfo;
public DescriptionFragment(final StreamInfo streamInfo) {
this.streamInfo = streamInfo;
}
public DescriptionFragment() {
// keep empty constructor for State 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()));
}
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));
}
}
}
}

View File

@ -0,0 +1,36 @@
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.StreamDescriptionSection
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) {
StreamDescriptionSection(requireArguments().serializable(KEY_INFO)!!)
}
}
}
companion object {
@JvmStatic
fun getInstance(streamInfo: StreamInfo) = DescriptionFragment().apply {
arguments = bundleOf(KEY_INFO to streamInfo)
}
}
}

View File

@ -41,9 +41,12 @@ import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.content.ContextCompat
import androidx.core.content.edit
import androidx.core.net.toUri
import androidx.core.os.bundleOf
import androidx.core.os.postDelayed
import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.fragment.app.commit
import androidx.fragment.app.replace
import androidx.preference.PreferenceManager
import coil3.util.CoilUtils
import com.evernote.android.state.State
@ -79,8 +82,8 @@ import org.schabi.newpipe.fragments.BackPressable
import org.schabi.newpipe.fragments.BaseStateFragment
import org.schabi.newpipe.fragments.EmptyFragment
import org.schabi.newpipe.fragments.MainFragment
import org.schabi.newpipe.fragments.list.comments.CommentsFragment.Companion.getInstance
import org.schabi.newpipe.fragments.list.videos.RelatedItemsFragment.Companion.getInstance
import org.schabi.newpipe.fragments.list.comments.CommentsFragment
import org.schabi.newpipe.fragments.list.videos.RelatedItemsFragment
import org.schabi.newpipe.ktx.AnimationType
import org.schabi.newpipe.ktx.animate
import org.schabi.newpipe.ktx.animateRotation
@ -103,6 +106,7 @@ import org.schabi.newpipe.util.DependentPreferenceHelper
import org.schabi.newpipe.util.DeviceUtils
import org.schabi.newpipe.util.ExtractorHelper
import org.schabi.newpipe.util.InfoCache
import org.schabi.newpipe.util.KEY_INFO
import org.schabi.newpipe.util.ListHelper
import org.schabi.newpipe.util.Localization
import org.schabi.newpipe.util.NO_SERVICE_ID
@ -791,7 +795,7 @@ class VideoDetailFragment :
tabContentDescriptions.clear()
if (shouldShowComments()) {
pageAdapter.addFragment(getInstance(serviceId, url), COMMENTS_TAB_TAG)
pageAdapter.addFragment(CommentsFragment.getInstance(serviceId, url), COMMENTS_TAB_TAG)
tabIcons.add(R.drawable.ic_comment)
tabContentDescriptions.add(R.string.comments_tab_description)
}
@ -845,18 +849,19 @@ class VideoDetailFragment :
private fun updateTabs(info: StreamInfo) {
if (showRelatedItems) {
when (val relatedItemsLayout = binding.relatedItemsLayout) {
null -> pageAdapter.updateItem(RELATED_TAB_TAG, getInstance(info)) // phone
null -> pageAdapter.updateItem(RELATED_TAB_TAG, RelatedItemsFragment.getInstance(info)) // phone
else -> { // tablet + TV
getChildFragmentManager().beginTransaction()
.replace(R.id.relatedItemsLayout, getInstance(info))
.commitAllowingStateLoss()
childFragmentManager.commit(allowStateLoss = true) {
val args = bundleOf(KEY_INFO to info)
replace<RelatedItemsFragment>(R.id.relatedItemsLayout, args = args)
}
relatedItemsLayout.isVisible = !this.isFullscreen
}
}
}
if (showDescription) {
pageAdapter.updateItem(DESCRIPTION_TAB_TAG, DescriptionFragment(info))
pageAdapter.updateItem(DESCRIPTION_TAB_TAG, DescriptionFragment.getInstance(info))
}
binding.viewPager.visibility = View.VISIBLE

View File

@ -0,0 +1,37 @@
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.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
class AboutChannelFragment : Fragment() {
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
) = content {
AppTheme {
Surface(color = MaterialTheme.colorScheme.background) {
AboutChannelSection(requireArguments().parcelable(KEY_INFO)!!)
}
}
}
companion object {
@JvmStatic
fun getInstance(channelInfo: ChannelInfo) = AboutChannelFragment().apply {
arguments = bundleOf(KEY_INFO to ParcelableChannelInfo(channelInfo))
}
}
}

View File

@ -1,92 +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 com.evernote.android.state.State;
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;
public class ChannelAboutFragment extends BaseDescriptionFragment {
@State
protected ChannelInfo channelInfo;
ChannelAboutFragment(@NonNull final ChannelInfo channelInfo) {
this.channelInfo = channelInfo;
}
public ChannelAboutFragment() {
// keep empty constructor for State 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<String> 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(channelInfo.getSubscriberCount()));
}
addImagesMetadataItem(inflater, layout, R.string.metadata_avatars,
channelInfo.getAvatars());
addImagesMetadataItem(inflater, layout, R.string.metadata_banners,
channelInfo.getBanners());
}
}

View File

@ -491,7 +491,7 @@ public class ChannelFragment extends BaseStateFragment<ChannelInfo>
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));
}
}

View File

@ -1,9 +1,14 @@
package org.schabi.newpipe.ktx
import android.os.Bundle
import android.os.Parcelable
import androidx.core.os.BundleCompat
import java.io.Serializable
inline fun <reified T : Parcelable> Bundle.parcelable(key: String?): T? {
return BundleCompat.getParcelable(this, key, T::class.java)
}
inline fun <reified T : Serializable> Bundle.serializable(key: String?): T? {
return BundleCompat.getSerializable(this, key, T::class.java)
}

View File

@ -0,0 +1,93 @@
package org.schabi.newpipe.ui.components.channel
import android.content.res.Configuration
import androidx.compose.foundation.layout.Arrangement
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 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.common.LazyColumnThemedScrollbar
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) {
val (serviceId, description, count, avatars, banners, tags) = channelInfo
val lazyListState = rememberLazyListState()
LazyColumnThemedScrollbar(state = lazyListState) {
LazyColumn(
modifier = Modifier
.padding(12.dp)
.nestedScroll(rememberNestedScrollInteropConnection()),
state = lazyListState,
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
if (description.isNotEmpty()) {
item {
Text(text = description, style = MaterialTheme.typography.bodyMedium)
}
}
if (count != StreamExtractor.UNKNOWN_SUBSCRIBER_COUNT) {
item {
MetadataItem(
title = R.string.metadata_subscribers,
value = Localization.shortCount(LocalContext.current, count)
)
}
}
imageMetadataItem(R.string.metadata_avatars, avatars)
imageMetadataItem(R.string.metadata_banners, banners)
if (tags.isNotEmpty()) {
item {
TagsSection(serviceId, 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 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) {
AboutChannelSection(info)
}
}
}

View File

@ -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<Image>,
val banners: List<Image>,
val tags: List<String>
) : Parcelable {
constructor(channelInfo: ChannelInfo) : this(
channelInfo.serviceId, channelInfo.description, channelInfo.subscriberCount,
channelInfo.avatars, channelInfo.banners, channelInfo.tags
)
}

View File

@ -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<Any>().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) }
}

View File

@ -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<Description> {
override val values = sequenceOf(
Description("This is a description.", Description.PLAIN_TEXT),
Description("This is a <b>bold description</b>.", 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))
}
}
}

View File

@ -0,0 +1,27 @@
package org.schabi.newpipe.ui.components.common.link
import android.content.Context
import androidx.compose.ui.platform.AndroidUriHandler
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 {
private val uriHandler = AndroidUriHandler(context)
override fun onClick(link: LinkAnnotation) {
val url = (link as LinkAnnotation.Url).url
val uri = url.toUri()
// TODO: Handle other links in NewPipe as well.
if ("hashtag" in uri.pathSegments) {
NavigationHelper.openSearch(
context, ServiceList.YouTube.serviceId, "#${uri.lastPathSegment}"
)
} else {
uriHandler.openUri(url)
}
}
}

View File

@ -0,0 +1,98 @@
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
@Composable
fun ImageMetadataItem(@StringRes title: Int, images: List<Image>) {
val context = LocalContext.current
val imageLinks = remember(images) { convertImagesToLinks(context, images) }
MetadataItem(title = title, value = imageLinks)
}
fun LazyListScope.imageMetadataItem(@StringRes title: Int, images: List<Image>) {
if (images.isNotEmpty()) {
item {
ImageMetadataItem(title, images)
}
}
}
private fun convertImagesToLinks(context: Context, images: List<Image>): 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()
}
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() {
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,59 @@
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.layout.width
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.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.width(96.dp),
textAlign = TextAlign.End,
text = stringResource(title).uppercase(),
style = MaterialTheme.typography.titleSmall
)
Text(text = value, style = MaterialTheme.typography.bodyMedium)
}
}
@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,65 @@
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.material3.ElevatedSuggestionChip
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.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
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.ktx.findFragmentActivity
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(),
text = stringResource(R.string.metadata_tags).uppercase(),
style = MaterialTheme.typography.titleMedium,
textAlign = TextAlign.Center
)
FlowRow(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
for (tag in sortedTags) {
ElevatedSuggestionChip(
onClick = {
NavigationHelper.openSearchFragment(
context.findFragmentActivity().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,224 @@
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
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.LazyListScope
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.LocalDensity
import androidx.compose.ui.platform.rememberNestedScrollInteropConnection
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
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.LazyColumnThemedScrollbar
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
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 StreamDescriptionSection(streamInfo: StreamInfo) {
var isSelectable by rememberSaveable { mutableStateOf(false) }
val hasDescription = streamInfo.description != Description.EMPTY_DESCRIPTION
val lazyListState = rememberLazyListState()
LazyColumnThemedScrollbar(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(it.offsetDateTime())
Text(
text = stringResource(R.string.upload_date_text, date),
style = MaterialTheme.typography.titleMedium
)
}
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 {
val description = parseDescription(streamInfo.description, streamInfo.serviceId)
if (isSelectable) {
SelectionContainer {
Text(text = description, style = MaterialTheme.typography.bodyMedium)
}
} else {
Text(text = description, style = MaterialTheme.typography.bodyMedium)
}
}
}
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))
}
}
val ageLimit = streamInfo.ageLimit
if (ageLimit != StreamExtractor.NO_AGE_LIMIT) {
item {
MetadataItem(title = R.string.metadata_age_limit, value = ageLimit.toString())
}
}
streamInfo.languageInfo?.let {
item {
MetadataItem(
title = R.string.metadata_language,
value = it.getDisplayLanguage(Localization.getAppLocale())
)
}
}
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)
}
}
}
}
}
private 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 StreamDescriptionSectionPreview() {
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) {
StreamDescriptionSection(info)
}
}
}

View File

@ -1,11 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- This is used to inflate a chip with a Material theme, otherwise it would crash -->
<!-- Theme.MaterialComponents.DayNight is used to guarantee auto day/night switching -->
<com.google.android.material.chip.Chip xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:theme="@style/Theme.MaterialComponents.DayNight.Bridge"
app:chipMinTouchTargetSize="40dp"
tools:text="I'm a correctly themed chip!" />

View File

@ -1,101 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.core.widget.NestedScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:scrollbars="vertical">
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/constraint_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:animateLayoutChanges="true">
<org.schabi.newpipe.views.NewPipeTextView
android:id="@+id/detail_upload_date_view"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="6dp"
android:layout_marginEnd="8dp"
android:textAppearance="?android:attr/textAppearanceMedium"
android:textSize="@dimen/video_item_detail_upload_date_text_size"
android:textStyle="bold"
app:layout_constraintEnd_toStartOf="@+id/detail_select_description_button"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="Published on Oct 2, 2009" />
<ImageView
android:id="@+id/detail_select_description_button"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginEnd="16dp"
android:layout_marginBottom="2dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:clickable="true"
android:contentDescription="@string/description_select_enable"
android:focusable="true"
android:padding="5dp"
android:src="@drawable/ic_select_all"
app:layout_constraintBottom_toTopOf="@+id/barrier"
app:layout_constraintDimensionRatio="1"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<androidx.constraintlayout.widget.Barrier
android:id="@+id/barrier"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:barrierAllowsGoneWidgets="false"
app:barrierDirection="top"
app:constraint_referenced_ids="detail_description_note_view,detail_description_view" />
<org.schabi.newpipe.views.NewPipeTextView
android:id="@+id/detail_description_note_view"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="16dp"
android:gravity="center"
android:text="@string/description_select_note"
android:textSize="12sp"
android:textStyle="italic"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/detail_upload_date_view"
tools:visibility="visible" />
<org.schabi.newpipe.views.NewPipeTextView
android:id="@+id/detail_description_view"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="16dp"
android:textAppearance="?android:attr/textAppearanceMedium"
android:textSize="@dimen/video_item_detail_description_text_size"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/detail_description_note_view"
tools:text="Description Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed a ultricies ex. Integer sit amet sodales risus. Duis non mi et urna pretium bibendum." />
<LinearLayout
android:id="@+id/detail_metadata_layout"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="10dp"
android:layout_marginEnd="16dp"
android:layout_marginBottom="10dp"
android:orientation="vertical"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/detail_description_view" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.core.widget.NestedScrollView>

View File

@ -1,33 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingVertical="6dp">
<org.schabi.newpipe.views.NewPipeTextView
android:id="@+id/metadata_type_view"
android:layout_width="96dp"
android:layout_height="wrap_content"
android:gravity="center_vertical|end"
android:textAllCaps="true"
android:textColor="?android:attr/textColorPrimary"
android:textStyle="bold"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="Licence" />
<org.schabi.newpipe.views.NewPipeTextView
android:id="@+id/metadata_content_view"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/metadata_type_view"
app:layout_constraintTop_toTopOf="parent"
tools:text="Description Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed a ultricies ex. Integer sit amet sodales risus. Duis non mi et urna pretium bibendum." />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -1,31 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fadeScrollbars="false">
<TextView
android:id="@+id/metadata_type_view"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:gravity="center"
android:text="@string/metadata_tags"
android:textAllCaps="true"
android:textColor="?android:attr/textColorPrimary"
android:textStyle="bold"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<com.google.android.material.chip.ChipGroup
android:id="@+id/metadata_tags_chips"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/metadata_type_view" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -765,7 +765,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>