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/detail/DescriptionFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/detail/DescriptionFragment.java deleted file mode 100644 index 52fb3f29e72..00000000000 --- a/app/src/main/java/org/schabi/newpipe/fragments/detail/DescriptionFragment.java +++ /dev/null @@ -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 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; - } - - if (contentRes != 0) { - addMetadataItem(inflater, layout, false, R.string.metadata_privacy, - getString(contentRes)); - } - } - } -} 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 new file mode 100644 index 00000000000..c32a80fd2ef --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/detail/DescriptionFragment.kt @@ -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) + } + } +} 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 63077e92d44..c53ec760370 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 @@ -944,7 +944,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/fragments/list/channel/AboutChannelFragment.kt b/app/src/main/java/org/schabi/newpipe/fragments/list/channel/AboutChannelFragment.kt new file mode 100644 index 00000000000..510a940be37 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/channel/AboutChannelFragment.kt @@ -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)) + } + } +} 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 b7f4a9d3dfc..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 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 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 2d5873e3f41..36b305966e7 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 @@ -487,7 +487,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/ktx/Bundle.kt b/app/src/main/java/org/schabi/newpipe/ktx/Bundle.kt index c65b286cfa6..89cdcf6503e 100644 --- a/app/src/main/java/org/schabi/newpipe/ktx/Bundle.kt +++ b/app/src/main/java/org/schabi/newpipe/ktx/Bundle.kt @@ -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 Bundle.parcelable(key: String?): T? { + return BundleCompat.getParcelable(this, key, T::class.java) +} + inline fun Bundle.serializable(key: String?): T? { return BundleCompat.getSerializable(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 new file mode 100644 index 00000000000..ae40d333b04 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/ui/components/channel/AboutChannelSection.kt @@ -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) + } + } +} 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/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/Scrollbar.kt b/app/src/main/java/org/schabi/newpipe/ui/components/common/Scrollbar.kt index eb1595467f9..1e619f32a12 100644 --- a/app/src/main/java/org/schabi/newpipe/ui/components/common/Scrollbar.kt +++ b/app/src/main/java/org/schabi/newpipe/ui/components/common/Scrollbar.kt @@ -4,6 +4,7 @@ import androidx.compose.foundation.lazy.LazyListState import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import my.nanihadesuka.compose.LazyColumnScrollbar import my.nanihadesuka.compose.ScrollbarSettings @Composable @@ -20,7 +21,7 @@ fun LazyColumnThemedScrollbar( indicatorContent: (@Composable (index: Int, isThumbSelected: Boolean) -> Unit)? = null, content: @Composable () -> Unit ) { - my.nanihadesuka.compose.LazyColumnScrollbar( + LazyColumnScrollbar( state = state, modifier = modifier, settings = settings, 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..649f3ef4ca9 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/ui/components/common/link/YouTubeLinkHandler.kt @@ -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) + } + } +} 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..86ea5e6c7a0 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/ui/components/metadata/ImageMetadataItem.kt @@ -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) { + val context = LocalContext.current + val imageLinks = remember(images) { convertImagesToLinks(context, images) } + + 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) + + 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 + ) + } + } +} 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..98df4768144 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/ui/components/metadata/MetadataItem.kt @@ -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") + } + } + } +} 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..d0f77ac8b62 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/ui/components/metadata/TagsSection.kt @@ -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) { + 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")) + } + } +} 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 new file mode 100644 index 00000000000..d8645d40bba --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/ui/components/video/StreamDescriptionSection.kt @@ -0,0 +1,226 @@ +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.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.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(LocalContext.current, 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 { + 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) + } + } + } + } +} + +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 example description", Description.HTML) + + AppTheme { + Surface(color = MaterialTheme.colorScheme.background) { + StreamDescriptionSection(info) + } + } +} 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 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b01b8697c1b..eb145e134ae 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -756,7 +756,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