diff --git a/app/build.gradle b/app/build.gradle index 396f119b24f..051414ba07c 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -197,7 +197,7 @@ dependencies { // name and the commit hash with the commit hash of the (pushed) commit you want to test // This works thanks to JitPack: https://jitpack.io/ implementation 'com.github.TeamNewPipe:nanojson:1d9e1aea9049fc9f85e68b43ba39fe7be1c1f751' - implementation 'com.github.TeamNewPipe:NewPipeExtractor:340095515d45ecbee576872c7198992ebd8e4f08' + implementation 'com.github.TeamNewPipe:NewPipeExtractor:95a3cc0a173bba28c179f9f9503b1010ec6bff21' implementation 'com.github.TeamNewPipe:NoNonsense-FilePicker:5.0.0' /** Checkstyle **/ diff --git a/app/src/androidTest/java/org/schabi/newpipe/local/subscription/SubscriptionManagerTest.java b/app/src/androidTest/java/org/schabi/newpipe/local/subscription/SubscriptionManagerTest.java index e71083d2cc6..213b679f009 100644 --- a/app/src/androidTest/java/org/schabi/newpipe/local/subscription/SubscriptionManagerTest.java +++ b/app/src/androidTest/java/org/schabi/newpipe/local/subscription/SubscriptionManagerTest.java @@ -10,19 +10,13 @@ import org.junit.Test; import org.schabi.newpipe.database.AppDatabase; import org.schabi.newpipe.database.feed.model.FeedGroupEntity; -import org.schabi.newpipe.database.stream.model.StreamEntity; import org.schabi.newpipe.database.subscription.SubscriptionEntity; import org.schabi.newpipe.extractor.channel.ChannelInfo; import org.schabi.newpipe.extractor.exceptions.ExtractionException; -import org.schabi.newpipe.extractor.localization.DateWrapper; -import org.schabi.newpipe.extractor.stream.StreamInfoItem; -import org.schabi.newpipe.extractor.stream.StreamType; import org.schabi.newpipe.testUtil.TestDatabase; import org.schabi.newpipe.testUtil.TrampolineSchedulerRule; import java.io.IOException; -import java.time.OffsetDateTime; -import java.util.Comparator; import java.util.List; public class SubscriptionManagerTest { @@ -58,7 +52,7 @@ public void testInsert() throws ExtractionException, IOException { final ChannelInfo info = ChannelInfo.getInfo("https://www.youtube.com/c/3blue1brown"); final SubscriptionEntity subscription = SubscriptionEntity.from(info); - manager.insertSubscription(subscription, info); + manager.insertSubscription(subscription); final SubscriptionEntity readSubscription = getAssertOneSubscriptionEntity(); // the uid has changed, since the uid is chosen upon inserting, but the rest should match @@ -76,7 +70,7 @@ public void testUpdateNotificationMode() throws ExtractionException, IOException final SubscriptionEntity subscription = SubscriptionEntity.from(info); subscription.setNotificationMode(0); - manager.insertSubscription(subscription, info); + manager.insertSubscription(subscription); manager.updateNotificationMode(subscription.getServiceId(), subscription.getUrl(), 1) .blockingAwait(); final SubscriptionEntity anotherSubscription = getAssertOneSubscriptionEntity(); @@ -85,35 +79,4 @@ public void testUpdateNotificationMode() throws ExtractionException, IOException assertEquals(subscription.getUrl(), anotherSubscription.getUrl()); assertEquals(1, anotherSubscription.getNotificationMode()); } - - @Test - public void testRememberRecentStreams() throws ExtractionException, IOException { - final ChannelInfo info = ChannelInfo.getInfo("https://www.youtube.com/c/Polyphia"); - final List relatedItems = List.of( - new StreamInfoItem(0, "a", "b", StreamType.VIDEO_STREAM), - new StreamInfoItem(1, "c", "d", StreamType.AUDIO_STREAM), - new StreamInfoItem(2, "e", "f", StreamType.AUDIO_LIVE_STREAM), - new StreamInfoItem(3, "g", "h", StreamType.LIVE_STREAM)); - relatedItems.forEach(item -> { - // these two fields must be non-null for the insert to succeed - item.setUploaderUrl(info.getUrl()); - item.setUploaderName(info.getName()); - // the upload date must not be too much in the past for the item to actually be inserted - item.setUploadDate(new DateWrapper(OffsetDateTime.now())); - }); - info.setRelatedItems(relatedItems); - final SubscriptionEntity subscription = SubscriptionEntity.from(info); - - manager.insertSubscription(subscription, info); - final List streams = database.streamDAO().getAll().blockingFirst(); - - assertEquals(4, streams.size()); - streams.sort(Comparator.comparing(StreamEntity::getServiceId)); - for (int i = 0; i < 4; i++) { - assertEquals(relatedItems.get(0).getServiceId(), streams.get(0).getServiceId()); - assertEquals(relatedItems.get(0).getUrl(), streams.get(0).getUrl()); - assertEquals(relatedItems.get(0).getName(), streams.get(0).getTitle()); - assertEquals(relatedItems.get(0).getStreamType(), streams.get(0).getStreamType()); - } - } } diff --git a/app/src/main/java/org/schabi/newpipe/RouterActivity.java b/app/src/main/java/org/schabi/newpipe/RouterActivity.java index 70c377de973..c59dc753235 100644 --- a/app/src/main/java/org/schabi/newpipe/RouterActivity.java +++ b/app/src/main/java/org/schabi/newpipe/RouterActivity.java @@ -65,6 +65,7 @@ import org.schabi.newpipe.extractor.exceptions.ReCaptchaException; import org.schabi.newpipe.extractor.exceptions.SoundCloudGoPlusContentException; import org.schabi.newpipe.extractor.exceptions.YoutubeMusicPremiumContentException; +import org.schabi.newpipe.extractor.linkhandler.ListLinkHandler; import org.schabi.newpipe.extractor.playlist.PlaylistInfo; import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.ktx.ExceptionUtils; @@ -72,10 +73,11 @@ import org.schabi.newpipe.player.PlayerType; import org.schabi.newpipe.player.helper.PlayerHelper; import org.schabi.newpipe.player.helper.PlayerHolder; -import org.schabi.newpipe.player.playqueue.ChannelPlayQueue; +import org.schabi.newpipe.player.playqueue.ChannelTabPlayQueue; import org.schabi.newpipe.player.playqueue.PlayQueue; import org.schabi.newpipe.player.playqueue.PlaylistPlayQueue; import org.schabi.newpipe.player.playqueue.SinglePlayQueue; +import org.schabi.newpipe.util.ChannelTabHelper; import org.schabi.newpipe.util.Constants; import org.schabi.newpipe.util.DeviceUtils; import org.schabi.newpipe.util.ExtractorHelper; @@ -1022,7 +1024,16 @@ public Consumer getResultHandler(final Choice choice) { } playQueue = new SinglePlayQueue((StreamInfo) info); } else if (info instanceof ChannelInfo) { - playQueue = new ChannelPlayQueue((ChannelInfo) info); + final Optional playableTab = ((ChannelInfo) info).getTabs() + .stream() + .filter(ChannelTabHelper::isStreamsTab) + .findFirst(); + + if (playableTab.isPresent()) { + playQueue = new ChannelTabPlayQueue(info.getServiceId(), playableTab.get()); + } else { + return; // there is no playable tab + } } else if (info instanceof PlaylistInfo) { playQueue = new PlaylistPlayQueue((PlaylistInfo) info); } else { 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 new file mode 100644 index 00000000000..ae334b761c4 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/detail/BaseDescriptionFragment.java @@ -0,0 +1,209 @@ +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.os.Bundle; +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.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.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 + */ + @Nullable + protected abstract Description getDescription(); + + /** + * Get the streaming service. Used for generating description links. + * @return streaming service + */ + @Nullable + 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 + */ + @Nullable + 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, + @Nullable 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 void addTagsMetadataItem(final LayoutInflater inflater, final LinearLayout layout) { + final List tags = getTags(); + + if (tags != null && !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 index d364c0c0fd4..92219883b28 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/detail/DescriptionFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/detail/DescriptionFragment.java @@ -1,46 +1,29 @@ package org.schabi.newpipe.fragments.detail; -import static android.text.TextUtils.isEmpty; import static org.schabi.newpipe.extractor.stream.StreamExtractor.NO_AGE_LIMIT; -import static org.schabi.newpipe.extractor.utils.Utils.isBlank; import static org.schabi.newpipe.util.Localization.getAppLocale; -import static org.schabi.newpipe.util.text.TextLinkifier.SET_LINK_MOVEMENT_METHOD; -import android.os.Bundle; 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.StreamingService; import org.schabi.newpipe.extractor.stream.Description; import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.util.Localization; -import org.schabi.newpipe.util.NavigationHelper; -import org.schabi.newpipe.util.external_communication.ShareUtils; -import org.schabi.newpipe.util.text.TextLinkifier; + +import java.util.List; import icepick.State; -import io.reactivex.rxjava3.disposables.CompositeDisposable; -public class DescriptionFragment extends BaseFragment { +public class DescriptionFragment extends BaseDescriptionFragment { @State StreamInfo streamInfo = null; - final CompositeDisposable descriptionDisposables = new CompositeDisposable(); - FragmentDescriptionBinding binding; public DescriptionFragment() { } @@ -49,86 +32,64 @@ public DescriptionFragment(final StreamInfo streamInfo) { this.streamInfo = streamInfo; } + @Nullable @Override - public View onCreateView(@NonNull final LayoutInflater inflater, - @Nullable final ViewGroup container, - @Nullable final Bundle savedInstanceState) { - binding = FragmentDescriptionBinding.inflate(inflater, container, false); - if (streamInfo != null) { - setupUploadDate(); - setupDescription(); - setupMetadata(inflater, binding.detailMetadataLayout); + protected Description getDescription() { + if (streamInfo == null) { + return null; } - return binding.getRoot(); + return streamInfo.getDescription(); } + @Nullable @Override - public void onDestroy() { - descriptionDisposables.clear(); - super.onDestroy(); + protected StreamingService getService() { + if (streamInfo == null) { + return null; + } + return streamInfo.getService(); } - - private void setupUploadDate() { - if (streamInfo.getUploadDate() != null) { - binding.detailUploadDateView.setText(Localization - .localizeUploadDate(activity, streamInfo.getUploadDate().offsetDateTime())); - } else { - binding.detailUploadDateView.setVisibility(View.GONE); + @Override + protected int getServiceId() { + if (streamInfo == null) { + return -1; } + return streamInfo.getServiceId(); } - - private void setupDescription() { - final Description description = streamInfo.getDescription(); - if (description == null || isEmpty(description.getContent()) - || description == Description.EMPTY_DESCRIPTION) { - binding.detailDescriptionView.setVisibility(View.GONE); - binding.detailSelectDescriptionButton.setVisibility(View.GONE); - return; + @Nullable + @Override + protected String getStreamUrl() { + if (streamInfo == null) { + return null; } - - // 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(); - } - }); + return streamInfo.getUrl(); } - 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); + @Nullable + @Override + public List getTags() { + if (streamInfo == null) { + return null; + } + return streamInfo.getTags(); } - private void disableDescriptionSelection() { - // show description content again, otherwise some links are not clickable - TextLinkifier.fromDescription(binding.detailDescriptionView, - streamInfo.getDescription(), HtmlCompat.FROM_HTML_MODE_LEGACY, - streamInfo.getService(), streamInfo.getUrl(), - descriptionDisposables, SET_LINK_MOVEMENT_METHOD); - - binding.detailDescriptionNoteView.setVisibility(View.GONE); - binding.detailDescriptionView.setTextIsSelectable(false); + @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); + } - 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); - } + if (streamInfo == null) { + return; + } - private void setupMetadata(final LayoutInflater inflater, - final LinearLayout layout) { addMetadataItem(inflater, layout, false, R.string.metadata_category, streamInfo.getCategory()); @@ -153,67 +114,6 @@ private void setupMetadata(final LayoutInflater inflater, streamInfo.getHost()); addMetadataItem(inflater, layout, true, R.string.metadata_thumbnail_url, streamInfo.getThumbnailUrl()); - - addTagsMetadataItem(inflater, layout); - } - - private void addMetadataItem(final LayoutInflater inflater, - final LinearLayout layout, - final boolean linkifyContent, - @StringRes final int type, - @Nullable 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 void addTagsMetadataItem(final LayoutInflater inflater, final LinearLayout layout) { - if (streamInfo.getTags() != null && !streamInfo.getTags().isEmpty()) { - final var itemBinding = ItemMetadataTagsBinding.inflate(inflater, layout, false); - - streamInfo.getTags().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(), - streamInfo.getServiceId(), ((Chip) chip).getText().toString()); - } - } - - private boolean onTagLongClick(final View chip) { - ShareUtils.copyToClipboard(requireContext(), ((Chip) chip).getText().toString()); - return true; } private void addPrivacyMetadataItem(final LayoutInflater inflater, final LinearLayout layout) { 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 def1774b765..686e102f1bf 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 @@ -112,6 +112,7 @@ import org.schabi.newpipe.util.ThemeHelper; import org.schabi.newpipe.util.external_communication.KoreUtils; import org.schabi.newpipe.util.external_communication.ShareUtils; +import org.schabi.newpipe.util.PlayButtonHelper; import java.util.ArrayList; import java.util.Iterator; @@ -535,9 +536,11 @@ private void setOnLongClickListeners() { })); binding.detailControlsBackground.setOnLongClickListener(makeOnLongClickListener(info -> - openBackgroundPlayer(true))); + openBackgroundPlayer(true) + )); binding.detailControlsPopup.setOnLongClickListener(makeOnLongClickListener(info -> - openPopupPlayer(true))); + openPopupPlayer(true) + )); binding.detailControlsDownload.setOnLongClickListener(makeOnLongClickListener(info -> NavigationHelper.openDownloads(activity))); @@ -620,8 +623,7 @@ protected void initListeners() { final View.OnTouchListener controlsTouchListener = (view, motionEvent) -> { if (motionEvent.getAction() == MotionEvent.ACTION_DOWN - && PreferenceManager.getDefaultSharedPreferences(activity) - .getBoolean(getString(R.string.show_hold_to_append_key), true)) { + && PlayButtonHelper.shouldShowHoldToAppendTip(activity)) { animate(binding.touchAppendDetail, true, 250, AnimationType.ALPHA, 0, () -> animate(binding.touchAppendDetail, false, 1500, AnimationType.ALPHA, 1000)); diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListInfoFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListInfoFragment.java index c73ae8be062..e7e9f5aad1d 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListInfoFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListInfoFragment.java @@ -16,7 +16,6 @@ import org.schabi.newpipe.extractor.ListExtractor; import org.schabi.newpipe.extractor.ListInfo; import org.schabi.newpipe.extractor.Page; -import org.schabi.newpipe.extractor.channel.ChannelInfo; import org.schabi.newpipe.extractor.exceptions.ContentNotSupportedException; import org.schabi.newpipe.util.Constants; import org.schabi.newpipe.views.NewPipeRecyclerView; @@ -234,11 +233,7 @@ public void handleResult(@NonNull final L result) { showListFooter(hasMoreItems()); } else { infoListAdapter.clearStreamItemList(); - // showEmptyState should be called only if there is no item as - // well as no header in infoListAdapter - if (!(result instanceof ChannelInfo && infoListAdapter.getItemCount() == 1)) { - showEmptyState(); - } + showEmptyState(); } } 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 new file mode 100644 index 00000000000..543fd80f322 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelAboutFragment.java @@ -0,0 +1,107 @@ +package org.schabi.newpipe.fragments.list.channel; + +import static org.schabi.newpipe.extractor.stream.StreamExtractor.UNKNOWN_SUBSCRIBER_COUNT; + +import android.content.Context; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.LinearLayout; + +import androidx.annotation.Nullable; + +import org.schabi.newpipe.R; +import org.schabi.newpipe.extractor.StreamingService; +import org.schabi.newpipe.extractor.channel.ChannelInfo; +import org.schabi.newpipe.extractor.stream.Description; +import org.schabi.newpipe.fragments.detail.BaseDescriptionFragment; +import org.schabi.newpipe.util.DeviceUtils; +import org.schabi.newpipe.util.Localization; + +import java.util.List; + +import icepick.State; + +public class ChannelAboutFragment extends BaseDescriptionFragment { + @State + protected ChannelInfo channelInfo; + + public static ChannelAboutFragment getInstance(final ChannelInfo channelInfo) { + final ChannelAboutFragment fragment = new ChannelAboutFragment(); + fragment.channelInfo = channelInfo; + return fragment; + } + + public ChannelAboutFragment() { + super(); + } + + @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() { + if (channelInfo == null) { + return null; + } + return new Description(channelInfo.getDescription(), Description.PLAIN_TEXT); + } + + @Nullable + @Override + protected StreamingService getService() { + if (channelInfo == null) { + return null; + } + return channelInfo.getService(); + } + + @Override + protected int getServiceId() { + if (channelInfo == null) { + return -1; + } + return channelInfo.getServiceId(); + } + + @Nullable + @Override + protected String getStreamUrl() { + return null; + } + + @Nullable + @Override + public List getTags() { + if (channelInfo == null) { + return null; + } + 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; + } + + final Context context = getContext(); + if (channelInfo.getSubscriberCount() != UNKNOWN_SUBSCRIBER_COUNT) { + addMetadataItem(inflater, layout, false, R.string.metadata_subscribers, + Localization.localizeNumber(context, channelInfo.getSubscriberCount())); + } + + addMetadataItem(inflater, layout, true, R.string.metadata_avatar_url, + channelInfo.getAvatarUrl()); + addMetadataItem(inflater, layout, true, R.string.metadata_banner_url, + channelInfo.getBannerUrl()); + } +} 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 8a0b49249ae..c1345180ba5 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 @@ -1,10 +1,12 @@ package org.schabi.newpipe.fragments.list.channel; +import static org.schabi.newpipe.extractor.utils.Utils.isBlank; import static org.schabi.newpipe.ktx.TextViewUtils.animateTextColor; import static org.schabi.newpipe.ktx.ViewUtils.animate; import static org.schabi.newpipe.ktx.ViewUtils.animateBackgroundColor; import android.content.Context; +import android.content.SharedPreferences; import android.graphics.Color; import android.os.Bundle; import android.text.TextUtils; @@ -16,51 +18,49 @@ import android.view.MenuItem; import android.view.View; import android.view.ViewGroup; -import android.widget.Button; import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import androidx.appcompat.app.ActionBar; import androidx.core.content.ContextCompat; +import androidx.core.graphics.ColorUtils; +import androidx.preference.PreferenceManager; import com.google.android.material.snackbar.Snackbar; +import com.google.android.material.tabs.TabLayout; import com.jakewharton.rxbinding4.view.RxView; import org.schabi.newpipe.R; import org.schabi.newpipe.database.subscription.NotificationMode; import org.schabi.newpipe.database.subscription.SubscriptionEntity; -import org.schabi.newpipe.databinding.ChannelHeaderBinding; import org.schabi.newpipe.databinding.FragmentChannelBinding; -import org.schabi.newpipe.databinding.PlaylistControlBinding; import org.schabi.newpipe.error.ErrorInfo; import org.schabi.newpipe.error.ErrorUtil; import org.schabi.newpipe.error.UserAction; -import org.schabi.newpipe.extractor.ListExtractor; import org.schabi.newpipe.extractor.channel.ChannelInfo; import org.schabi.newpipe.extractor.exceptions.ContentNotSupportedException; -import org.schabi.newpipe.extractor.stream.StreamInfoItem; -import org.schabi.newpipe.fragments.list.BaseListInfoFragment; +import org.schabi.newpipe.extractor.linkhandler.ListLinkHandler; +import org.schabi.newpipe.fragments.BaseStateFragment; +import org.schabi.newpipe.fragments.detail.TabAdapter; import org.schabi.newpipe.ktx.AnimationType; -import org.schabi.newpipe.local.subscription.SubscriptionManager; import org.schabi.newpipe.local.feed.notifications.NotificationHelper; -import org.schabi.newpipe.player.PlayerType; -import org.schabi.newpipe.player.playqueue.ChannelPlayQueue; -import org.schabi.newpipe.player.playqueue.PlayQueue; +import org.schabi.newpipe.local.subscription.SubscriptionManager; +import org.schabi.newpipe.util.ChannelTabHelper; +import org.schabi.newpipe.util.Constants; import org.schabi.newpipe.util.ExtractorHelper; import org.schabi.newpipe.util.Localization; import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.PicassoHelper; +import org.schabi.newpipe.util.StateSaver; import org.schabi.newpipe.util.ThemeHelper; import org.schabi.newpipe.util.external_communication.ShareUtils; import java.util.List; +import java.util.Queue; import java.util.concurrent.TimeUnit; -import java.util.function.Supplier; -import java.util.stream.Collectors; +import icepick.State; import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; import io.reactivex.rxjava3.core.Observable; -import io.reactivex.rxjava3.core.Single; import io.reactivex.rxjava3.disposables.CompositeDisposable; import io.reactivex.rxjava3.disposables.Disposable; import io.reactivex.rxjava3.functions.Action; @@ -68,29 +68,37 @@ import io.reactivex.rxjava3.functions.Function; import io.reactivex.rxjava3.schedulers.Schedulers; -public class ChannelFragment extends BaseListInfoFragment - implements View.OnClickListener { +public class ChannelFragment extends BaseStateFragment + implements StateSaver.WriteRead { private static final int BUTTON_DEBOUNCE_INTERVAL = 100; private static final String PICASSO_CHANNEL_TAG = "PICASSO_CHANNEL_TAG"; + @State + protected int serviceId = Constants.NO_SERVICE_ID; + @State + protected String name; + @State + protected String url; + + private ChannelInfo currentInfo; + private Disposable currentWorker; private final CompositeDisposable disposables = new CompositeDisposable(); private Disposable subscribeButtonMonitor; - + private SubscriptionManager subscriptionManager; + private int lastTab; private boolean channelContentNotSupported = false; /*////////////////////////////////////////////////////////////////////////// // Views //////////////////////////////////////////////////////////////////////////*/ - private SubscriptionManager subscriptionManager; - - private FragmentChannelBinding channelBinding; - private ChannelHeaderBinding headerBinding; - private PlaylistControlBinding playlistControlBinding; + private FragmentChannelBinding binding; + private TabAdapter tabAdapter; private MenuItem menuRssButton; private MenuItem menuNotifyButton; + private SubscriptionEntity channelSubscription; public static ChannelFragment getInstance(final int serviceId, final String url, final String name) { @@ -99,22 +107,23 @@ public static ChannelFragment getInstance(final int serviceId, final String url, return instance; } - public ChannelFragment() { - super(UserAction.REQUESTED_CHANNEL); + private void setInitialData(final int sid, final String u, final String title) { + this.serviceId = sid; + this.url = u; + this.name = !TextUtils.isEmpty(title) ? title : ""; } - @Override - public void onResume() { - super.onResume(); - if (activity != null && useAsFrontPage) { - setTitle(currentInfo != null ? currentInfo.getName() : name); - } - } /*////////////////////////////////////////////////////////////////////////// // LifeCycle //////////////////////////////////////////////////////////////////////////*/ + @Override + public void onCreate(final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setHasOptionsMenu(true); + } + @Override public void onAttach(@NonNull final Context context) { super.onAttach(context); @@ -125,49 +134,58 @@ public void onAttach(@NonNull final Context context) { public View onCreateView(@NonNull final LayoutInflater inflater, @Nullable final ViewGroup container, @Nullable final Bundle savedInstanceState) { - return inflater.inflate(R.layout.fragment_channel, container, false); + binding = FragmentChannelBinding.inflate(inflater, container, false); + return binding.getRoot(); } - @Override - public void onViewCreated(@NonNull final View rootView, final Bundle savedInstanceState) { - super.onViewCreated(rootView, savedInstanceState); - channelBinding = FragmentChannelBinding.bind(rootView); - showContentNotSupportedIfNeeded(); - } + @Override // called from onViewCreated in BaseFragment.onViewCreated + protected void initViews(final View rootView, final Bundle savedInstanceState) { + super.initViews(rootView, savedInstanceState); - @Override - public void onDestroy() { - super.onDestroy(); - disposables.clear(); - if (subscribeButtonMonitor != null) { - subscribeButtonMonitor.dispose(); + tabAdapter = new TabAdapter(getChildFragmentManager()); + binding.viewPager.setAdapter(tabAdapter); + binding.tabLayout.setupWithViewPager(binding.viewPager); + + setTitle(name); + binding.channelTitleView.setText(name); + if (!PicassoHelper.getShouldLoadImages()) { + // do not waste space for the banner if it is not going to be loaded + binding.channelBannerImage.setImageDrawable(null); } - channelBinding = null; - headerBinding = null; - playlistControlBinding = null; } - /*////////////////////////////////////////////////////////////////////////// - // Init - //////////////////////////////////////////////////////////////////////////*/ - @Override - protected Supplier getListHeaderSupplier() { - headerBinding = ChannelHeaderBinding - .inflate(activity.getLayoutInflater(), itemsList, false); - playlistControlBinding = headerBinding.playlistControl; + protected void initListeners() { + super.initListeners(); - return headerBinding::getRoot; + final View.OnClickListener openSubChannel = v -> { + if (!TextUtils.isEmpty(currentInfo.getParentChannelUrl())) { + try { + NavigationHelper.openChannelFragment(getFM(), currentInfo.getServiceId(), + currentInfo.getParentChannelUrl(), + currentInfo.getParentChannelName()); + } catch (final Exception e) { + ErrorUtil.showUiErrorSnackbar(this, "Opening channel fragment", e); + } + } else if (DEBUG) { + Log.i(TAG, "Can't open parent channel because we got no channel URL"); + } + }; + binding.subChannelAvatarView.setOnClickListener(openSubChannel); + binding.subChannelTitleView.setOnClickListener(openSubChannel); } @Override - protected void initListeners() { - super.initListeners(); - - headerBinding.subChannelTitleView.setOnClickListener(this); - headerBinding.subChannelAvatarView.setOnClickListener(this); + public void onDestroy() { + super.onDestroy(); + if (currentWorker != null) { + currentWorker.dispose(); + } + disposables.clear(); + binding = null; } + /*////////////////////////////////////////////////////////////////////////// // Menu //////////////////////////////////////////////////////////////////////////*/ @@ -176,32 +194,33 @@ protected void initListeners() { public void onCreateOptionsMenu(@NonNull final Menu menu, @NonNull final MenuInflater inflater) { super.onCreateOptionsMenu(menu, inflater); - final ActionBar supportActionBar = activity.getSupportActionBar(); - if (useAsFrontPage && supportActionBar != null) { - supportActionBar.setDisplayHomeAsUpEnabled(false); - } else { - inflater.inflate(R.menu.menu_channel, menu); + inflater.inflate(R.menu.menu_channel, menu); - if (DEBUG) { - Log.d(TAG, "onCreateOptionsMenu() called with: " - + "menu = [" + menu + "], inflater = [" + inflater + "]"); - } - menuRssButton = menu.findItem(R.id.menu_item_rss); - menuNotifyButton = menu.findItem(R.id.menu_item_notify); + if (DEBUG) { + Log.d(TAG, "onCreateOptionsMenu() called with: " + + "menu = [" + menu + "], inflater = [" + inflater + "]"); } } @Override - public boolean onOptionsItemSelected(final MenuItem item) { + public void onPrepareOptionsMenu(@NonNull final Menu menu) { + super.onPrepareOptionsMenu(menu); + menuRssButton = menu.findItem(R.id.menu_item_rss); + menuNotifyButton = menu.findItem(R.id.menu_item_notify); + updateNotifyButton(channelSubscription); + } + + @Override + public boolean onOptionsItemSelected(@NonNull final MenuItem item) { switch (item.getItemId()) { - case R.id.action_settings: - NavigationHelper.openSettings(requireContext()); - break; case R.id.menu_item_notify: final boolean value = !item.isChecked(); item.setEnabled(false); setNotify(value); break; + case R.id.action_settings: + NavigationHelper.openSettings(requireContext()); + break; case R.id.menu_item_rss: if (currentInfo != null) { ShareUtils.openUrlInApp(requireContext(), currentInfo.getFeedUrl()); @@ -224,13 +243,14 @@ public boolean onOptionsItemSelected(final MenuItem item) { return true; } + /*////////////////////////////////////////////////////////////////////////// // Channel Subscription //////////////////////////////////////////////////////////////////////////*/ private void monitorSubscription(final ChannelInfo info) { final Consumer onError = (Throwable throwable) -> { - animate(headerBinding.channelSubscribeButton, false, 100); + animate(binding.channelSubscribeButton, false, 100); showSnackBarError(new ErrorInfo(throwable, UserAction.SUBSCRIPTION_GET, "Get subscription status", currentInfo)); }; @@ -263,10 +283,9 @@ private void monitorSubscription(final ChannelInfo info) { }, onError)); } - private Function mapOnSubscribe(final SubscriptionEntity subscription, - final ChannelInfo info) { + private Function mapOnSubscribe(final SubscriptionEntity subscription) { return (@NonNull Object o) -> { - subscriptionManager.insertSubscription(subscription, info); + subscriptionManager.insertSubscription(subscription); return o; }; } @@ -298,8 +317,7 @@ private void updateSubscription(final ChannelInfo info) { .subscribe(onComplete, onError)); } - private Disposable monitorSubscribeButton(final Button subscribeButton, - final Function action) { + private Disposable monitorSubscribeButton(final Function action) { final Consumer onNext = (@NonNull Object o) -> { if (DEBUG) { Log.d(TAG, "Changed subscription status to this channel!"); @@ -311,7 +329,7 @@ private Disposable monitorSubscribeButton(final Button subscribeButton, "Changing subscription for " + currentInfo.getUrl(), currentInfo)); /* Emit clicks from main thread unto io thread */ - return RxView.clicks(subscribeButton) + return RxView.clicks(binding.channelSubscribeButton) .subscribeOn(AndroidSchedulers.mainThread()) .observeOn(Schedulers.io()) .debounce(BUTTON_DEBOUNCE_INTERVAL, TimeUnit.MILLISECONDS) // Ignore rapid clicks @@ -340,17 +358,17 @@ private Consumer> getSubscribeUpdateMonitor(final Chann info.getAvatarUrl(), info.getDescription(), info.getSubscriberCount()); + channelSubscription = null; updateNotifyButton(null); - subscribeButtonMonitor = monitorSubscribeButton( - headerBinding.channelSubscribeButton, mapOnSubscribe(channel, info)); + subscribeButtonMonitor = monitorSubscribeButton(mapOnSubscribe(channel)); } else { if (DEBUG) { Log.d(TAG, "Found subscription to this channel!"); } - final SubscriptionEntity subscription = subscriptionEntities.get(0); - updateNotifyButton(subscription); - subscribeButtonMonitor = monitorSubscribeButton( - headerBinding.channelSubscribeButton, mapOnUnsubscribe(subscription)); + channelSubscription = subscriptionEntities.get(0); + updateNotifyButton(channelSubscription); + subscribeButtonMonitor = + monitorSubscribeButton(mapOnUnsubscribe(channelSubscription)); } }; } @@ -361,34 +379,33 @@ private void updateSubscribeButton(final boolean isSubscribed) { + "isSubscribed = [" + isSubscribed + "]"); } - final boolean isButtonVisible = headerBinding.channelSubscribeButton.getVisibility() + final boolean isButtonVisible = binding.channelSubscribeButton.getVisibility() == View.VISIBLE; final int backgroundDuration = isButtonVisible ? 300 : 0; final int textDuration = isButtonVisible ? 200 : 0; - final int subscribeBackground = ThemeHelper - .resolveColorFromAttr(activity, R.attr.colorPrimary); - final int subscribeText = ContextCompat.getColor(activity, R.color.subscribe_text_color); final int subscribedBackground = ContextCompat .getColor(activity, R.color.subscribed_background_color); final int subscribedText = ContextCompat.getColor(activity, R.color.subscribed_text_color); + final int subscribeBackground = ColorUtils.blendARGB(ThemeHelper + .resolveColorFromAttr(activity, R.attr.colorPrimary), subscribedBackground, 0.35f); + final int subscribeText = ContextCompat.getColor(activity, R.color.subscribe_text_color); - if (!isSubscribed) { - headerBinding.channelSubscribeButton.setText(R.string.subscribe_button_title); - animateBackgroundColor(headerBinding.channelSubscribeButton, backgroundDuration, - subscribedBackground, subscribeBackground); - animateTextColor(headerBinding.channelSubscribeButton, textDuration, subscribedText, - subscribeText); - } else { - headerBinding.channelSubscribeButton.setText(R.string.subscribed_button_title); - animateBackgroundColor(headerBinding.channelSubscribeButton, backgroundDuration, + if (isSubscribed) { + binding.channelSubscribeButton.setText(R.string.subscribed_button_title); + animateBackgroundColor(binding.channelSubscribeButton, backgroundDuration, subscribeBackground, subscribedBackground); - animateTextColor(headerBinding.channelSubscribeButton, textDuration, subscribeText, + animateTextColor(binding.channelSubscribeButton, textDuration, subscribeText, subscribedText); + } else { + binding.channelSubscribeButton.setText(R.string.subscribe_button_title); + animateBackgroundColor(binding.channelSubscribeButton, backgroundDuration, + subscribedBackground, subscribeBackground); + animateTextColor(binding.channelSubscribeButton, textDuration, subscribedText, + subscribeText); } - animate(headerBinding.channelSubscribeButton, true, 100, - AnimationType.LIGHT_SCALE_AND_ALPHA); + animate(binding.channelSubscribeButton, true, 100, AnimationType.LIGHT_SCALE_AND_ALPHA); } private void updateNotifyButton(@Nullable final SubscriptionEntity subscription) { @@ -424,108 +441,179 @@ private void setNotify(final boolean isEnabled) { * Show a snackbar with the option to enable notifications on new streams for this channel. */ private void showNotifySnackbar() { - Snackbar.make(itemsList, R.string.you_successfully_subscribed, Snackbar.LENGTH_LONG) + Snackbar.make(binding.getRoot(), R.string.you_successfully_subscribed, Snackbar.LENGTH_LONG) .setAction(R.string.get_notified, v -> setNotify(true)) .setActionTextColor(Color.YELLOW) .show(); } + /*////////////////////////////////////////////////////////////////////////// - // Load and handle + // Init + //////////////////////////////////////////////////////////////////////////*/ + + private void updateTabs() { + tabAdapter.clearAllItems(); + + if (currentInfo != null && !channelContentNotSupported) { + final Context context = requireContext(); + final SharedPreferences preferences = PreferenceManager + .getDefaultSharedPreferences(context); + + for (final ListLinkHandler linkHandler : currentInfo.getTabs()) { + final String tab = linkHandler.getContentFilters().get(0); + if (ChannelTabHelper.showChannelTab(context, preferences, tab)) { + final ChannelTabFragment channelTabFragment = + ChannelTabFragment.getInstance(serviceId, linkHandler, name); + channelTabFragment.useAsFrontPage(useAsFrontPage); + tabAdapter.addFragment(channelTabFragment, + context.getString(ChannelTabHelper.getTranslationKey(tab))); + } + } + + if (ChannelTabHelper.showChannelTab( + context, preferences, R.string.show_channel_tabs_about)) { + tabAdapter.addFragment( + ChannelAboutFragment.getInstance(currentInfo), + context.getString(R.string.channel_tab_about)); + } + } + + tabAdapter.notifyDataSetUpdate(); + + for (int i = 0; i < tabAdapter.getCount(); i++) { + binding.tabLayout.getTabAt(i).setText(tabAdapter.getItemTitle(i)); + } + + // Restore previously selected tab + final TabLayout.Tab ltab = binding.tabLayout.getTabAt(lastTab); + if (ltab != null) { + binding.tabLayout.selectTab(ltab); + } + } + + + /*////////////////////////////////////////////////////////////////////////// + // State Saving //////////////////////////////////////////////////////////////////////////*/ @Override - protected Single> loadMoreItemsLogic() { - return ExtractorHelper.getMoreChannelItems(serviceId, url, currentNextPage); + public String generateSuffix() { + return null; } @Override - protected Single loadResult(final boolean forceLoad) { - return ExtractorHelper.getChannelInfo(serviceId, url, forceLoad); + public void writeTo(final Queue objectsToSave) { + objectsToSave.add(currentInfo); + objectsToSave.add(binding == null ? 0 : binding.tabLayout.getSelectedTabPosition()); } - /*////////////////////////////////////////////////////////////////////////// - // OnClick - //////////////////////////////////////////////////////////////////////////*/ + @Override + public void readFrom(@NonNull final Queue savedObjects) { + currentInfo = (ChannelInfo) savedObjects.poll(); + lastTab = (Integer) savedObjects.poll(); + } @Override - public void onClick(final View v) { - if (isLoading.get() || currentInfo == null) { - return; + public void onSaveInstanceState(final @NonNull Bundle outState) { + super.onSaveInstanceState(outState); + if (binding != null) { + outState.putInt("LastTab", binding.tabLayout.getSelectedTabPosition()); } + } - switch (v.getId()) { - case R.id.sub_channel_avatar_view: - case R.id.sub_channel_title_view: - if (!TextUtils.isEmpty(currentInfo.getParentChannelUrl())) { - try { - NavigationHelper.openChannelFragment(getFM(), currentInfo.getServiceId(), - currentInfo.getParentChannelUrl(), - currentInfo.getParentChannelName()); - } catch (final Exception e) { - ErrorUtil.showUiErrorSnackbar(this, "Opening channel fragment", e); - } - } else if (DEBUG) { - Log.i(TAG, "Can't open parent channel because we got no channel URL"); - } - break; - } + @Override + protected void onRestoreInstanceState(@NonNull final Bundle savedInstanceState) { + super.onRestoreInstanceState(savedInstanceState); + lastTab = savedInstanceState.getInt("LastTab", 0); } + /*////////////////////////////////////////////////////////////////////////// // Contract //////////////////////////////////////////////////////////////////////////*/ + @Override + protected void doInitialLoadLogic() { + if (currentInfo == null) { + startLoading(false); + } else { + handleResult(currentInfo); + } + } + + @Override + public void startLoading(final boolean forceLoad) { + super.startLoading(forceLoad); + + currentInfo = null; + updateTabs(); + if (currentWorker != null) { + currentWorker.dispose(); + } + + runWorker(forceLoad); + } + + private void runWorker(final boolean forceLoad) { + currentWorker = ExtractorHelper.getChannelInfo(serviceId, url, forceLoad) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(result -> { + isLoading.set(false); + handleResult(result); + }, throwable -> showError(new ErrorInfo(throwable, UserAction.REQUESTED_CHANNEL, + url == null ? "No URL" : url, serviceId))); + } + @Override public void showLoading() { super.showLoading(); PicassoHelper.cancelTag(PICASSO_CHANNEL_TAG); - animate(headerBinding.channelSubscribeButton, false, 100); + animate(binding.channelSubscribeButton, false, 100); } @Override public void handleResult(@NonNull final ChannelInfo result) { super.handleResult(result); + currentInfo = result; + setInitialData(result.getServiceId(), result.getOriginalUrl(), result.getName()); + + if (PicassoHelper.getShouldLoadImages() && !isBlank(result.getBannerUrl())) { + PicassoHelper.loadBanner(result.getBannerUrl()).tag(PICASSO_CHANNEL_TAG) + .into(binding.channelBannerImage); + } else { + // do not waste space for the banner, if the user disabled images or there is not one + binding.channelBannerImage.setImageDrawable(null); + } - headerBinding.getRoot().setVisibility(View.VISIBLE); - PicassoHelper.loadBanner(result.getBannerUrl()).tag(PICASSO_CHANNEL_TAG) - .into(headerBinding.channelBannerImage); PicassoHelper.loadAvatar(result.getAvatarUrl()).tag(PICASSO_CHANNEL_TAG) - .into(headerBinding.channelAvatarView); + .into(binding.channelAvatarView); PicassoHelper.loadAvatar(result.getParentChannelAvatarUrl()).tag(PICASSO_CHANNEL_TAG) - .into(headerBinding.subChannelAvatarView); + .into(binding.subChannelAvatarView); - headerBinding.channelSubscriberView.setVisibility(View.VISIBLE); + binding.channelTitleView.setText(result.getName()); + binding.channelSubscriberView.setVisibility(View.VISIBLE); if (result.getSubscriberCount() >= 0) { - headerBinding.channelSubscriberView.setText(Localization + binding.channelSubscriberView.setText(Localization .shortSubscriberCount(activity, result.getSubscriberCount())); } else { - headerBinding.channelSubscriberView.setText(R.string.subscribers_count_not_available); + binding.channelSubscriberView.setText(R.string.subscribers_count_not_available); } if (!TextUtils.isEmpty(currentInfo.getParentChannelName())) { - headerBinding.subChannelTitleView.setText(String.format( + binding.subChannelTitleView.setText(String.format( getString(R.string.channel_created_by), currentInfo.getParentChannelName()) ); - headerBinding.subChannelTitleView.setVisibility(View.VISIBLE); - headerBinding.subChannelAvatarView.setVisibility(View.VISIBLE); - } else { - headerBinding.subChannelTitleView.setVisibility(View.GONE); + binding.subChannelTitleView.setVisibility(View.VISIBLE); + binding.subChannelAvatarView.setVisibility(View.VISIBLE); } if (menuRssButton != null) { menuRssButton.setVisible(!TextUtils.isEmpty(result.getFeedUrl())); } - // PlaylistControls should be visible only if there is some item in - // infoListAdapter other than header - if (infoListAdapter.getItemCount() != 1) { - playlistControlBinding.getRoot().setVisibility(View.VISIBLE); - } else { - playlistControlBinding.getRoot().setVisibility(View.GONE); - } - channelContentNotSupported = false; for (final Throwable throwable : result.getErrors()) { if (throwable instanceof ContentNotSupportedException) { @@ -539,62 +627,21 @@ public void handleResult(@NonNull final ChannelInfo result) { if (subscribeButtonMonitor != null) { subscribeButtonMonitor.dispose(); } + + updateTabs(); updateSubscription(result); monitorSubscription(result); - - playlistControlBinding.playlistCtrlPlayAllButton - .setOnClickListener(view -> NavigationHelper - .playOnMainPlayer(activity, getPlayQueue())); - playlistControlBinding.playlistCtrlPlayPopupButton - .setOnClickListener(view -> NavigationHelper - .playOnPopupPlayer(activity, getPlayQueue(), false)); - playlistControlBinding.playlistCtrlPlayBgButton - .setOnClickListener(view -> NavigationHelper - .playOnBackgroundPlayer(activity, getPlayQueue(), false)); - - playlistControlBinding.playlistCtrlPlayPopupButton.setOnLongClickListener(view -> { - NavigationHelper.enqueueOnPlayer(activity, getPlayQueue(), PlayerType.POPUP); - return true; - }); - - playlistControlBinding.playlistCtrlPlayBgButton.setOnLongClickListener(view -> { - NavigationHelper.enqueueOnPlayer(activity, getPlayQueue(), PlayerType.AUDIO); - return true; - }); } private void showContentNotSupportedIfNeeded() { // channelBinding might not be initialized when handleResult() is called // (e.g. after rotating the screen, #6696) - if (!channelContentNotSupported || channelBinding == null) { + if (!channelContentNotSupported || binding == null) { return; } - channelBinding.errorContentNotSupported.setVisibility(View.VISIBLE); - channelBinding.channelKaomoji.setText("(︶︹︺)"); - channelBinding.channelKaomoji.setTextSize(TypedValue.COMPLEX_UNIT_SP, 45f); - channelBinding.emptyStateMessage.setVisibility(View.GONE); - } - - private PlayQueue getPlayQueue() { - final List streamItems = infoListAdapter.getItemsList().stream() - .filter(StreamInfoItem.class::isInstance) - .map(StreamInfoItem.class::cast) - .collect(Collectors.toList()); - - return new ChannelPlayQueue(currentInfo.getServiceId(), currentInfo.getUrl(), - currentInfo.getNextPage(), streamItems, 0); - } - - /*////////////////////////////////////////////////////////////////////////// - // Utils - //////////////////////////////////////////////////////////////////////////*/ - - @Override - public void setTitle(final String title) { - super.setTitle(title); - if (!useAsFrontPage) { - headerBinding.channelTitleView.setText(title); - } + binding.errorContentNotSupported.setVisibility(View.VISIBLE); + binding.channelKaomoji.setText("(︶︹︺)"); + binding.channelKaomoji.setTextSize(TypedValue.COMPLEX_UNIT_SP, 45f); } } diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelTabFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelTabFragment.java new file mode 100644 index 00000000000..8712ab4d914 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelTabFragment.java @@ -0,0 +1,140 @@ +package org.schabi.newpipe.fragments.list.channel; + +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.schabi.newpipe.R; +import org.schabi.newpipe.databinding.PlaylistControlBinding; +import org.schabi.newpipe.error.UserAction; +import org.schabi.newpipe.extractor.InfoItem; +import org.schabi.newpipe.extractor.ListExtractor; +import org.schabi.newpipe.extractor.channel.tabs.ChannelTabInfo; +import org.schabi.newpipe.extractor.linkhandler.ListLinkHandler; +import org.schabi.newpipe.extractor.stream.StreamInfoItem; +import org.schabi.newpipe.fragments.list.BaseListInfoFragment; +import org.schabi.newpipe.fragments.list.playlist.PlaylistControlViewHolder; +import org.schabi.newpipe.player.playqueue.ChannelTabPlayQueue; +import org.schabi.newpipe.player.playqueue.PlayQueue; +import org.schabi.newpipe.util.ChannelTabHelper; +import org.schabi.newpipe.util.ExtractorHelper; +import org.schabi.newpipe.util.PlayButtonHelper; + +import java.util.List; +import java.util.function.Supplier; +import java.util.stream.Collectors; + +import icepick.State; +import io.reactivex.rxjava3.core.Single; + +public class ChannelTabFragment extends BaseListInfoFragment + implements PlaylistControlViewHolder { + + // states must be protected and not private for IcePick being able to access them + @State + protected ListLinkHandler tabHandler; + @State + protected String channelName; + + private PlaylistControlBinding playlistControlBinding; + + @NonNull + public static ChannelTabFragment getInstance(final int serviceId, + final ListLinkHandler tabHandler, + final String channelName) { + final ChannelTabFragment instance = new ChannelTabFragment(); + instance.serviceId = serviceId; + instance.tabHandler = tabHandler; + instance.channelName = channelName; + return instance; + } + + public ChannelTabFragment() { + super(UserAction.REQUESTED_CHANNEL); + } + + /*////////////////////////////////////////////////////////////////////////// + // LifeCycle + //////////////////////////////////////////////////////////////////////////*/ + + @Override + public void onCreate(final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setHasOptionsMenu(false); + } + + @Override + public View onCreateView(@NonNull final LayoutInflater inflater, + @Nullable final ViewGroup container, + @Nullable final Bundle savedInstanceState) { + return inflater.inflate(R.layout.fragment_channel_tab, container, false); + } + + @Override + public void onDestroyView() { + super.onDestroyView(); + playlistControlBinding = null; + } + + @Override + protected Supplier getListHeaderSupplier() { + if (ChannelTabHelper.isStreamsTab(tabHandler)) { + playlistControlBinding = PlaylistControlBinding + .inflate(activity.getLayoutInflater(), itemsList, false); + return playlistControlBinding::getRoot; + } + return null; + } + + @Override + protected Single loadResult(final boolean forceLoad) { + return ExtractorHelper.getChannelTab(serviceId, tabHandler, forceLoad); + } + + @Override + protected Single> loadMoreItemsLogic() { + return ExtractorHelper.getMoreChannelTabItems(serviceId, tabHandler, currentNextPage); + } + + @Override + public void setTitle(final String title) { + // The channel name is displayed as title in the toolbar. + // The title is always a description of the content of the tab fragment. + // It should be unique for each channel because multiple channel tabs + // can be added to the main page. Therefore, the channel name is used. + // Using the title variable would cause the title to be the same for all channel tabs. + super.setTitle(channelName); + } + + @Override + public void handleResult(@NonNull final ChannelTabInfo result) { + super.handleResult(result); + + if (playlistControlBinding != null) { + // PlaylistControls should be visible only if there is some item in + // infoListAdapter other than header + if (infoListAdapter.getItemCount() > 1) { + playlistControlBinding.getRoot().setVisibility(View.VISIBLE); + } else { + playlistControlBinding.getRoot().setVisibility(View.GONE); + } + + PlayButtonHelper.initPlaylistControlClickListener( + activity, playlistControlBinding, this); + } + } + + public PlayQueue getPlayQueue() { + final List streamItems = infoListAdapter.getItemsList().stream() + .filter(StreamInfoItem.class::isInstance) + .map(StreamInfoItem.class::cast) + .collect(Collectors.toList()); + + return new ChannelTabPlayQueue(currentInfo.getServiceId(), tabHandler, + currentInfo.getNextPage(), streamItems, 0); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/playlist/PlaylistControlViewHolder.java b/app/src/main/java/org/schabi/newpipe/fragments/list/playlist/PlaylistControlViewHolder.java new file mode 100644 index 00000000000..e4705bb7188 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/playlist/PlaylistControlViewHolder.java @@ -0,0 +1,11 @@ +package org.schabi.newpipe.fragments.list.playlist; + +import org.schabi.newpipe.player.playqueue.PlayQueue; + +/** + * Interface for {@code R.layout.playlist_control} view holders + * to give access to the play queue. + */ +public interface PlaylistControlViewHolder { + PlayQueue getPlayQueue(); +} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/playlist/PlaylistFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/playlist/PlaylistFragment.java index 8dd77bed65a..2b7cf9446fb 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/playlist/PlaylistFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/playlist/PlaylistFragment.java @@ -43,7 +43,6 @@ import org.schabi.newpipe.info_list.dialog.StreamDialogDefaultEntry; import org.schabi.newpipe.local.dialog.PlaylistDialog; import org.schabi.newpipe.local.playlist.RemotePlaylistManager; -import org.schabi.newpipe.player.PlayerType; import org.schabi.newpipe.player.playqueue.PlayQueue; import org.schabi.newpipe.player.playqueue.PlaylistPlayQueue; import org.schabi.newpipe.util.ExtractorHelper; @@ -51,6 +50,7 @@ import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.PicassoHelper; import org.schabi.newpipe.util.external_communication.ShareUtils; +import org.schabi.newpipe.util.PlayButtonHelper; import java.util.ArrayList; import java.util.List; @@ -64,7 +64,8 @@ import io.reactivex.rxjava3.disposables.CompositeDisposable; import io.reactivex.rxjava3.disposables.Disposable; -public class PlaylistFragment extends BaseListInfoFragment { +public class PlaylistFragment extends BaseListInfoFragment + implements PlaylistControlViewHolder { private static final String PICASSO_PLAYLIST_TAG = "PICASSO_PLAYLIST_TAG"; @@ -332,25 +333,10 @@ public void handleResult(@NonNull final PlaylistInfo result) { .observeOn(AndroidSchedulers.mainThread()) .subscribe(getPlaylistBookmarkSubscriber()); - playlistControlBinding.playlistCtrlPlayAllButton.setOnClickListener(view -> - NavigationHelper.playOnMainPlayer(activity, getPlayQueue())); - playlistControlBinding.playlistCtrlPlayPopupButton.setOnClickListener(view -> - NavigationHelper.playOnPopupPlayer(activity, getPlayQueue(), false)); - playlistControlBinding.playlistCtrlPlayBgButton.setOnClickListener(view -> - NavigationHelper.playOnBackgroundPlayer(activity, getPlayQueue(), false)); - - playlistControlBinding.playlistCtrlPlayPopupButton.setOnLongClickListener(view -> { - NavigationHelper.enqueueOnPlayer(activity, getPlayQueue(), PlayerType.POPUP); - return true; - }); - - playlistControlBinding.playlistCtrlPlayBgButton.setOnLongClickListener(view -> { - NavigationHelper.enqueueOnPlayer(activity, getPlayQueue(), PlayerType.AUDIO); - return true; - }); + PlayButtonHelper.initPlaylistControlClickListener(activity, playlistControlBinding, this); } - private PlayQueue getPlayQueue() { + public PlayQueue getPlayQueue() { return getPlayQueue(0); } diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/FeedFragment.kt b/app/src/main/java/org/schabi/newpipe/local/feed/FeedFragment.kt index ea9a3f36e31..4f153dcf875 100644 --- a/app/src/main/java/org/schabi/newpipe/local/feed/FeedFragment.kt +++ b/app/src/main/java/org/schabi/newpipe/local/feed/FeedFragment.kt @@ -60,6 +60,7 @@ import org.schabi.newpipe.database.feed.model.FeedGroupEntity import org.schabi.newpipe.database.subscription.SubscriptionEntity import org.schabi.newpipe.databinding.FragmentFeedBinding import org.schabi.newpipe.error.ErrorInfo +import org.schabi.newpipe.error.ErrorUtil import org.schabi.newpipe.error.UserAction import org.schabi.newpipe.extractor.exceptions.AccountTerminatedException import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException @@ -453,24 +454,33 @@ class FeedFragment : BaseStateFragment() { if (t is FeedLoadService.RequestException && t.cause is ContentNotAvailableException ) { - Single.fromCallable { - NewPipeDatabase.getInstance(requireContext()).subscriptionDAO() - .getSubscription(t.subscriptionId) - }.subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe( - { subscriptionEntity -> - handleFeedNotAvailable( - subscriptionEntity, - t.cause, - errors.subList(i + 1, errors.size) - ) - }, - { throwable -> Log.e(TAG, "Unable to process", throwable) } - ) - return // this will be called on the remaining errors by handleFeedNotAvailable() + disposables.add( + Single.fromCallable { + NewPipeDatabase.getInstance(requireContext()).subscriptionDAO() + .getSubscription(t.subscriptionId) + } + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { subscriptionEntity -> + handleFeedNotAvailable( + subscriptionEntity, + t.cause, + errors.subList(i + 1, errors.size) + ) + }, + { throwable -> Log.e(TAG, "Unable to process", throwable) } + ) + ) + // this will be called on the remaining errors by handleFeedNotAvailable() + return@handleItemsErrors } } + + if (errors.isNotEmpty()) { + // if no error was a ContentNotAvailableException, show a general error snackbar + ErrorUtil.showSnackbar(this, ErrorInfo(errors, UserAction.REQUESTED_FEED, "")) + } } private fun handleFeedNotAvailable( diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/FeedState.kt b/app/src/main/java/org/schabi/newpipe/local/feed/FeedState.kt index 27613e83e9c..665ebbe4396 100644 --- a/app/src/main/java/org/schabi/newpipe/local/feed/FeedState.kt +++ b/app/src/main/java/org/schabi/newpipe/local/feed/FeedState.kt @@ -13,9 +13,9 @@ sealed class FeedState { data class LoadedState( val items: List, - val oldestUpdate: OffsetDateTime? = null, + val oldestUpdate: OffsetDateTime?, val notLoadedCount: Long, - val itemsErrors: List = emptyList() + val itemsErrors: List ) : FeedState() data class ErrorState( diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/FeedViewModel.kt b/app/src/main/java/org/schabi/newpipe/local/feed/FeedViewModel.kt index 58f9e9edc28..728570b17e0 100644 --- a/app/src/main/java/org/schabi/newpipe/local/feed/FeedViewModel.kt +++ b/app/src/main/java/org/schabi/newpipe/local/feed/FeedViewModel.kt @@ -86,7 +86,7 @@ class FeedViewModel( .subscribe { (event, listFromDB, notLoadedCount, oldestUpdate) -> mutableStateLiveData.postValue( when (event) { - is IdleEvent -> FeedState.LoadedState(listFromDB.map { e -> StreamItem(e) }, oldestUpdate, notLoadedCount) + is IdleEvent -> FeedState.LoadedState(listFromDB.map { e -> StreamItem(e) }, oldestUpdate, notLoadedCount, listOf()) is ProgressEvent -> FeedState.ProgressState(event.currentProgress, event.maxProgress, event.progressMessage) is SuccessResultEvent -> FeedState.LoadedState(listFromDB.map { e -> StreamItem(e) }, oldestUpdate, notLoadedCount, event.itemsErrors) is ErrorResultEvent -> FeedState.ErrorState(event.error) diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/notifications/NotificationHelper.kt b/app/src/main/java/org/schabi/newpipe/local/feed/notifications/NotificationHelper.kt index 5aca3ad26a3..782f5ee47fa 100644 --- a/app/src/main/java/org/schabi/newpipe/local/feed/notifications/NotificationHelper.kt +++ b/app/src/main/java/org/schabi/newpipe/local/feed/notifications/NotificationHelper.kt @@ -58,7 +58,7 @@ class NotificationHelper(val context: Context) { .setAutoCancel(true) .setCategory(NotificationCompat.CATEGORY_SOCIAL) .setGroupSummary(true) - .setGroup(data.listInfo.url) + .setGroup(data.originalInfo.url) .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_SUMMARY) // Build a summary notification for Android versions < 7.0 @@ -73,7 +73,7 @@ class NotificationHelper(val context: Context) { context, data.pseudoId, NavigationHelper - .getChannelIntent(context, data.listInfo.serviceId, data.listInfo.url) + .getChannelIntent(context, data.originalInfo.serviceId, data.originalInfo.url) .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK), 0, false @@ -88,7 +88,7 @@ class NotificationHelper(val context: Context) { // Show individual stream notifications, set channel icon only if there is actually // one - showStreamNotifications(newStreams, data.listInfo.serviceId, bitmap) + showStreamNotifications(newStreams, data.originalInfo.serviceId, bitmap) // Show summary notification manager.notify(data.pseudoId, summaryBuilder.build()) @@ -97,7 +97,7 @@ class NotificationHelper(val context: Context) { override fun onBitmapFailed(e: Exception, errorDrawable: Drawable) { // Show individual stream notifications - showStreamNotifications(newStreams, data.listInfo.serviceId, null) + showStreamNotifications(newStreams, data.originalInfo.serviceId, null) // Show summary notification manager.notify(data.pseudoId, summaryBuilder.build()) iconLoadingTargets.remove(this) // allow it to be garbage-collected diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedLoadManager.kt b/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedLoadManager.kt index fec50a579a7..b86c856fc25 100644 --- a/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedLoadManager.kt +++ b/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedLoadManager.kt @@ -1,6 +1,7 @@ package org.schabi.newpipe.local.feed.service import android.content.Context +import android.content.SharedPreferences import androidx.preference.PreferenceManager import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.core.Completable @@ -13,11 +14,17 @@ import io.reactivex.rxjava3.schedulers.Schedulers import org.schabi.newpipe.R import org.schabi.newpipe.database.feed.model.FeedGroupEntity import org.schabi.newpipe.database.subscription.NotificationMode -import org.schabi.newpipe.extractor.ListInfo +import org.schabi.newpipe.database.subscription.SubscriptionEntity +import org.schabi.newpipe.extractor.Info +import org.schabi.newpipe.extractor.NewPipe +import org.schabi.newpipe.extractor.feed.FeedInfo import org.schabi.newpipe.extractor.stream.StreamInfoItem import org.schabi.newpipe.local.feed.FeedDatabaseManager import org.schabi.newpipe.local.subscription.SubscriptionManager -import org.schabi.newpipe.util.ExtractorHelper +import org.schabi.newpipe.util.ChannelTabHelper +import org.schabi.newpipe.util.ExtractorHelper.getChannelInfo +import org.schabi.newpipe.util.ExtractorHelper.getChannelTab +import org.schabi.newpipe.util.ExtractorHelper.getMoreChannelTabItems import java.time.OffsetDateTime import java.time.ZoneOffset import java.util.concurrent.atomic.AtomicBoolean @@ -75,7 +82,9 @@ class FeedLoadManager(private val context: Context) { * subscriptions which have not been updated within the feed updated threshold */ val outdatedSubscriptions = when (groupId) { - FeedGroupEntity.GROUP_ALL_ID -> feedDatabaseManager.outdatedSubscriptions(outdatedThreshold) + FeedGroupEntity.GROUP_ALL_ID -> feedDatabaseManager.outdatedSubscriptions( + outdatedThreshold + ) GROUP_NOTIFICATION_ENABLED -> feedDatabaseManager.outdatedSubscriptionsWithNotificationMode( outdatedThreshold, NotificationMode.ENABLED ) @@ -101,52 +110,7 @@ class FeedLoadManager(private val context: Context) { .runOn(Schedulers.io(), PARALLEL_EXTRACTIONS * 2) .filter { !cancelSignal.get() } .map { subscriptionEntity -> - var error: Throwable? = null - try { - // check for and load new streams - // either by using the dedicated feed method or by getting the channel info - val listInfo = if (useFeedExtractor) { - ExtractorHelper - .getFeedInfoFallbackToChannelInfo( - subscriptionEntity.serviceId, - subscriptionEntity.url - ) - .onErrorReturn { - error = it // store error, otherwise wrapped into RuntimeException - throw it - } - .blockingGet() - } else { - ExtractorHelper - .getChannelInfo( - subscriptionEntity.serviceId, - subscriptionEntity.url, - true - ) - .onErrorReturn { - error = it // store error, otherwise wrapped into RuntimeException - throw it - } - .blockingGet() - } as ListInfo - - return@map Notification.createOnNext( - FeedUpdateInfo( - subscriptionEntity, - listInfo - ) - ) - } catch (e: Throwable) { - if (error == null) { - // do this to prevent blockingGet() from wrapping into RuntimeException - error = e - } - - val request = "${subscriptionEntity.serviceId}:${subscriptionEntity.url}" - val wrapper = - FeedLoadService.RequestException(subscriptionEntity.uid, request, error!!) - return@map Notification.createOnError(wrapper) - } + loadStreams(subscriptionEntity, useFeedExtractor, defaultSharedPreferences) } .sequential() .observeOn(AndroidSchedulers.mainThread()) @@ -164,7 +128,112 @@ class FeedLoadManager(private val context: Context) { } private fun broadcastProgress() { - FeedEventManager.postEvent(FeedEventManager.Event.ProgressEvent(currentProgress.get(), maxProgress.get())) + FeedEventManager.postEvent( + FeedEventManager.Event.ProgressEvent( + currentProgress.get(), + maxProgress.get() + ) + ) + } + + private fun loadStreams( + subscriptionEntity: SubscriptionEntity, + useFeedExtractor: Boolean, + defaultSharedPreferences: SharedPreferences + ): Notification { + var error: Throwable? = null + val storeOriginalErrorAndRethrow = { e: Throwable -> + // keep original to prevent blockingGet() from wrapping it into RuntimeException + error = e + throw e + } + + try { + // check for and load new streams + // either by using the dedicated feed method or by getting the channel info + var originalInfo: Info? = null + var streams: List? = null + val errors = ArrayList() + + if (useFeedExtractor) { + NewPipe.getService(subscriptionEntity.serviceId) + .getFeedExtractor(subscriptionEntity.url) + ?.also { feedExtractor -> + // the user wants to use a feed extractor and there is one, use it + val feedInfo = FeedInfo.getInfo(feedExtractor) + errors.addAll(feedInfo.errors) + originalInfo = feedInfo + streams = feedInfo.relatedItems + } + } + + if (originalInfo == null) { + // use the normal channel tabs extractor if either the user wants it, or + // the current service does not have a dedicated feed extractor + + val channelInfo = getChannelInfo( + subscriptionEntity.serviceId, + subscriptionEntity.url, true + ) + .onErrorReturn(storeOriginalErrorAndRethrow) + .blockingGet() + errors.addAll(channelInfo.errors) + originalInfo = channelInfo + + streams = channelInfo.tabs + .filter { tab -> + ChannelTabHelper.fetchFeedChannelTab( + context, + defaultSharedPreferences, + tab + ) + } + .map { + Pair( + getChannelTab(subscriptionEntity.serviceId, it, true) + .onErrorReturn(storeOriginalErrorAndRethrow) + .blockingGet(), + it + ) + } + .flatMap { (channelTabInfo, linkHandler) -> + errors.addAll(channelTabInfo.errors) + if (channelTabInfo.relatedItems.isEmpty() && + channelTabInfo.nextPage != null + ) { + val infoItemsPage = getMoreChannelTabItems( + subscriptionEntity.serviceId, + linkHandler, channelTabInfo.nextPage + ) + .blockingGet() + + errors.addAll(infoItemsPage.errors) + return@flatMap infoItemsPage.items + } else { + return@flatMap channelTabInfo.relatedItems + } + } + .filterIsInstance() + } + + return Notification.createOnNext( + FeedUpdateInfo( + subscriptionEntity, + originalInfo!!, + streams!!, + errors, + ) + ) + } catch (e: Throwable) { + val request = "${subscriptionEntity.serviceId}:${subscriptionEntity.url}" + val wrapper = FeedLoadService.RequestException( + subscriptionEntity.uid, + request, + // do this to prevent blockingGet() from wrapping into RuntimeException + error ?: e + ) + return Notification.createOnError(wrapper) + } } /** @@ -203,24 +272,24 @@ class FeedLoadManager(private val context: Context) { for (notification in list) { when { notification.isOnNext -> { - val subscriptionId = notification.value!!.uid - val info = notification.value!!.listInfo + val info = notification.value!! - notification.value!!.newStreams = filterNewStreams( - notification.value!!.listInfo.relatedItems - ) + notification.value!!.newStreams = filterNewStreams(info.streams) - feedDatabaseManager.upsertAll(subscriptionId, info.relatedItems) - subscriptionManager.updateFromInfo(subscriptionId, info) + feedDatabaseManager.upsertAll(info.uid, info.streams) + subscriptionManager.updateFromInfo(info.uid, info.originalInfo) if (info.errors.isNotEmpty()) { feedResultsHolder.addErrors( - FeedLoadService.RequestException.wrapList( - subscriptionId, - info - ) + info.errors.map { + FeedLoadService.RequestException( + info.uid, + "${info.originalInfo.serviceId}:${info.originalInfo.url}", + it + ) + } ) - feedDatabaseManager.markAsOutdated(subscriptionId) + feedDatabaseManager.markAsOutdated(info.uid) } } notification.isOnError -> { diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedLoadService.kt b/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedLoadService.kt index bde301b9224..f960040de6b 100644 --- a/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedLoadService.kt +++ b/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedLoadService.kt @@ -39,8 +39,6 @@ import org.schabi.newpipe.App import org.schabi.newpipe.MainActivity.DEBUG import org.schabi.newpipe.R import org.schabi.newpipe.database.feed.model.FeedGroupEntity -import org.schabi.newpipe.extractor.ListInfo -import org.schabi.newpipe.extractor.stream.StreamInfoItem import org.schabi.newpipe.local.feed.service.FeedEventManager.Event.ErrorResultEvent import org.schabi.newpipe.local.feed.service.FeedEventManager.postEvent import java.util.concurrent.TimeUnit @@ -126,17 +124,7 @@ class FeedLoadService : Service() { // Loading & Handling // ///////////////////////////////////////////////////////////////////////// - class RequestException(val subscriptionId: Long, message: String, cause: Throwable) : Exception(message, cause) { - companion object { - fun wrapList(subscriptionId: Long, info: ListInfo): List { - val toReturn = ArrayList(info.errors.size) - info.errors.mapTo(toReturn) { - RequestException(subscriptionId, info.serviceId.toString() + ":" + info.url, it) - } - return toReturn - } - } - } + class RequestException(val subscriptionId: Long, message: String, cause: Throwable) : Exception(message, cause) // ///////////////////////////////////////////////////////////////////////// // Notification diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedUpdateInfo.kt b/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedUpdateInfo.kt index 5f72a6b842a..12fbe8d4120 100644 --- a/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedUpdateInfo.kt +++ b/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedUpdateInfo.kt @@ -2,7 +2,7 @@ package org.schabi.newpipe.local.feed.service import org.schabi.newpipe.database.subscription.NotificationMode import org.schabi.newpipe.database.subscription.SubscriptionEntity -import org.schabi.newpipe.extractor.ListInfo +import org.schabi.newpipe.extractor.Info import org.schabi.newpipe.extractor.stream.StreamInfoItem data class FeedUpdateInfo( @@ -11,24 +11,30 @@ data class FeedUpdateInfo( val notificationMode: Int, val name: String, val avatarUrl: String, - val listInfo: ListInfo, + val originalInfo: Info, + val streams: List, + val errors: List, ) { constructor( subscription: SubscriptionEntity, - listInfo: ListInfo, + originalInfo: Info, + streams: List, + errors: List, ) : this( uid = subscription.uid, notificationMode = subscription.notificationMode, name = subscription.name, avatarUrl = subscription.avatarUrl, - listInfo = listInfo, + originalInfo = originalInfo, + streams = streams, + errors = errors, ) /** * Integer id, can be used as notification id, etc. */ val pseudoId: Int - get() = listInfo.url.hashCode() + get() = originalInfo.url.hashCode() lateinit var newStreams: List } diff --git a/app/src/main/java/org/schabi/newpipe/local/history/StatisticsPlaylistFragment.java b/app/src/main/java/org/schabi/newpipe/local/history/StatisticsPlaylistFragment.java index a20a80ae985..1fea7e1559c 100644 --- a/app/src/main/java/org/schabi/newpipe/local/history/StatisticsPlaylistFragment.java +++ b/app/src/main/java/org/schabi/newpipe/local/history/StatisticsPlaylistFragment.java @@ -28,14 +28,16 @@ import org.schabi.newpipe.error.ErrorInfo; import org.schabi.newpipe.error.UserAction; import org.schabi.newpipe.extractor.stream.StreamInfoItem; +import org.schabi.newpipe.fragments.list.playlist.PlaylistControlViewHolder; import org.schabi.newpipe.info_list.dialog.InfoItemDialog; +import org.schabi.newpipe.info_list.dialog.StreamDialogDefaultEntry; import org.schabi.newpipe.local.BaseLocalListFragment; import org.schabi.newpipe.player.playqueue.PlayQueue; import org.schabi.newpipe.player.playqueue.SinglePlayQueue; import org.schabi.newpipe.settings.HistorySettingsFragment; import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.OnClickGesture; -import org.schabi.newpipe.info_list.dialog.StreamDialogDefaultEntry; +import org.schabi.newpipe.util.PlayButtonHelper; import java.util.ArrayList; import java.util.Collections; @@ -49,7 +51,8 @@ import io.reactivex.rxjava3.disposables.Disposable; public class StatisticsPlaylistFragment - extends BaseLocalListFragment, Void> { + extends BaseLocalListFragment, Void> + implements PlaylistControlViewHolder { private final CompositeDisposable disposables = new CompositeDisposable(); @State Parcelable itemsListState; @@ -195,14 +198,9 @@ public void onDestroyView() { if (itemListAdapter != null) { itemListAdapter.unsetSelectedListener(); } - if (playlistControlBinding != null) { - playlistControlBinding.playlistCtrlPlayBgButton.setOnClickListener(null); - playlistControlBinding.playlistCtrlPlayAllButton.setOnClickListener(null); - playlistControlBinding.playlistCtrlPlayPopupButton.setOnClickListener(null); - headerBinding = null; - playlistControlBinding = null; - } + headerBinding = null; + playlistControlBinding = null; if (databaseSubscription != null) { databaseSubscription.cancel(); @@ -276,12 +274,8 @@ public void handleResult(@NonNull final List result) { itemsListState = null; } - playlistControlBinding.playlistCtrlPlayAllButton.setOnClickListener(view -> - NavigationHelper.playOnMainPlayer(activity, getPlayQueue())); - playlistControlBinding.playlistCtrlPlayPopupButton.setOnClickListener(view -> - NavigationHelper.playOnPopupPlayer(activity, getPlayQueue(), false)); - playlistControlBinding.playlistCtrlPlayBgButton.setOnClickListener(view -> - NavigationHelper.playOnBackgroundPlayer(activity, getPlayQueue(), false)); + PlayButtonHelper.initPlaylistControlClickListener(activity, playlistControlBinding, this); + headerBinding.sortButton.setOnClickListener(view -> toggleSortMode()); hideLoading(); @@ -374,7 +368,7 @@ private void deleteEntry(final int index) { } } - private PlayQueue getPlayQueue() { + public PlayQueue getPlayQueue() { return getPlayQueue(0); } diff --git a/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistFragment.java b/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistFragment.java index 2a639a69f0d..0d8f8133440 100644 --- a/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistFragment.java +++ b/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistFragment.java @@ -22,7 +22,6 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.app.AlertDialog; -import androidx.preference.PreferenceManager; import androidx.recyclerview.widget.ItemTouchHelper; import androidx.recyclerview.widget.RecyclerView; import androidx.viewbinding.ViewBinding; @@ -42,17 +41,18 @@ import org.schabi.newpipe.error.ErrorInfo; import org.schabi.newpipe.error.UserAction; import org.schabi.newpipe.extractor.stream.StreamInfoItem; +import org.schabi.newpipe.fragments.list.playlist.PlaylistControlViewHolder; import org.schabi.newpipe.info_list.dialog.InfoItemDialog; import org.schabi.newpipe.info_list.dialog.StreamDialogDefaultEntry; import org.schabi.newpipe.local.BaseLocalListFragment; import org.schabi.newpipe.local.history.HistoryRecordManager; -import org.schabi.newpipe.player.PlayerType; import org.schabi.newpipe.player.playqueue.PlayQueue; import org.schabi.newpipe.player.playqueue.SinglePlayQueue; import org.schabi.newpipe.util.Localization; import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.OnClickGesture; import org.schabi.newpipe.util.external_communication.ShareUtils; +import org.schabi.newpipe.util.PlayButtonHelper; import java.util.ArrayList; import java.util.Collections; @@ -69,7 +69,8 @@ import io.reactivex.rxjava3.schedulers.Schedulers; import io.reactivex.rxjava3.subjects.PublishSubject; -public class LocalPlaylistFragment extends BaseLocalListFragment, Void> { +public class LocalPlaylistFragment extends BaseLocalListFragment, Void> + implements PlaylistControlViewHolder { // Save the list 10 seconds after the last change occurred private static final long SAVE_DEBOUNCE_MILLIS = 10000; private static final int MINIMUM_INITIAL_DRAG_VELOCITY = 12; @@ -265,14 +266,10 @@ public void onDestroyView() { if (itemListAdapter != null) { itemListAdapter.unsetSelectedListener(); } - if (playlistControlBinding != null) { - playlistControlBinding.playlistCtrlPlayBgButton.setOnClickListener(null); - playlistControlBinding.playlistCtrlPlayAllButton.setOnClickListener(null); - playlistControlBinding.playlistCtrlPlayPopupButton.setOnClickListener(null); - headerBinding = null; - playlistControlBinding = null; - } + headerBinding = null; + playlistControlBinding = null; + if (databaseSubscription != null) { databaseSubscription.cancel(); @@ -498,38 +495,11 @@ public void handleResult(@NonNull final List result) { } setVideoCount(itemListAdapter.getItemsList().size()); - playlistControlBinding.playlistCtrlPlayAllButton.setOnClickListener(view -> { - NavigationHelper.playOnMainPlayer(activity, getPlayQueue()); - showHoldToAppendTipIfNeeded(); - }); - playlistControlBinding.playlistCtrlPlayPopupButton.setOnClickListener(view -> { - NavigationHelper.playOnPopupPlayer(activity, getPlayQueue(), false); - showHoldToAppendTipIfNeeded(); - }); - playlistControlBinding.playlistCtrlPlayBgButton.setOnClickListener(view -> { - NavigationHelper.playOnBackgroundPlayer(activity, getPlayQueue(), false); - showHoldToAppendTipIfNeeded(); - }); - playlistControlBinding.playlistCtrlPlayPopupButton.setOnLongClickListener(view -> { - NavigationHelper.enqueueOnPlayer(activity, getPlayQueue(), PlayerType.POPUP); - return true; - }); - - playlistControlBinding.playlistCtrlPlayBgButton.setOnLongClickListener(view -> { - NavigationHelper.enqueueOnPlayer(activity, getPlayQueue(), PlayerType.AUDIO); - return true; - }); + PlayButtonHelper.initPlaylistControlClickListener(activity, playlistControlBinding, this); hideLoading(); } - private void showHoldToAppendTipIfNeeded() { - if (PreferenceManager.getDefaultSharedPreferences(activity) - .getBoolean(getString(R.string.show_hold_to_append_key), true)) { - Toast.makeText(activity, R.string.hold_to_append, Toast.LENGTH_SHORT).show(); - } - } - /////////////////////////////////////////////////////////////////////////// // Fragment Error Handling /////////////////////////////////////////////////////////////////////////// @@ -853,7 +823,7 @@ private void setVideoCount(final long count) { } } - private PlayQueue getPlayQueue() { + public PlayQueue getPlayQueue() { return getPlayQueue(0); } diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionManager.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionManager.kt index b17f498015e..3c11ce15204 100644 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionManager.kt +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionManager.kt @@ -1,6 +1,7 @@ package org.schabi.newpipe.local.subscription import android.content.Context +import android.util.Pair import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.core.Completable import io.reactivex.rxjava3.core.Flowable @@ -11,8 +12,9 @@ import org.schabi.newpipe.database.stream.model.StreamEntity import org.schabi.newpipe.database.subscription.NotificationMode import org.schabi.newpipe.database.subscription.SubscriptionDAO import org.schabi.newpipe.database.subscription.SubscriptionEntity -import org.schabi.newpipe.extractor.ListInfo +import org.schabi.newpipe.extractor.Info import org.schabi.newpipe.extractor.channel.ChannelInfo +import org.schabi.newpipe.extractor.channel.tabs.ChannelTabInfo import org.schabi.newpipe.extractor.feed.FeedInfo import org.schabi.newpipe.extractor.stream.StreamInfoItem import org.schabi.newpipe.local.feed.FeedDatabaseManager @@ -46,28 +48,33 @@ class SubscriptionManager(context: Context) { } } - fun upsertAll(infoList: List): List { + fun upsertAll(infoList: List>>): List { val listEntities = subscriptionTable.upsertAll( - infoList.map { SubscriptionEntity.from(it) } + infoList.map { SubscriptionEntity.from(it.first) } ) database.runInTransaction { infoList.forEachIndexed { index, info -> - feedDatabaseManager.upsertAll(listEntities[index].uid, info.relatedItems) + info.second.forEach { + feedDatabaseManager.upsertAll( + listEntities[index].uid, + it.relatedItems.filterIsInstance() + ) + } } } return listEntities } - fun updateChannelInfo(info: ChannelInfo): Completable = subscriptionTable.getSubscription(info.serviceId, info.url) - .flatMapCompletable { - Completable.fromRunnable { - it.setData(info.name, info.avatarUrl, info.description, info.subscriberCount) - subscriptionTable.update(it) - feedDatabaseManager.upsertAll(it.uid, info.relatedItems) + fun updateChannelInfo(info: ChannelInfo): Completable = + subscriptionTable.getSubscription(info.serviceId, info.url) + .flatMapCompletable { + Completable.fromRunnable { + it.setData(info.name, info.avatarUrl, info.description, info.subscriberCount) + subscriptionTable.update(it) + } } - } fun updateNotificationMode(serviceId: Int, url: String, @NotificationMode mode: Int): Completable { return subscriptionTable().getSubscription(serviceId, url) @@ -84,7 +91,7 @@ class SubscriptionManager(context: Context) { } } - fun updateFromInfo(subscriptionId: Long, info: ListInfo) { + fun updateFromInfo(subscriptionId: Long, info: Info) { val subscriptionEntity = subscriptionTable.getSubscription(subscriptionId) if (info is FeedInfo) { @@ -107,11 +114,8 @@ class SubscriptionManager(context: Context) { .observeOn(AndroidSchedulers.mainThread()) } - fun insertSubscription(subscriptionEntity: SubscriptionEntity, info: ChannelInfo) { - database.runInTransaction { - val subscriptionId = subscriptionTable.insert(subscriptionEntity) - feedDatabaseManager.upsertAll(subscriptionId, info.relatedItems) - } + fun insertSubscription(subscriptionEntity: SubscriptionEntity) { + subscriptionTable.insert(subscriptionEntity) } fun deleteSubscription(subscriptionEntity: SubscriptionEntity) { @@ -125,7 +129,10 @@ class SubscriptionManager(context: Context) { */ private fun rememberAllStreams(subscription: SubscriptionEntity): Completable { return ExtractorHelper.getChannelInfo(subscription.serviceId, subscription.url, false) - .map { channel -> channel.relatedItems.map { stream -> StreamEntity(stream) } } + .flatMap { info -> + ExtractorHelper.getChannelTab(subscription.serviceId, info.tabs.first(), false) + } + .map { channel -> channel.relatedItems.filterIsInstance().map { stream -> StreamEntity(stream) } } .flatMapCompletable { entities -> Completable.fromAction { database.streamDAO().upsertAll(entities) diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/services/SubscriptionsImportService.java b/app/src/main/java/org/schabi/newpipe/local/subscription/services/SubscriptionsImportService.java index af598b10601..d624e1038e7 100644 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/services/SubscriptionsImportService.java +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/services/SubscriptionsImportService.java @@ -26,6 +26,7 @@ import android.net.Uri; import android.text.TextUtils; import android.util.Log; +import android.util.Pair; import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -38,6 +39,7 @@ import org.schabi.newpipe.database.subscription.SubscriptionEntity; import org.schabi.newpipe.extractor.NewPipe; import org.schabi.newpipe.extractor.channel.ChannelInfo; +import org.schabi.newpipe.extractor.channel.tabs.ChannelTabInfo; import org.schabi.newpipe.extractor.subscription.SubscriptionItem; import org.schabi.newpipe.ktx.ExceptionUtils; import org.schabi.newpipe.streams.io.SharpInputStream; @@ -48,6 +50,7 @@ import java.io.IOException; import java.io.InputStream; import java.util.ArrayList; +import java.util.Collections; import java.util.List; import java.util.Objects; @@ -199,12 +202,19 @@ private void startImport() { .parallel(PARALLEL_EXTRACTIONS) .runOn(Schedulers.io()) - .map((Function>) subscriptionItem -> { + .map((Function>>>) subscriptionItem -> { try { - return Notification.createOnNext(ExtractorHelper + final ChannelInfo channelInfo = ExtractorHelper .getChannelInfo(subscriptionItem.getServiceId(), subscriptionItem.getUrl(), true) - .blockingGet()); + .blockingGet(); + return Notification.createOnNext(new Pair<>(channelInfo, + Collections.singletonList( + ExtractorHelper.getChannelTab( + subscriptionItem.getServiceId(), + channelInfo.getTabs().get(0), true).blockingGet() + ))); } catch (final Throwable e) { return Notification.createOnError(e); } @@ -223,7 +233,7 @@ private void startImport() { } private Subscriber> getSubscriber() { - return new Subscriber>() { + return new Subscriber<>() { @Override public void onSubscribe(final Subscription s) { subscription = s; @@ -254,10 +264,11 @@ public void onComplete() { }; } - private Consumer> getNotificationsConsumer() { + private Consumer>>> getNotificationsConsumer() { return notification -> { if (notification.isOnNext()) { - final String name = notification.getValue().getName(); + final String name = notification.getValue().first.getName(); eventListener.onItemCompleted(!TextUtils.isEmpty(name) ? name : ""); } else if (notification.isOnError()) { final Throwable error = notification.getError(); @@ -275,10 +286,12 @@ private Consumer> getNotificationsConsumer() { }; } - private Function>, List> upsertBatch() { + private Function>>>, + List> upsertBatch() { return notificationList -> { - final List infoList = new ArrayList<>(notificationList.size()); - for (final Notification n : notificationList) { + final List>> infoList = + new ArrayList<>(notificationList.size()); + for (final Notification>> n : notificationList) { if (n.isOnNext()) { infoList.add(n.getValue()); } diff --git a/app/src/main/java/org/schabi/newpipe/player/playqueue/AbstractInfoPlayQueue.java b/app/src/main/java/org/schabi/newpipe/player/playqueue/AbstractInfoPlayQueue.java index e51ee4720d4..33ec390a567 100644 --- a/app/src/main/java/org/schabi/newpipe/player/playqueue/AbstractInfoPlayQueue.java +++ b/app/src/main/java/org/schabi/newpipe/player/playqueue/AbstractInfoPlayQueue.java @@ -4,6 +4,7 @@ import androidx.annotation.NonNull; +import org.schabi.newpipe.extractor.InfoItem; import org.schabi.newpipe.extractor.ListExtractor; import org.schabi.newpipe.extractor.ListInfo; import org.schabi.newpipe.extractor.Page; @@ -15,7 +16,7 @@ import io.reactivex.rxjava3.core.SingleObserver; import io.reactivex.rxjava3.disposables.Disposable; -abstract class AbstractInfoPlayQueue> +abstract class AbstractInfoPlayQueue> extends PlayQueue { boolean isInitial; private boolean isComplete; @@ -27,7 +28,13 @@ abstract class AbstractInfoPlayQueue> private transient Disposable fetchReactor; protected AbstractInfoPlayQueue(final T info) { - this(info.getServiceId(), info.getUrl(), info.getNextPage(), info.getRelatedItems(), 0); + this(info.getServiceId(), info.getUrl(), info.getNextPage(), + info.getRelatedItems() + .stream() + .filter(StreamInfoItem.class::isInstance) + .map(StreamInfoItem.class::cast) + .collect(Collectors.toList()), + 0); } protected AbstractInfoPlayQueue(final int serviceId, @@ -72,7 +79,11 @@ public void onSuccess(@NonNull final T result) { } nextPage = result.getNextPage(); - append(extractListItems(result.getRelatedItems())); + append(extractListItems(result.getRelatedItems() + .stream() + .filter(StreamInfoItem.class::isInstance) + .map(StreamInfoItem.class::cast) + .collect(Collectors.toList()))); fetchReactor.dispose(); fetchReactor = null; @@ -87,7 +98,7 @@ public void onError(@NonNull final Throwable e) { }; } - SingleObserver> getNextPageObserver() { + SingleObserver> getNextPageObserver() { return new SingleObserver<>() { @Override public void onSubscribe(@NonNull final Disposable d) { @@ -101,13 +112,17 @@ public void onSubscribe(@NonNull final Disposable d) { @Override public void onSuccess( - @NonNull final ListExtractor.InfoItemsPage result) { + @NonNull final ListExtractor.InfoItemsPage result) { if (!result.hasNextPage()) { isComplete = true; } nextPage = result.getNextPage(); - append(extractListItems(result.getItems())); + append(extractListItems(result.getItems() + .stream() + .filter(StreamInfoItem.class::isInstance) + .map(StreamInfoItem.class::cast) + .collect(Collectors.toList()))); fetchReactor.dispose(); fetchReactor = null; diff --git a/app/src/main/java/org/schabi/newpipe/player/playqueue/ChannelPlayQueue.java b/app/src/main/java/org/schabi/newpipe/player/playqueue/ChannelPlayQueue.java deleted file mode 100644 index 1e1fef85ea2..00000000000 --- a/app/src/main/java/org/schabi/newpipe/player/playqueue/ChannelPlayQueue.java +++ /dev/null @@ -1,47 +0,0 @@ -package org.schabi.newpipe.player.playqueue; - - -import org.schabi.newpipe.extractor.Page; -import org.schabi.newpipe.extractor.channel.ChannelInfo; -import org.schabi.newpipe.extractor.stream.StreamInfoItem; -import org.schabi.newpipe.util.ExtractorHelper; - -import java.util.List; - -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; -import io.reactivex.rxjava3.schedulers.Schedulers; - -public final class ChannelPlayQueue extends AbstractInfoPlayQueue { - - public ChannelPlayQueue(final ChannelInfo info) { - super(info); - } - - public ChannelPlayQueue(final int serviceId, - final String url, - final Page nextPage, - final List streams, - final int index) { - super(serviceId, url, nextPage, streams, index); - } - - @Override - protected String getTag() { - return "ChannelPlayQueue@" + Integer.toHexString(hashCode()); - } - - @Override - public void fetch() { - if (this.isInitial) { - ExtractorHelper.getChannelInfo(this.serviceId, this.baseUrl, false) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(getHeadListObserver()); - } else { - ExtractorHelper.getMoreChannelItems(this.serviceId, this.baseUrl, this.nextPage) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(getNextPageObserver()); - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/playqueue/ChannelTabPlayQueue.java b/app/src/main/java/org/schabi/newpipe/player/playqueue/ChannelTabPlayQueue.java new file mode 100644 index 00000000000..a9eb2a19c7e --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/playqueue/ChannelTabPlayQueue.java @@ -0,0 +1,53 @@ +package org.schabi.newpipe.player.playqueue; + + +import org.schabi.newpipe.extractor.Page; +import org.schabi.newpipe.extractor.channel.tabs.ChannelTabInfo; +import org.schabi.newpipe.extractor.linkhandler.ListLinkHandler; +import org.schabi.newpipe.extractor.stream.StreamInfoItem; +import org.schabi.newpipe.util.ExtractorHelper; + +import java.util.Collections; +import java.util.List; + +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; +import io.reactivex.rxjava3.schedulers.Schedulers; + +public final class ChannelTabPlayQueue extends AbstractInfoPlayQueue { + + final ListLinkHandler linkHandler; + + public ChannelTabPlayQueue(final int serviceId, + final ListLinkHandler linkHandler, + final Page nextPage, + final List streams, + final int index) { + super(serviceId, linkHandler.getUrl(), nextPage, streams, index); + this.linkHandler = linkHandler; + } + + public ChannelTabPlayQueue(final int serviceId, + final ListLinkHandler linkHandler) { + this(serviceId, linkHandler, null, Collections.emptyList(), 0); + } + + @Override + protected String getTag() { + return "ChannelTabPlayQueue@" + Integer.toHexString(hashCode()); + } + + @Override + public void fetch() { + if (isInitial) { + ExtractorHelper.getChannelTab(this.serviceId, this.linkHandler, false) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(getHeadListObserver()); + } else { + ExtractorHelper.getMoreChannelTabItems(this.serviceId, this.linkHandler, this.nextPage) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(getNextPageObserver()); + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/settings/NewPipeSettings.java b/app/src/main/java/org/schabi/newpipe/settings/NewPipeSettings.java index 2cca0807270..b85b95eb03b 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/NewPipeSettings.java +++ b/app/src/main/java/org/schabi/newpipe/settings/NewPipeSettings.java @@ -44,21 +44,11 @@ public final class NewPipeSettings { private NewPipeSettings() { } public static void initSettings(final Context context) { - // check if there are entries in the prefs to determine whether this is the first app run - Boolean isFirstRun = null; - final Set prefsKeys = PreferenceManager.getDefaultSharedPreferences(context) - .getAll().keySet(); - for (final String key: prefsKeys) { - // ACRA stores some info in the prefs during app initialization - // which happens before this method is called. Therefore ignore ACRA-related keys. - if (!key.toLowerCase().startsWith("acra")) { - isFirstRun = false; - break; - } - } - if (isFirstRun == null) { - isFirstRun = true; - } + // check if the last used preference version is set + // to determine whether this is the first app run + final int lastUsedPrefVersion = PreferenceManager.getDefaultSharedPreferences(context) + .getInt(context.getString(R.string.last_used_preferences_version), -1); + final boolean isFirstRun = lastUsedPrefVersion == -1; // first run migrations, then setDefaultValues, since the latter requires the correct types SettingMigrations.runMigrationsIfNeeded(context, isFirstRun); diff --git a/app/src/main/java/org/schabi/newpipe/util/ChannelTabHelper.java b/app/src/main/java/org/schabi/newpipe/util/ChannelTabHelper.java new file mode 100644 index 00000000000..8e8d3849007 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/ChannelTabHelper.java @@ -0,0 +1,151 @@ +package org.schabi.newpipe.util; + +import android.content.Context; +import android.content.SharedPreferences; + +import androidx.annotation.StringRes; + +import org.schabi.newpipe.R; +import org.schabi.newpipe.extractor.channel.tabs.ChannelTabs; +import org.schabi.newpipe.extractor.linkhandler.ListLinkHandler; + +import java.util.List; +import java.util.Set; + +public final class ChannelTabHelper { + private ChannelTabHelper() { + } + + /** + * @param tab the channel tab to check + * @return whether the tab should contain (playable) streams or not + */ + public static boolean isStreamsTab(final String tab) { + switch (tab) { + case ChannelTabs.VIDEOS: + case ChannelTabs.TRACKS: + case ChannelTabs.SHORTS: + case ChannelTabs.LIVESTREAMS: + return true; + default: + return false; + } + } + + /** + * @param tab the channel tab link handler to check + * @return whether the tab should contain (playable) streams or not + */ + public static boolean isStreamsTab(final ListLinkHandler tab) { + final List contentFilters = tab.getContentFilters(); + if (contentFilters.isEmpty()) { + return false; // this should never happen, but check just to be sure + } else { + return isStreamsTab(contentFilters.get(0)); + } + } + + @StringRes + private static int getShowTabKey(final String tab) { + switch (tab) { + case ChannelTabs.VIDEOS: + return R.string.show_channel_tabs_videos; + case ChannelTabs.TRACKS: + return R.string.show_channel_tabs_tracks; + case ChannelTabs.SHORTS: + return R.string.show_channel_tabs_shorts; + case ChannelTabs.LIVESTREAMS: + return R.string.show_channel_tabs_livestreams; + case ChannelTabs.CHANNELS: + return R.string.show_channel_tabs_channels; + case ChannelTabs.PLAYLISTS: + return R.string.show_channel_tabs_playlists; + case ChannelTabs.ALBUMS: + return R.string.show_channel_tabs_albums; + default: + return -1; + } + } + + @StringRes + private static int getFetchFeedTabKey(final String tab) { + switch (tab) { + case ChannelTabs.VIDEOS: + return R.string.fetch_channel_tabs_videos; + case ChannelTabs.TRACKS: + return R.string.fetch_channel_tabs_tracks; + case ChannelTabs.SHORTS: + return R.string.fetch_channel_tabs_shorts; + case ChannelTabs.LIVESTREAMS: + return R.string.fetch_channel_tabs_livestreams; + default: + return -1; + } + } + + @StringRes + public static int getTranslationKey(final String tab) { + switch (tab) { + case ChannelTabs.VIDEOS: + return R.string.channel_tab_videos; + case ChannelTabs.TRACKS: + return R.string.channel_tab_tracks; + case ChannelTabs.SHORTS: + return R.string.channel_tab_shorts; + case ChannelTabs.LIVESTREAMS: + return R.string.channel_tab_livestreams; + case ChannelTabs.CHANNELS: + return R.string.channel_tab_channels; + case ChannelTabs.PLAYLISTS: + return R.string.channel_tab_playlists; + case ChannelTabs.ALBUMS: + return R.string.channel_tab_albums; + default: + return R.string.unknown_content; + } + } + + public static boolean showChannelTab(final Context context, + final SharedPreferences sharedPreferences, + @StringRes final int key) { + final Set enabledTabs = sharedPreferences.getStringSet( + context.getString(R.string.show_channel_tabs_key), null); + if (enabledTabs == null) { + return true; // default to true + } else { + return enabledTabs.contains(context.getString(key)); + } + } + + public static boolean showChannelTab(final Context context, + final SharedPreferences sharedPreferences, + final String tab) { + final int key = ChannelTabHelper.getShowTabKey(tab); + if (key == -1) { + return false; + } + return showChannelTab(context, sharedPreferences, key); + } + + public static boolean fetchFeedChannelTab(final Context context, + final SharedPreferences sharedPreferences, + final ListLinkHandler tab) { + final List contentFilters = tab.getContentFilters(); + if (contentFilters.isEmpty()) { + return false; // this should never happen, but check just to be sure + } + + final int key = ChannelTabHelper.getFetchFeedTabKey(contentFilters.get(0)); + if (key == -1) { + return false; + } + + final Set enabledTabs = sharedPreferences.getStringSet( + context.getString(R.string.feed_fetch_channel_tabs_key), null); + if (enabledTabs == null) { + return true; // default to true + } else { + return enabledTabs.contains(context.getString(key)); + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/util/ExtractorHelper.java b/app/src/main/java/org/schabi/newpipe/util/ExtractorHelper.java index d5d472d6f28..07d0f516de2 100644 --- a/app/src/main/java/org/schabi/newpipe/util/ExtractorHelper.java +++ b/app/src/main/java/org/schabi/newpipe/util/ExtractorHelper.java @@ -36,17 +36,15 @@ import org.schabi.newpipe.extractor.Info; import org.schabi.newpipe.extractor.InfoItem; import org.schabi.newpipe.extractor.ListExtractor.InfoItemsPage; -import org.schabi.newpipe.extractor.ListInfo; import org.schabi.newpipe.extractor.MetaInfo; import org.schabi.newpipe.extractor.NewPipe; import org.schabi.newpipe.extractor.Page; -import org.schabi.newpipe.extractor.StreamingService; import org.schabi.newpipe.extractor.channel.ChannelInfo; +import org.schabi.newpipe.extractor.channel.tabs.ChannelTabInfo; import org.schabi.newpipe.extractor.comments.CommentsInfo; import org.schabi.newpipe.extractor.comments.CommentsInfoItem; -import org.schabi.newpipe.extractor.feed.FeedExtractor; -import org.schabi.newpipe.extractor.feed.FeedInfo; import org.schabi.newpipe.extractor.kiosk.KioskInfo; +import org.schabi.newpipe.extractor.linkhandler.ListLinkHandler; import org.schabi.newpipe.extractor.playlist.PlaylistInfo; import org.schabi.newpipe.extractor.search.SearchInfo; import org.schabi.newpipe.extractor.stream.StreamInfo; @@ -127,28 +125,24 @@ public static Single getChannelInfo(final int serviceId, final Stri ChannelInfo.getInfo(NewPipe.getService(serviceId), url))); } - public static Single> getMoreChannelItems(final int serviceId, - final String url, - final Page nextPage) { + public static Single getChannelTab(final int serviceId, + final ListLinkHandler listLinkHandler, + final boolean forceLoad) { checkServiceId(serviceId); - return Single.fromCallable(() -> - ChannelInfo.getMoreItems(NewPipe.getService(serviceId), url, nextPage)); + return checkCache(forceLoad, serviceId, + listLinkHandler.getUrl(), InfoItem.InfoType.CHANNEL, + Single.fromCallable(() -> + ChannelTabInfo.getInfo(NewPipe.getService(serviceId), listLinkHandler))); } - public static Single> getFeedInfoFallbackToChannelInfo( - final int serviceId, final String url) { - final Maybe> maybeFeedInfo = Maybe.fromCallable(() -> { - final StreamingService service = NewPipe.getService(serviceId); - final FeedExtractor feedExtractor = service.getFeedExtractor(url); - - if (feedExtractor == null) { - return null; - } - - return FeedInfo.getInfo(feedExtractor); - }); - - return maybeFeedInfo.switchIfEmpty(getChannelInfo(serviceId, url, true)); + public static Single> getMoreChannelTabItems( + final int serviceId, + final ListLinkHandler listLinkHandler, + final Page nextPage) { + checkServiceId(serviceId); + return Single.fromCallable(() -> + ChannelTabInfo.getMoreItems(NewPipe.getService(serviceId), + listLinkHandler, nextPage)); } public static Single getCommentsInfo(final int serviceId, final String url, @@ -229,7 +223,7 @@ private static Single checkCache(final boolean forceLoad, load = actualLoadFromNetwork; } else { load = Maybe.concat(ExtractorHelper.loadFromCache(serviceId, url, infoType), - actualLoadFromNetwork.toMaybe()) + actualLoadFromNetwork.toMaybe()) .firstElement() // Take the first valid .toSingle(); } @@ -240,10 +234,10 @@ private static Single checkCache(final boolean forceLoad, /** * Default implementation uses the {@link InfoCache} to get cached results. * - * @param the item type's class that extends {@link Info} - * @param serviceId the service to load from - * @param url the URL to load - * @param infoType the {@link InfoItem.InfoType} of the item + * @param the item type's class that extends {@link Info} + * @param serviceId the service to load from + * @param url the URL to load + * @param infoType the {@link InfoItem.InfoType} of the item * @return a {@link Single} that loads the item */ private static Maybe loadFromCache(final int serviceId, final String url, @@ -274,11 +268,12 @@ public static boolean isCached(final int serviceId, final String url, * Formats the text contained in the meta info list as HTML and puts it into the text view, * while also making the separator visible. If the list is null or empty, or the user chose not * to see meta information, both the text view and the separator are hidden - * @param metaInfos a list of meta information, can be null or empty - * @param metaInfoTextView the text view in which to show the formatted HTML + * + * @param metaInfos a list of meta information, can be null or empty + * @param metaInfoTextView the text view in which to show the formatted HTML * @param metaInfoSeparator another view to be shown or hidden accordingly to the text view - * @param disposables disposables created by the method are added here and their lifecycle - * should be handled by the calling class + * @param disposables disposables created by the method are added here and their lifecycle + * should be handled by the calling class */ public static void showMetaInfoInTextView(@Nullable final List metaInfos, final TextView metaInfoTextView, @@ -287,7 +282,7 @@ public static void showMetaInfoInTextView(@Nullable final List metaInf final Context context = metaInfoTextView.getContext(); if (metaInfos == null || metaInfos.isEmpty() || !PreferenceManager.getDefaultSharedPreferences(context).getBoolean( - context.getString(R.string.show_meta_info_key), true)) { + context.getString(R.string.show_meta_info_key), true)) { metaInfoTextView.setVisibility(View.GONE); metaInfoSeparator.setVisibility(View.GONE); diff --git a/app/src/main/java/org/schabi/newpipe/util/PlayButtonHelper.java b/app/src/main/java/org/schabi/newpipe/util/PlayButtonHelper.java new file mode 100644 index 00000000000..9727c808300 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/PlayButtonHelper.java @@ -0,0 +1,90 @@ +package org.schabi.newpipe.util; + +import android.content.Context; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.AppCompatActivity; +import androidx.preference.PreferenceManager; + +import org.schabi.newpipe.R; +import org.schabi.newpipe.databinding.PlaylistControlBinding; +import org.schabi.newpipe.fragments.list.playlist.PlaylistControlViewHolder; +import org.schabi.newpipe.player.PlayerType; + +/** + * Utility class for play buttons and their respective click listeners. + */ +public final class PlayButtonHelper { + + private PlayButtonHelper() { + // utility class + } + + /** + * Initialize {@link android.view.View.OnClickListener OnClickListener} + * and {@link android.view.View.OnLongClickListener OnLongClickListener} for playlist control + * buttons defined in {@link R.layout#playlist_control}. + * + * @param activity The activity to use for the {@link android.widget.Toast Toast}. + * @param playlistControlBinding The binding of the + * {@link R.layout#playlist_control playlist control layout}. + * @param fragment The fragment to get the play queue from. + */ + public static void initPlaylistControlClickListener( + @NonNull final AppCompatActivity activity, + @NonNull final PlaylistControlBinding playlistControlBinding, + @NonNull final PlaylistControlViewHolder fragment) { + // click listener + playlistControlBinding.playlistCtrlPlayAllButton.setOnClickListener(view -> { + NavigationHelper.playOnMainPlayer(activity, fragment.getPlayQueue()); + showHoldToAppendToastIfNeeded(activity); + }); + playlistControlBinding.playlistCtrlPlayPopupButton.setOnClickListener(view -> { + NavigationHelper.playOnPopupPlayer(activity, fragment.getPlayQueue(), false); + showHoldToAppendToastIfNeeded(activity); + }); + playlistControlBinding.playlistCtrlPlayBgButton.setOnClickListener(view -> { + NavigationHelper.playOnBackgroundPlayer(activity, fragment.getPlayQueue(), false); + showHoldToAppendToastIfNeeded(activity); + }); + + // long click listener + playlistControlBinding.playlistCtrlPlayPopupButton.setOnLongClickListener(view -> { + NavigationHelper.enqueueOnPlayer(activity, fragment.getPlayQueue(), PlayerType.POPUP); + return true; + }); + playlistControlBinding.playlistCtrlPlayBgButton.setOnLongClickListener(view -> { + NavigationHelper.enqueueOnPlayer(activity, fragment.getPlayQueue(), PlayerType.AUDIO); + return true; + }); + } + + /** + * Show the "hold to append" toast if the corresponding preference is enabled. + * + * @param context The context to show the toast. + */ + private static void showHoldToAppendToastIfNeeded(@NonNull final Context context) { + if (shouldShowHoldToAppendTip(context)) { + Toast.makeText(context, R.string.hold_to_append, Toast.LENGTH_SHORT).show(); + } + + } + + /** + * Check if the "hold to append" toast should be shown. + * + *

+ * The tip is shown if the corresponding preference is enabled. + * This is the default behaviour. + *

+ * + * @param context The context to get the preference. + * @return {@code true} if the tip should be shown, {@code false} otherwise. + */ + public static boolean shouldShowHoldToAppendTip(@NonNull final Context context) { + return PreferenceManager.getDefaultSharedPreferences(context) + .getBoolean(context.getString(R.string.show_hold_to_append_key), true); + } +} diff --git a/app/src/main/res/drawable-nodpi/placeholder_channel_banner.png b/app/src/main/res/drawable-nodpi/placeholder_channel_banner.png index 12e70bb6dae..cad11fa5041 100644 Binary files a/app/src/main/res/drawable-nodpi/placeholder_channel_banner.png and b/app/src/main/res/drawable-nodpi/placeholder_channel_banner.png differ diff --git a/app/src/main/res/layout/channel_header.xml b/app/src/main/res/layout/channel_header.xml deleted file mode 100644 index 9d1304635d1..00000000000 --- a/app/src/main/res/layout/channel_header.xml +++ /dev/null @@ -1,131 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/fragment_channel.xml b/app/src/main/res/layout/fragment_channel.xml index 714b9d4f95b..f557e339696 100644 --- a/app/src/main/res/layout/fragment_channel.xml +++ b/app/src/main/res/layout/fragment_channel.xml @@ -1,80 +1,214 @@ - - + - - - - + + + + + + + + + + + + + + + + + + + + + + + + app:layout_behavior="@string/appbar_scrolling_view_behavior"> - - - + + + + + android:layout_centerInParent="true" + android:indeterminate="true" + android:visibility="gone" + tools:visibility="visible" /> - - - - - - + android:layout_centerInParent="true" + android:orientation="vertical" + android:paddingTop="90dp" + android:visibility="gone" + tools:visibility="visible"> - + + + - + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_channel_tab.xml b/app/src/main/res/layout/fragment_channel_tab.xml new file mode 100644 index 00000000000..62795c7da6d --- /dev/null +++ b/app/src/main/res/layout/fragment_channel_tab.xml @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_channel_videos.xml b/app/src/main/res/layout/fragment_channel_videos.xml new file mode 100644 index 00000000000..77d14b020db --- /dev/null +++ b/app/src/main/res/layout/fragment_channel_videos.xml @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_description.xml b/app/src/main/res/layout/fragment_description.xml index 157b8f394f9..b20905d4ad2 100644 --- a/app/src/main/res/layout/fragment_description.xml +++ b/app/src/main/res/layout/fragment_description.xml @@ -8,6 +8,7 @@ android:scrollbars="vertical"> diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index dfc26a4e21d..5283f1afa05 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -620,6 +620,7 @@ Vorschaubild-URL Server Unterstützung + Abonnenten Auswählen von Text in der Beschreibung deaktivieren Auswählen von Text in der Beschreibung aktivieren Du kannst nun Text innerhalb der Beschreibung auswählen. Beachte, dass die Seite flackern kann und Links im Auswahlmodus möglicherweise nicht anklickbar sind. @@ -766,4 +767,12 @@ Das Media-Tunneling wurde auf dem Gerät standardmäßig deaktiviert, da das Gerätemodell diese Funktion bekanntermaßen nicht unterstützt. Keine Live-Streams Keine Streams + Videos + Live + Shorts + Wiedergabelisten + Kanäle + Alben + Tabs auf den Kanalseiten + Welche Tabs auf den Kanalseiten angezeigt werden \ No newline at end of file diff --git a/app/src/main/res/values-land/dimens.xml b/app/src/main/res/values-land/dimens.xml index 77e18695d3e..46244b3c9c6 100644 --- a/app/src/main/res/values-land/dimens.xml +++ b/app/src/main/res/values-land/dimens.xml @@ -32,7 +32,6 @@ 16sp 14sp 14sp - 14sp 14sp 42dp diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml index e47b72c9aa6..0e5fd126f5b 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -75,7 +75,6 @@ 14sp 13sp 13sp - 12sp 12sp 32dp diff --git a/app/src/main/res/values/settings_keys.xml b/app/src/main/res/values/settings_keys.xml index 56fc19eedd0..51abe14fbbf 100644 --- a/app/src/main/res/values/settings_keys.xml +++ b/app/src/main/res/values/settings_keys.xml @@ -274,6 +274,35 @@ main_tabs_position + channel_tabs + show_channel_tabs_videos + show_channel_tabs_tracks + show_channel_tabs_shorts + show_channel_tabs_livestreams + show_channel_tabs_channels + show_channel_tabs_playlists + show_channel_tabs_albums + show_channel_tabs_about + + @string/show_channel_tabs_videos + @string/show_channel_tabs_tracks + @string/show_channel_tabs_shorts + @string/show_channel_tabs_livestreams + @string/show_channel_tabs_channels + @string/show_channel_tabs_playlists + @string/show_channel_tabs_albums + @string/show_channel_tabs_about + + + @string/channel_tab_videos + @string/channel_tab_tracks + @string/channel_tab_shorts + @string/channel_tab_livestreams + @string/channel_tab_channels + @string/channel_tab_playlists + @string/channel_tab_albums + @string/channel_tab_about + show_search_suggestions show_local_search_suggestions show_remote_search_suggestions @@ -343,6 +372,24 @@ feed_use_dedicated_fetch_method + feed_fetch_channel_tabs + fetch_channel_tabs_videos + fetch_channel_tabs_tracks + fetch_channel_tabs_shorts + fetch_channel_tabs_livestreams + + @string/fetch_channel_tabs_videos + @string/fetch_channel_tabs_tracks + @string/fetch_channel_tabs_shorts + @string/fetch_channel_tabs_livestreams + + + @string/channel_tab_videos + @string/channel_tab_tracks + @string/channel_tab_shorts + @string/channel_tab_livestreams + + import_export_data_path import_data export_data diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a37bfeb82f4..e6fb35a1634 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -714,6 +714,8 @@ \nSo the choice boils down to what you prefer: speed or precise information.
Show the following streams Show/Hide streams + Fetch channel tabs + Tabs to fetch when updating the feed. This option has no effect if a channel is updated using fast mode. This content is not yet supported by NewPipe.\n\nIt will hopefully be supported in a future version. Channel\'s avatar thumbnail Created by %s @@ -754,10 +756,13 @@ Support Host Thumbnail URL + Avatar URL + Banner URL Public Unlisted Private Internal + Subscribers Pinned comment Hearted by creator Open website @@ -796,4 +801,14 @@ original dubbed descriptive + Videos + Tracks + Shorts + Live + Channels + Playlists + Albums + About + Channel tabs + What tabs are shown on the channel pages \ No newline at end of file diff --git a/app/src/main/res/xml/content_settings.xml b/app/src/main/res/xml/content_settings.xml index fddb966c847..73a849af75e 100644 --- a/app/src/main/res/xml/content_settings.xml +++ b/app/src/main/res/xml/content_settings.xml @@ -41,6 +41,16 @@ app:singleLineTitle="false" app:iconSpaceReserved="false" /> + + + +