From 6d13cf5e71bcc429c2b80f4a1af0ec3b65b97f2f Mon Sep 17 00:00:00 2001 From: ThetaDev Date: Sun, 23 Oct 2022 10:27:35 +0200 Subject: [PATCH] feat: add channel tabs --- app/build.gradle | 2 +- .../list/channel/ChannelFragment.java | 571 +++-------------- .../list/channel/ChannelInfoFragment.java | 38 ++ .../list/channel/ChannelTabFragment.java | 68 ++ .../list/channel/ChannelVideosFragment.java | 584 ++++++++++++++++++ .../org/schabi/newpipe/settings/tabs/Tab.java | 6 +- .../schabi/newpipe/util/ExtractorHelper.java | 42 +- app/src/main/res/layout/fragment_channel.xml | 67 +- .../main/res/layout/fragment_channel_info.xml | 36 ++ .../main/res/layout/fragment_channel_tab.xml | 41 ++ .../res/layout/fragment_channel_videos.xml | 71 +++ app/src/main/res/menu/menu_channel_videos.xml | 14 + 12 files changed, 997 insertions(+), 543 deletions(-) create mode 100644 app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelInfoFragment.java create mode 100644 app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelTabFragment.java create mode 100644 app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelVideosFragment.java create mode 100644 app/src/main/res/layout/fragment_channel_info.xml create mode 100644 app/src/main/res/layout/fragment_channel_tab.xml create mode 100644 app/src/main/res/layout/fragment_channel_videos.xml create mode 100644 app/src/main/res/menu/menu_channel_videos.xml diff --git a/app/build.gradle b/app/build.gradle index 396f119b2..22ac7d67d 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.Theta-Dev:NewPipeExtractor:8446e20a71dbddbe1626a118d0adf490e5e63bbb' implementation 'com.github.TeamNewPipe:NoNonsense-FilePicker:5.0.0' /** Checkstyle **/ 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 8a0b49249..6989552f2 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,96 +1,55 @@ package org.schabi.newpipe.fragments.list.channel; -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.graphics.Color; import android.os.Bundle; import android.text.TextUtils; import android.util.Log; -import android.util.TypedValue; import android.view.LayoutInflater; import android.view.Menu; import android.view.MenuInflater; 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 com.google.android.material.snackbar.Snackbar; -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.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.extractor.linkhandler.ChannelTabHandler; +import org.schabi.newpipe.fragments.BaseStateFragment; +import org.schabi.newpipe.fragments.detail.TabAdapter; +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.ThemeHelper; import org.schabi.newpipe.util.external_communication.ShareUtils; -import java.util.List; -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; -import io.reactivex.rxjava3.functions.Consumer; -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 { + @State + protected int serviceId = Constants.NO_SERVICE_ID; + @State + protected String name; + @State + protected String url; - private static final int BUTTON_DEBOUNCE_INTERVAL = 100; - private static final String PICASSO_CHANNEL_TAG = "PICASSO_CHANNEL_TAG"; + private ChannelInfo currentInfo; + private Disposable currentWorker; - private final CompositeDisposable disposables = new CompositeDisposable(); - private Disposable subscribeButtonMonitor; - - private boolean channelContentNotSupported = false; + private MenuItem menuRssButton; /*////////////////////////////////////////////////////////////////////////// // Views //////////////////////////////////////////////////////////////////////////*/ - private SubscriptionManager subscriptionManager; - - private FragmentChannelBinding channelBinding; - private ChannelHeaderBinding headerBinding; - private PlaylistControlBinding playlistControlBinding; - - private MenuItem menuRssButton; - private MenuItem menuNotifyButton; + private FragmentChannelBinding binding; + private TabAdapter tabAdapter; public static ChannelFragment getInstance(final int serviceId, final String url, final String name) { @@ -100,15 +59,13 @@ public class ChannelFragment extends BaseListInfoFragment getListHeaderSupplier() { - headerBinding = ChannelHeaderBinding - .inflate(activity.getLayoutInflater(), itemsList, false); - playlistControlBinding = headerBinding.playlistControl; - - return headerBinding::getRoot; - } - - @Override - protected void initListeners() { - super.initListeners(); - - headerBinding.subChannelTitleView.setOnClickListener(this); - headerBinding.subChannelAvatarView.setOnClickListener(this); - } - - /*////////////////////////////////////////////////////////////////////////// + /*////////////////////////////////////////////////////////////////////////// // Menu //////////////////////////////////////////////////////////////////////////*/ @@ -176,19 +109,14 @@ public class ChannelFragment extends BaseListInfoFragment onError = (Throwable throwable) -> { - animate(headerBinding.channelSubscribeButton, false, 100); - showSnackBarError(new ErrorInfo(throwable, UserAction.SUBSCRIPTION_GET, - "Get subscription status", currentInfo)); - }; + private void updateTabs() { + tabAdapter.clearAllItems(); - final Observable> observable = subscriptionManager - .subscriptionTable() - .getSubscriptionFlowable(info.getServiceId(), info.getUrl()) - .toObservable(); + if (currentInfo != null) { + tabAdapter.addFragment(ChannelVideosFragment.getInstance(currentInfo), "Videos"); - disposables.add(observable - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(getSubscribeUpdateMonitor(info), onError)); - - disposables.add(observable - .map(List::isEmpty) - .distinctUntilChanged() - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(isEmpty -> updateSubscribeButton(!isEmpty), onError)); - - disposables.add(observable - .map(List::isEmpty) - .distinctUntilChanged() - .skip(1) // channel has just been opened - .filter(x -> NotificationHelper.areNewStreamsNotificationsEnabled(requireContext())) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(isEmpty -> { - if (!isEmpty) { - showNotifySnackbar(); - } - }, onError)); - } - - private Function mapOnSubscribe(final SubscriptionEntity subscription, - final ChannelInfo info) { - return (@NonNull Object o) -> { - subscriptionManager.insertSubscription(subscription, info); - return o; - }; - } - - private Function mapOnUnsubscribe(final SubscriptionEntity subscription) { - return (@NonNull Object o) -> { - subscriptionManager.deleteSubscription(subscription); - return o; - }; - } - - private void updateSubscription(final ChannelInfo info) { - if (DEBUG) { - Log.d(TAG, "updateSubscription() called with: info = [" + info + "]"); - } - final Action onComplete = () -> { - if (DEBUG) { - Log.d(TAG, "Updated subscription: " + info.getUrl()); + for (final ChannelTabHandler tab : currentInfo.getTabs()) { + tabAdapter.addFragment( + ChannelTabFragment.getInstance(serviceId, tab), tab.getTab().name()); } - }; - final Consumer onError = (@NonNull Throwable throwable) -> - showSnackBarError(new ErrorInfo(throwable, UserAction.SUBSCRIPTION_UPDATE, - "Updating subscription for " + info.getUrl(), info)); + final String description = currentInfo.getDescription(); + if (!description.isEmpty()) { + tabAdapter.addFragment(ChannelInfoFragment.getInstance(description), "Info"); + } + } - disposables.add(subscriptionManager.updateChannelInfo(info) + tabAdapter.notifyDataSetUpdate(); + + for (int i = 0; i < tabAdapter.getCount(); i++) { + binding.tabLayout.getTabAt(i).setText(tabAdapter.getItemTitle(i)); + } + } + + @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(onComplete, onError)); - } - - private Disposable monitorSubscribeButton(final Button subscribeButton, - final Function action) { - final Consumer onNext = (@NonNull Object o) -> { - if (DEBUG) { - Log.d(TAG, "Changed subscription status to this channel!"); - } - }; - - final Consumer onError = (@NonNull Throwable throwable) -> - showSnackBarError(new ErrorInfo(throwable, UserAction.SUBSCRIPTION_CHANGE, - "Changing subscription for " + currentInfo.getUrl(), currentInfo)); - - /* Emit clicks from main thread unto io thread */ - return RxView.clicks(subscribeButton) - .subscribeOn(AndroidSchedulers.mainThread()) - .observeOn(Schedulers.io()) - .debounce(BUTTON_DEBOUNCE_INTERVAL, TimeUnit.MILLISECONDS) // Ignore rapid clicks - .map(action) - .subscribe(onNext, onError); - } - - private Consumer> getSubscribeUpdateMonitor(final ChannelInfo info) { - return (List subscriptionEntities) -> { - if (DEBUG) { - Log.d(TAG, "subscriptionManager.subscriptionTable.doOnNext() called with: " - + "subscriptionEntities = [" + subscriptionEntities + "]"); - } - if (subscribeButtonMonitor != null) { - subscribeButtonMonitor.dispose(); - } - - if (subscriptionEntities.isEmpty()) { - if (DEBUG) { - Log.d(TAG, "No subscription to this channel!"); - } - final SubscriptionEntity channel = new SubscriptionEntity(); - channel.setServiceId(info.getServiceId()); - channel.setUrl(info.getUrl()); - channel.setData(info.getName(), - info.getAvatarUrl(), - info.getDescription(), - info.getSubscriberCount()); - updateNotifyButton(null); - subscribeButtonMonitor = monitorSubscribeButton( - headerBinding.channelSubscribeButton, mapOnSubscribe(channel, info)); - } 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)); - } - }; - } - - private void updateSubscribeButton(final boolean isSubscribed) { - if (DEBUG) { - Log.d(TAG, "updateSubscribeButton() called with: " - + "isSubscribed = [" + isSubscribed + "]"); - } - - final boolean isButtonVisible = headerBinding.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); - - 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, - subscribeBackground, subscribedBackground); - animateTextColor(headerBinding.channelSubscribeButton, textDuration, subscribeText, - subscribedText); - } - - animate(headerBinding.channelSubscribeButton, true, 100, - AnimationType.LIGHT_SCALE_AND_ALPHA); - } - - private void updateNotifyButton(@Nullable final SubscriptionEntity subscription) { - if (menuNotifyButton == null) { - return; - } - if (subscription != null) { - menuNotifyButton.setEnabled( - NotificationHelper.areNewStreamsNotificationsEnabled(requireContext()) - ); - menuNotifyButton.setChecked( - subscription.getNotificationMode() == NotificationMode.ENABLED - ); - } - - menuNotifyButton.setVisible(subscription != null); - } - - private void setNotify(final boolean isEnabled) { - disposables.add( - subscriptionManager - .updateNotificationMode( - currentInfo.getServiceId(), - currentInfo.getUrl(), - isEnabled ? NotificationMode.ENABLED : NotificationMode.DISABLED) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe() - ); - } - - /** - * 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) - .setAction(R.string.get_notified, v -> setNotify(true)) - .setActionTextColor(Color.YELLOW) - .show(); - } - - /*////////////////////////////////////////////////////////////////////////// - // Load and handle - //////////////////////////////////////////////////////////////////////////*/ - - @Override - protected Single> loadMoreItemsLogic() { - return ExtractorHelper.getMoreChannelItems(serviceId, url, currentNextPage); + .subscribe(result -> { + isLoading.set(false); + handleResult(result); + }, throwable -> showError(new ErrorInfo(throwable, UserAction.REQUESTED_STREAM, + url == null ? "no url" : url, serviceId))); } @Override - protected Single loadResult(final boolean forceLoad) { - return ExtractorHelper.getChannelInfo(serviceId, url, forceLoad); - } + public void handleResult(@NonNull final ChannelInfo info) { + super.handleResult(info); - /*////////////////////////////////////////////////////////////////////////// - // OnClick - //////////////////////////////////////////////////////////////////////////*/ - - @Override - public void onClick(final View v) { - if (isLoading.get() || currentInfo == null) { - return; - } - - 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; - } - } - - /*////////////////////////////////////////////////////////////////////////// - // Contract - //////////////////////////////////////////////////////////////////////////*/ - - @Override - public void showLoading() { - super.showLoading(); - PicassoHelper.cancelTag(PICASSO_CHANNEL_TAG); - animate(headerBinding.channelSubscribeButton, false, 100); - } - - @Override - public void handleResult(@NonNull final ChannelInfo result) { - super.handleResult(result); - - 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); - PicassoHelper.loadAvatar(result.getParentChannelAvatarUrl()).tag(PICASSO_CHANNEL_TAG) - .into(headerBinding.subChannelAvatarView); - - headerBinding.channelSubscriberView.setVisibility(View.VISIBLE); - if (result.getSubscriberCount() >= 0) { - headerBinding.channelSubscriberView.setText(Localization - .shortSubscriberCount(activity, result.getSubscriberCount())); - } else { - headerBinding.channelSubscriberView.setText(R.string.subscribers_count_not_available); - } - - if (!TextUtils.isEmpty(currentInfo.getParentChannelName())) { - headerBinding.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); - } - - 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) { - channelContentNotSupported = true; - showContentNotSupportedIfNeeded(); - break; - } - } - - disposables.clear(); - if (subscribeButtonMonitor != null) { - subscribeButtonMonitor.dispose(); - } - 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) { - 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); - } + currentInfo = info; + setInitialData(info.getServiceId(), info.getOriginalUrl(), info.getName()); + updateTabs(); + updateRssButton(); } } diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelInfoFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelInfoFragment.java new file mode 100644 index 000000000..c9273f528 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelInfoFragment.java @@ -0,0 +1,38 @@ +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.BaseFragment; +import org.schabi.newpipe.databinding.FragmentChannelInfoBinding; + +public class ChannelInfoFragment extends BaseFragment { + private String description; + + public static ChannelInfoFragment getInstance(final String description) { + final ChannelInfoFragment fragment = new ChannelInfoFragment(); + fragment.description = description; + return fragment; + } + + public ChannelInfoFragment() { + super(); + } + + @Override + public View onCreateView(@NonNull final LayoutInflater inflater, + @Nullable final ViewGroup container, + final Bundle savedInstanceState) { + final FragmentChannelInfoBinding binding = + FragmentChannelInfoBinding.inflate(inflater, container, false); + binding.descriptionText.setText(description); + + return binding.getRoot(); + } + +} 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 000000000..12514a55c --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelTabFragment.java @@ -0,0 +1,68 @@ +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.error.UserAction; +import org.schabi.newpipe.extractor.InfoItem; +import org.schabi.newpipe.extractor.ListExtractor; +import org.schabi.newpipe.extractor.channel.ChannelTabInfo; +import org.schabi.newpipe.extractor.linkhandler.ChannelTabHandler; +import org.schabi.newpipe.fragments.list.BaseListInfoFragment; +import org.schabi.newpipe.util.Constants; +import org.schabi.newpipe.util.ExtractorHelper; + +import icepick.State; +import io.reactivex.rxjava3.core.Single; + +public class ChannelTabFragment extends BaseListInfoFragment { + + @State + protected int serviceId = Constants.NO_SERVICE_ID; + + @State + protected ChannelTabHandler tabHandler; + + public static ChannelTabFragment getInstance(final int serviceId, + final ChannelTabHandler tabHandler) { + final ChannelTabFragment instance = new ChannelTabFragment(); + instance.serviceId = serviceId; + instance.tabHandler = tabHandler; + return instance; + } + + public ChannelTabFragment() { + super(UserAction.REQUESTED_CHANNEL); + } + + /*////////////////////////////////////////////////////////////////////////// + // LifeCycle + //////////////////////////////////////////////////////////////////////////*/ + + @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 + 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) { + } +} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelVideosFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelVideosFragment.java new file mode 100644 index 000000000..9f8c83ce7 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelVideosFragment.java @@ -0,0 +1,584 @@ +package org.schabi.newpipe.fragments.list.channel; + +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.graphics.Color; +import android.os.Bundle; +import android.text.TextUtils; +import android.util.Log; +import android.util.TypedValue; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +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 com.google.android.material.snackbar.Snackbar; +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.FragmentChannelVideosBinding; +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.ktx.AnimationType; +import org.schabi.newpipe.local.feed.notifications.NotificationHelper; +import org.schabi.newpipe.local.subscription.SubscriptionManager; +import org.schabi.newpipe.player.PlayerType; +import org.schabi.newpipe.player.playqueue.ChannelPlayQueue; +import org.schabi.newpipe.player.playqueue.PlayQueue; +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.ThemeHelper; +import org.schabi.newpipe.util.external_communication.ShareUtils; + +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.function.Supplier; +import java.util.stream.Collectors; + +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; +import io.reactivex.rxjava3.functions.Consumer; +import io.reactivex.rxjava3.functions.Function; +import io.reactivex.rxjava3.schedulers.Schedulers; + +public class ChannelVideosFragment extends BaseListInfoFragment + implements View.OnClickListener { + + private static final int BUTTON_DEBOUNCE_INTERVAL = 100; + private static final String PICASSO_CHANNEL_TAG = "PICASSO_CHANNEL_TAG"; + + private final CompositeDisposable disposables = new CompositeDisposable(); + private Disposable subscribeButtonMonitor; + + private boolean channelContentNotSupported = false; + + /*////////////////////////////////////////////////////////////////////////// + // Views + //////////////////////////////////////////////////////////////////////////*/ + + private SubscriptionManager subscriptionManager; + + private FragmentChannelVideosBinding channelBinding; + private ChannelHeaderBinding headerBinding; + private PlaylistControlBinding playlistControlBinding; + + private MenuItem menuNotifyButton; + + public static ChannelVideosFragment getInstance(@NonNull final ChannelInfo channelInfo) { + final ChannelVideosFragment instance = new ChannelVideosFragment(); + instance.setInitialData(channelInfo.getServiceId(), channelInfo.getUrl(), + channelInfo.getName()); + instance.currentInfo = channelInfo; + instance.currentNextPage = channelInfo.getNextPage(); + return instance; + } + + public static ChannelVideosFragment getInstance( + final int serviceId, final String url, final String name) { + final ChannelVideosFragment instance = new ChannelVideosFragment(); + instance.setInitialData(serviceId, url, name); + return instance; + } + + public ChannelVideosFragment() { + super(UserAction.REQUESTED_CHANNEL); + } + + @Override + public void onResume() { + super.onResume(); + if (activity != null && useAsFrontPage) { + setTitle(currentInfo != null ? currentInfo.getName() : name); + } + } + + /*////////////////////////////////////////////////////////////////////////// + // LifeCycle + //////////////////////////////////////////////////////////////////////////*/ + + @Override + public void onAttach(@NonNull final Context context) { + super.onAttach(context); + subscriptionManager = new SubscriptionManager(activity); + } + + @Override + public View onCreateView(@NonNull final LayoutInflater inflater, + @Nullable final ViewGroup container, + @Nullable final Bundle savedInstanceState) { + return inflater.inflate(R.layout.fragment_channel_videos, container, false); + } + + @Override + public void onViewCreated(@NonNull final View rootView, final Bundle savedInstanceState) { + super.onViewCreated(rootView, savedInstanceState); + channelBinding = FragmentChannelVideosBinding.bind(rootView); + showContentNotSupportedIfNeeded(); + } + + @Override + public void onDestroy() { + super.onDestroy(); + disposables.clear(); + if (subscribeButtonMonitor != null) { + subscribeButtonMonitor.dispose(); + } + channelBinding = null; + headerBinding = null; + playlistControlBinding = null; + } + + /*////////////////////////////////////////////////////////////////////////// + // Init + //////////////////////////////////////////////////////////////////////////*/ + + @Override + protected Supplier getListHeaderSupplier() { + headerBinding = ChannelHeaderBinding + .inflate(activity.getLayoutInflater(), itemsList, false); + playlistControlBinding = headerBinding.playlistControl; + + return headerBinding::getRoot; + } + + @Override + protected void initListeners() { + super.initListeners(); + + headerBinding.subChannelTitleView.setOnClickListener(this); + headerBinding.subChannelAvatarView.setOnClickListener(this); + } + + /*////////////////////////////////////////////////////////////////////////// + // Menu + //////////////////////////////////////////////////////////////////////////*/ + + @Override + 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_videos, menu); + + if (DEBUG) { + Log.d(TAG, "onCreateOptionsMenu() called with: " + + "menu = [" + menu + "], inflater = [" + inflater + "]"); + } + menuNotifyButton = menu.findItem(R.id.menu_item_notify); + } + } + + @Override + public boolean onOptionsItemSelected(final MenuItem item) { + switch (item.getItemId()) { + case R.id.menu_item_notify: + final boolean value = !item.isChecked(); + item.setEnabled(false); + setNotify(value); + break; + default: + return super.onOptionsItemSelected(item); + } + return true; + } + + private void updateNotifyButton(@Nullable final SubscriptionEntity subscription) { + if (menuNotifyButton == null) { + return; + } + if (subscription != null) { + menuNotifyButton.setEnabled( + NotificationHelper.areNewStreamsNotificationsEnabled(requireContext()) + ); + menuNotifyButton.setChecked( + subscription.getNotificationMode() == NotificationMode.ENABLED + ); + } + + menuNotifyButton.setVisible(subscription != null); + } + + private void setNotify(final boolean isEnabled) { + disposables.add( + subscriptionManager + .updateNotificationMode( + currentInfo.getServiceId(), + currentInfo.getUrl(), + isEnabled ? NotificationMode.ENABLED : NotificationMode.DISABLED) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe() + ); + } + + /*////////////////////////////////////////////////////////////////////////// + // Channel Subscription + //////////////////////////////////////////////////////////////////////////*/ + + private void monitorSubscription(final ChannelInfo info) { + final Consumer onError = (Throwable throwable) -> { + animate(headerBinding.channelSubscribeButton, false, 100); + showSnackBarError(new ErrorInfo(throwable, UserAction.SUBSCRIPTION_GET, + "Get subscription status", currentInfo)); + }; + + final Observable> observable = subscriptionManager + .subscriptionTable() + .getSubscriptionFlowable(info.getServiceId(), info.getUrl()) + .toObservable(); + + disposables.add(observable + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(getSubscribeUpdateMonitor(info), onError)); + + disposables.add(observable + .map(List::isEmpty) + .distinctUntilChanged() + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(isEmpty -> updateSubscribeButton(!isEmpty), onError)); + + disposables.add(observable + .map(List::isEmpty) + .distinctUntilChanged() + .skip(1) // channel has just been opened + .filter(x -> NotificationHelper.areNewStreamsNotificationsEnabled(requireContext())) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(isEmpty -> { + if (!isEmpty) { + showNotifySnackbar(); + } + }, onError)); + } + + private Function mapOnSubscribe(final SubscriptionEntity subscription, + final ChannelInfo info) { + return (@NonNull Object o) -> { + subscriptionManager.insertSubscription(subscription, info); + return o; + }; + } + + private Function mapOnUnsubscribe(final SubscriptionEntity subscription) { + return (@NonNull Object o) -> { + subscriptionManager.deleteSubscription(subscription); + return o; + }; + } + + private void updateSubscription(final ChannelInfo info) { + if (DEBUG) { + Log.d(TAG, "updateSubscription() called with: info = [" + info + "]"); + } + final Action onComplete = () -> { + if (DEBUG) { + Log.d(TAG, "Updated subscription: " + info.getUrl()); + } + }; + + final Consumer onError = (@NonNull Throwable throwable) -> + showSnackBarError(new ErrorInfo(throwable, UserAction.SUBSCRIPTION_UPDATE, + "Updating subscription for " + info.getUrl(), info)); + + disposables.add(subscriptionManager.updateChannelInfo(info) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(onComplete, onError)); + } + + private Disposable monitorSubscribeButton(final Button subscribeButton, + final Function action) { + final Consumer onNext = (@NonNull Object o) -> { + if (DEBUG) { + Log.d(TAG, "Changed subscription status to this channel!"); + } + }; + + final Consumer onError = (@NonNull Throwable throwable) -> + showSnackBarError(new ErrorInfo(throwable, UserAction.SUBSCRIPTION_CHANGE, + "Changing subscription for " + currentInfo.getUrl(), currentInfo)); + + /* Emit clicks from main thread unto io thread */ + return RxView.clicks(subscribeButton) + .subscribeOn(AndroidSchedulers.mainThread()) + .observeOn(Schedulers.io()) + .debounce(BUTTON_DEBOUNCE_INTERVAL, TimeUnit.MILLISECONDS) // Ignore rapid clicks + .map(action) + .subscribe(onNext, onError); + } + + private Consumer> getSubscribeUpdateMonitor(final ChannelInfo info) { + return (List subscriptionEntities) -> { + if (DEBUG) { + Log.d(TAG, "subscriptionManager.subscriptionTable.doOnNext() called with: " + + "subscriptionEntities = [" + subscriptionEntities + "]"); + } + if (subscribeButtonMonitor != null) { + subscribeButtonMonitor.dispose(); + } + + if (subscriptionEntities.isEmpty()) { + if (DEBUG) { + Log.d(TAG, "No subscription to this channel!"); + } + final SubscriptionEntity channel = new SubscriptionEntity(); + channel.setServiceId(info.getServiceId()); + channel.setUrl(info.getUrl()); + channel.setData(info.getName(), + info.getAvatarUrl(), + info.getDescription(), + info.getSubscriberCount()); + updateNotifyButton(null); + subscribeButtonMonitor = monitorSubscribeButton( + headerBinding.channelSubscribeButton, mapOnSubscribe(channel, info)); + } 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)); + } + }; + } + + private void updateSubscribeButton(final boolean isSubscribed) { + if (DEBUG) { + Log.d(TAG, "updateSubscribeButton() called with: " + + "isSubscribed = [" + isSubscribed + "]"); + } + + final boolean isButtonVisible = headerBinding.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); + + 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, + subscribeBackground, subscribedBackground); + animateTextColor(headerBinding.channelSubscribeButton, textDuration, subscribeText, + subscribedText); + } + + animate(headerBinding.channelSubscribeButton, true, 100, + AnimationType.LIGHT_SCALE_AND_ALPHA); + } + + /** + * 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) + .setAction(R.string.get_notified, v -> setNotify(true)) + .setActionTextColor(Color.YELLOW) + .show(); + } + + /*////////////////////////////////////////////////////////////////////////// + // Load and handle + //////////////////////////////////////////////////////////////////////////*/ + + @Override + protected Single> loadMoreItemsLogic() { + return ExtractorHelper.getMoreChannelItems(serviceId, url, currentNextPage); + } + + @Override + protected Single loadResult(final boolean forceLoad) { + return ExtractorHelper.getChannelInfo(serviceId, url, forceLoad); + } + + /*////////////////////////////////////////////////////////////////////////// + // OnClick + //////////////////////////////////////////////////////////////////////////*/ + + @Override + public void onClick(final View v) { + if (isLoading.get() || currentInfo == null) { + return; + } + + 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; + } + } + + /*////////////////////////////////////////////////////////////////////////// + // Contract + //////////////////////////////////////////////////////////////////////////*/ + + @Override + public void showLoading() { + super.showLoading(); + PicassoHelper.cancelTag(PICASSO_CHANNEL_TAG); + animate(headerBinding.channelSubscribeButton, false, 100); + } + + @Override + public void handleResult(@NonNull final ChannelInfo result) { + super.handleResult(result); + + 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); + PicassoHelper.loadAvatar(result.getParentChannelAvatarUrl()).tag(PICASSO_CHANNEL_TAG) + .into(headerBinding.subChannelAvatarView); + + headerBinding.channelSubscriberView.setVisibility(View.VISIBLE); + if (result.getSubscriberCount() >= 0) { + headerBinding.channelSubscriberView.setText(Localization + .shortSubscriberCount(activity, result.getSubscriberCount())); + } else { + headerBinding.channelSubscriberView.setText(R.string.subscribers_count_not_available); + } + + if (!TextUtils.isEmpty(currentInfo.getParentChannelName())) { + headerBinding.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); + } + + // updateRssButton(); + + // 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) { + channelContentNotSupported = true; + showContentNotSupportedIfNeeded(); + break; + } + } + + disposables.clear(); + if (subscribeButtonMonitor != null) { + subscribeButtonMonitor.dispose(); + } + 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) { + return; + } + + channelBinding.errorContentNotSupported.setVisibility(View.VISIBLE); + channelBinding.channelKaomoji.setText("(︶︹︺)"); + channelBinding.channelKaomoji.setTextSize(TypedValue.COMPLEX_UNIT_SP, 45f); + channelBinding.channelNoVideos.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); + headerBinding.channelTitleView.setText(title); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/settings/tabs/Tab.java b/app/src/main/java/org/schabi/newpipe/settings/tabs/Tab.java index 7e3f5d0c8..a06bf32d4 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/tabs/Tab.java +++ b/app/src/main/java/org/schabi/newpipe/settings/tabs/Tab.java @@ -19,7 +19,7 @@ import org.schabi.newpipe.extractor.NewPipe; import org.schabi.newpipe.extractor.StreamingService; import org.schabi.newpipe.extractor.exceptions.ExtractionException; import org.schabi.newpipe.fragments.BlankFragment; -import org.schabi.newpipe.fragments.list.channel.ChannelFragment; +import org.schabi.newpipe.fragments.list.channel.ChannelVideosFragment; import org.schabi.newpipe.fragments.list.kiosk.DefaultKioskFragment; import org.schabi.newpipe.fragments.list.kiosk.KioskFragment; import org.schabi.newpipe.fragments.list.playlist.PlaylistFragment; @@ -432,8 +432,8 @@ public abstract class Tab { } @Override - public ChannelFragment getFragment(final Context context) { - return ChannelFragment.getInstance(channelServiceId, channelUrl, channelName); + public ChannelVideosFragment getFragment(final Context context) { + return ChannelVideosFragment.getInstance(channelServiceId, channelUrl, channelName); } @Override 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 d5d472d6f..b4648c79b 100644 --- a/app/src/main/java/org/schabi/newpipe/util/ExtractorHelper.java +++ b/app/src/main/java/org/schabi/newpipe/util/ExtractorHelper.java @@ -42,11 +42,13 @@ 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.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.ChannelTabHandler; import org.schabi.newpipe.extractor.playlist.PlaylistInfo; import org.schabi.newpipe.extractor.search.SearchInfo; import org.schabi.newpipe.extractor.stream.StreamInfo; @@ -151,6 +153,25 @@ public final class ExtractorHelper { return maybeFeedInfo.switchIfEmpty(getChannelInfo(serviceId, url, true)); } + public static Single getChannelTab(final int serviceId, + final ChannelTabHandler tabHandler, + final boolean forceLoad) { + checkServiceId(serviceId); + return checkCache(forceLoad, serviceId, + tabHandler.getUrl() + tabHandler.getTab().name(), InfoItem.InfoType.CHANNEL, + Single.fromCallable(() -> + ChannelTabInfo.getInfo(NewPipe.getService(serviceId), tabHandler))); + } + + public static Single> getMoreChannelTabItems(final int serviceId, + final ChannelTabHandler + tabHandler, + final Page nextPage) { + checkServiceId(serviceId); + return Single.fromCallable(() -> + ChannelTabInfo.getMoreItems(NewPipe.getService(serviceId), tabHandler, nextPage)); + } + public static Single getCommentsInfo(final int serviceId, final String url, final boolean forceLoad) { checkServiceId(serviceId); @@ -229,7 +250,7 @@ public final class ExtractorHelper { load = actualLoadFromNetwork; } else { load = Maybe.concat(ExtractorHelper.loadFromCache(serviceId, url, infoType), - actualLoadFromNetwork.toMaybe()) + actualLoadFromNetwork.toMaybe()) .firstElement() // Take the first valid .toSingle(); } @@ -240,10 +261,10 @@ public final class ExtractorHelper { /** * 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 +295,12 @@ public final class ExtractorHelper { * 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 +309,7 @@ public final class ExtractorHelper { 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/res/layout/fragment_channel.xml b/app/src/main/res/layout/fragment_channel.xml index 714b9d4f9..d938f71a7 100644 --- a/app/src/main/res/layout/fragment_channel.xml +++ b/app/src/main/res/layout/fragment_channel.xml @@ -1,15 +1,25 @@ - + + + android:layout_below="@id/tab_layout" /> - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_channel_info.xml b/app/src/main/res/layout/fragment_channel_info.xml new file mode 100644 index 000000000..fbb8e355b --- /dev/null +++ b/app/src/main/res/layout/fragment_channel_info.xml @@ -0,0 +1,36 @@ + + + + + + + + \ 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 000000000..519156296 --- /dev/null +++ b/app/src/main/res/layout/fragment_channel_tab.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + \ No newline at end of file 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 000000000..2dfb2fbf6 --- /dev/null +++ b/app/src/main/res/layout/fragment_channel_videos.xml @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/menu/menu_channel_videos.xml b/app/src/main/res/menu/menu_channel_videos.xml new file mode 100644 index 000000000..a3b2e7ae0 --- /dev/null +++ b/app/src/main/res/menu/menu_channel_videos.xml @@ -0,0 +1,14 @@ + + + +