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 96de433f5..9de143518 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,16 @@ 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.content.SharedPreferences; +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; @@ -14,43 +20,59 @@ import android.view.ViewGroup; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +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.FragmentChannelBinding; import org.schabi.newpipe.error.ErrorInfo; +import org.schabi.newpipe.error.ErrorUtil; import org.schabi.newpipe.error.UserAction; import org.schabi.newpipe.extractor.channel.ChannelInfo; import org.schabi.newpipe.extractor.exceptions.ContentNotSupportedException; 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.feed.notifications.NotificationHelper; 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 icepick.State; import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; import io.reactivex.rxjava3.core.Observable; 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 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 @@ -60,13 +82,11 @@ public class ChannelFragment extends BaseStateFragment private ChannelInfo currentInfo; private Disposable currentWorker; - private Disposable subscriptionMonitor; private final CompositeDisposable disposables = new CompositeDisposable(); + private Disposable subscribeButtonMonitor; private SubscriptionManager subscriptionManager; private int lastTab; - - private MenuItem menuRssButton; - private MenuItem menuNotifyButton; + private boolean channelContentNotSupported = false; /*////////////////////////////////////////////////////////////////////////// // Views @@ -75,6 +95,9 @@ public class ChannelFragment extends BaseStateFragment private FragmentChannelBinding binding; private TabAdapter tabAdapter; + private MenuItem menuRssButton; + private MenuItem menuNotifyButton; + public static ChannelFragment getInstance(final int serviceId, final String url, final String name) { final ChannelFragment instance = new ChannelFragment(); @@ -82,12 +105,13 @@ public class ChannelFragment extends BaseStateFragment return instance; } - protected void setInitialData(final int sid, final String u, final String title) { + private void setInitialData(final int sid, final String u, final String title) { this.serviceId = sid; this.url = u; this.name = !TextUtils.isEmpty(title) ? title : ""; } + /*////////////////////////////////////////////////////////////////////////// // LifeCycle //////////////////////////////////////////////////////////////////////////*/ @@ -96,12 +120,6 @@ public class ChannelFragment extends BaseStateFragment public void onCreate(final Bundle savedInstanceState) { super.onCreate(savedInstanceState); setHasOptionsMenu(true); - - if (savedInstanceState != null) { - lastTab = savedInstanceState.getInt("LastTab"); - } else { - lastTab = 0; - } } @Override @@ -125,14 +143,29 @@ public class ChannelFragment extends BaseStateFragment tabAdapter = new TabAdapter(getChildFragmentManager()); binding.viewPager.setAdapter(tabAdapter); binding.tabLayout.setupWithViewPager(binding.viewPager); + + binding.channelTitleView.setText(name); } @Override - public void onSaveInstanceState(final @NonNull Bundle outState) { - super.onSaveInstanceState(outState); - if (binding != null) { - outState.putInt("LastTab", binding.tabLayout.getSelectedTabPosition()); - } + protected void initListeners() { + super.initListeners(); + + 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 @@ -141,14 +174,12 @@ public class ChannelFragment extends BaseStateFragment if (currentWorker != null) { currentWorker.dispose(); } - if (subscriptionMonitor != null) { - subscriptionMonitor.dispose(); - } disposables.clear(); binding = null; } - /*////////////////////////////////////////////////////////////////////////// + + /*////////////////////////////////////////////////////////////////////////// // Menu //////////////////////////////////////////////////////////////////////////*/ @@ -164,8 +195,6 @@ public class ChannelFragment extends BaseStateFragment } menuRssButton = menu.findItem(R.id.menu_item_rss); menuNotifyButton = menu.findItem(R.id.menu_item_notify); - updateRssButton(); - monitorSubscription(); } @Override @@ -201,37 +230,168 @@ public class ChannelFragment extends BaseStateFragment return true; } - private void updateRssButton() { - if (currentInfo != null && menuRssButton != null) { - menuRssButton.setVisible(!TextUtils.isEmpty(currentInfo.getFeedUrl())); - } + + /*////////////////////////////////////////////////////////////////////////// + // Channel Subscription + //////////////////////////////////////////////////////////////////////////*/ + + private void monitorSubscription(final ChannelInfo info) { + final Consumer onError = (Throwable throwable) -> { + animate(binding.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 void monitorSubscription() { - if (currentInfo != null) { - final Observable> observable = subscriptionManager - .subscriptionTable() - .getSubscriptionFlowable(currentInfo.getServiceId(), currentInfo.getUrl()) - .toObservable(); - - if (subscriptionMonitor != null) { - subscriptionMonitor.dispose(); - } - subscriptionMonitor = observable - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(getSubscribeUpdateMonitor()); - } + private Function mapOnSubscribe(final SubscriptionEntity subscription, + final ChannelInfo info) { + return (@NonNull Object o) -> { + subscriptionManager.insertSubscription(subscription, info); + return o; + }; } - private Consumer> getSubscribeUpdateMonitor() { - return (List subscriptionEntities) -> { - if (subscriptionEntities.isEmpty()) { - updateNotifyButton(null); - } else { - final SubscriptionEntity subscription = subscriptionEntities.get(0); - updateNotifyButton(subscription); + 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 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(binding.channelSubscribeButton) + .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(mapOnSubscribe(channel, info)); + } else { + if (DEBUG) { + Log.d(TAG, "Found subscription to this channel!"); + } + final SubscriptionEntity subscription = subscriptionEntities.get(0); + updateNotifyButton(subscription); + subscribeButtonMonitor = monitorSubscribeButton(mapOnUnsubscribe(subscription)); + } + }; + } + + private void updateSubscribeButton(final boolean isSubscribed) { + if (DEBUG) { + Log.d(TAG, "updateSubscribeButton() called with: " + + "isSubscribed = [" + isSubscribed + "]"); + } + + final boolean isButtonVisible = binding.channelSubscribeButton.getVisibility() + == View.VISIBLE; + final int backgroundDuration = isButtonVisible ? 300 : 0; + final int textDuration = isButtonVisible ? 200 : 0; + + 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) { + binding.channelSubscribeButton.setText(R.string.subscribed_button_title); + animateBackgroundColor(binding.channelSubscribeButton, backgroundDuration, + subscribeBackground, subscribedBackground); + 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(binding.channelSubscribeButton, true, 100, AnimationType.LIGHT_SCALE_AND_ALPHA); } private void updateNotifyButton(@Nullable final SubscriptionEntity subscription) { @@ -263,52 +423,48 @@ public class ChannelFragment extends BaseStateFragment ); } + /** + * Show a snackbar with the option to enable notifications on new streams for this channel. + */ + private void showNotifySnackbar() { + Snackbar.make(binding.getRoot(), R.string.you_successfully_subscribed, Snackbar.LENGTH_LONG) + .setAction(R.string.get_notified, v -> setNotify(true)) + .setActionTextColor(Color.YELLOW) + .show(); + } + + /*////////////////////////////////////////////////////////////////////////// // Init //////////////////////////////////////////////////////////////////////////*/ - private boolean isContentUnsupported() { - for (final Throwable throwable : currentInfo.getErrors()) { - if (throwable instanceof ContentNotSupportedException) { - return true; - } - } - return false; - } - private void updateTabs() { tabAdapter.clearAllItems(); - if (currentInfo != null) { - if (isContentUnsupported()) { - showEmptyState(); - binding.errorContentNotSupported.setVisibility(View.VISIBLE); - } else { - tabAdapter.addFragment( - ChannelVideosFragment.getInstance(currentInfo), "Videos"); + if (currentInfo != null && !channelContentNotSupported) { + tabAdapter.addFragment(new ChannelVideosFragment(currentInfo), "Videos"); - final Context context = getContext(); - final SharedPreferences preferences = PreferenceManager - .getDefaultSharedPreferences(context); + 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)) { - tabAdapter.addFragment( - ChannelTabFragment.getInstance(serviceId, linkHandler, name), - context.getString(ChannelTabHelper.getTranslationKey(tab))); - } - } - - final String description = currentInfo.getDescription(); - if (description != null && !description.isEmpty() - && ChannelTabHelper.showChannelTab( - context, preferences, R.string.show_channel_tabs_about)) { + for (final ListLinkHandler linkHandler : currentInfo.getTabs()) { + final String tab = linkHandler.getContentFilters().get(0); + if (ChannelTabHelper.showChannelTab(context, preferences, tab)) { tabAdapter.addFragment( - ChannelAboutFragment.getInstance(currentInfo), - context.getString(R.string.channel_tab_about)); + ChannelTabFragment.getInstance(serviceId, linkHandler, name), + context.getString(ChannelTabHelper.getTranslationKey(tab))); } } + + final String description = currentInfo.getDescription(); + if (description != null && !description.isEmpty() + && ChannelTabHelper.showChannelTab( + context, preferences, R.string.show_channel_tabs_about)) { + tabAdapter.addFragment( + ChannelAboutFragment.getInstance(currentInfo), + context.getString(R.string.channel_tab_about)); + } } tabAdapter.notifyDataSetUpdate(); @@ -324,6 +480,7 @@ public class ChannelFragment extends BaseStateFragment } } + /*////////////////////////////////////////////////////////////////////////// // State Saving //////////////////////////////////////////////////////////////////////////*/ @@ -336,11 +493,7 @@ public class ChannelFragment extends BaseStateFragment @Override public void writeTo(final Queue objectsToSave) { objectsToSave.add(currentInfo); - if (binding != null) { - objectsToSave.add(binding.tabLayout.getSelectedTabPosition()); - } else { - objectsToSave.add(0); - } + objectsToSave.add(binding == null ? 0 : binding.tabLayout.getSelectedTabPosition()); } @Override @@ -349,6 +502,25 @@ public class ChannelFragment extends BaseStateFragment lastTab = (Integer) savedObjects.poll(); } + @Override + public void onSaveInstanceState(final @NonNull Bundle outState) { + super.onSaveInstanceState(outState); + if (binding != null) { + outState.putInt("LastTab", binding.tabLayout.getSelectedTabPosition()); + } + } + + @Override + protected void onRestoreInstanceState(@NonNull final Bundle savedInstanceState) { + super.onRestoreInstanceState(savedInstanceState); + lastTab = savedInstanceState.getInt("LastTab", 0); + } + + + /*////////////////////////////////////////////////////////////////////////// + // Contract + //////////////////////////////////////////////////////////////////////////*/ + @Override protected void doInitialLoadLogic() { if (currentInfo == null) { @@ -382,14 +554,77 @@ public class ChannelFragment extends BaseStateFragment url == null ? "no url" : url, serviceId))); } + @Override + public void showLoading() { + super.showLoading(); + PicassoHelper.cancelTag(PICASSO_CHANNEL_TAG); + 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()); + binding.getRoot().setVisibility(View.VISIBLE); + PicassoHelper.loadBanner(result.getBannerUrl()).tag(PICASSO_CHANNEL_TAG) + .into(binding.channelBannerImage); + PicassoHelper.loadAvatar(result.getAvatarUrl()).tag(PICASSO_CHANNEL_TAG) + .into(binding.channelAvatarView); + PicassoHelper.loadAvatar(result.getParentChannelAvatarUrl()).tag(PICASSO_CHANNEL_TAG) + .into(binding.subChannelAvatarView); + + binding.channelTitleView.setText(result.getName()); + binding.channelSubscriberView.setVisibility(View.VISIBLE); + if (result.getSubscriberCount() >= 0) { + binding.channelSubscriberView.setText(Localization + .shortSubscriberCount(activity, result.getSubscriberCount())); + } else { + binding.channelSubscriberView.setText(R.string.subscribers_count_not_available); + } + + if (!TextUtils.isEmpty(currentInfo.getParentChannelName())) { + binding.subChannelTitleView.setText(String.format( + getString(R.string.channel_created_by), + currentInfo.getParentChannelName()) + ); + binding.subChannelTitleView.setVisibility(View.VISIBLE); + binding.subChannelAvatarView.setVisibility(View.VISIBLE); + } + + if (menuRssButton != null) { + menuRssButton.setVisible(!TextUtils.isEmpty(result.getFeedUrl())); + } + + channelContentNotSupported = false; + for (final Throwable throwable : result.getErrors()) { + if (throwable instanceof ContentNotSupportedException) { + channelContentNotSupported = true; + showContentNotSupportedIfNeeded(); + break; + } + } + + disposables.clear(); + if (subscribeButtonMonitor != null) { + subscribeButtonMonitor.dispose(); + } + updateTabs(); - updateRssButton(); - monitorSubscription(); + updateSubscription(result); + monitorSubscription(result); + } + + private void showContentNotSupportedIfNeeded() { + // channelBinding might not be initialized when handleResult() is called + // (e.g. after rotating the screen, #6696) + if (!channelContentNotSupported || binding == null) { + return; + } + + 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/ChannelVideosFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelVideosFragment.java index a38b913d6..a2d50836b 100644 --- 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 @@ -1,109 +1,61 @@ 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.View; import android.view.ViewGroup; -import android.widget.Button; import androidx.annotation.NonNull; import androidx.annotation.Nullable; -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 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"; +public class ChannelVideosFragment extends BaseListInfoFragment { 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; - 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; - } + /*////////////////////////////////////////////////////////////////////////// + // Constructors and lifecycle + //////////////////////////////////////////////////////////////////////////*/ + // required by the Android framework to restore fragments after saving public ChannelVideosFragment() { super(UserAction.REQUESTED_CHANNEL); } + public ChannelVideosFragment(final int serviceId, final String url, final String name) { + this(); + setInitialData(serviceId, url, name); + } + + public ChannelVideosFragment(@NonNull final ChannelInfo info) { + this(info.getServiceId(), info.getUrl(), info.getName()); + this.currentInfo = info; + this.currentNextPage = info.getNextPage(); + } + @Override public void onResume() { super.onResume(); @@ -112,22 +64,12 @@ public class ChannelVideosFragment extends BaseListInfoFragment getListHeaderSupplier() { - headerBinding = ChannelHeaderBinding + playlistControlBinding = PlaylistControlBinding .inflate(activity.getLayoutInflater(), itemsList, false); - playlistControlBinding = headerBinding.playlistControl; - - return headerBinding::getRoot; + return playlistControlBinding::getRoot; } - @Override - protected void initListeners() { - super.initListeners(); - - headerBinding.subChannelTitleView.setOnClickListener(this); - headerBinding.subChannelAvatarView.setOnClickListener(this); - } /*////////////////////////////////////////////////////////////////////////// - // 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()); - subscribeButtonMonitor = monitorSubscribeButton( - headerBinding.channelSubscribeButton, mapOnSubscribe(channel, info)); - } else { - if (DEBUG) { - Log.d(TAG, "Found subscription to this channel!"); - } - final SubscriptionEntity subscription = subscriptionEntities.get(0); - 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(); - } - - 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() - ); - } - - /*////////////////////////////////////////////////////////////////////////// - // Load and handle + // Loading //////////////////////////////////////////////////////////////////////////*/ @Override @@ -377,76 +108,15 @@ public class ChannelVideosFragment extends BaseListInfoFragment= 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); - } - // PlaylistControls should be visible only if there is some item in // infoListAdapter other than header if (infoListAdapter.getItemCount() != 1) { @@ -455,31 +125,14 @@ public class ChannelVideosFragment extends BaseListInfoFragment NavigationHelper - .playOnMainPlayer(activity, getPlayQueue())); - playlistControlBinding.playlistCtrlPlayPopupButton - .setOnClickListener(view -> NavigationHelper - .playOnPopupPlayer(activity, getPlayQueue(), false)); - playlistControlBinding.playlistCtrlPlayBgButton - .setOnClickListener(view -> NavigationHelper - .playOnBackgroundPlayer(activity, getPlayQueue(), false)); + 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); @@ -492,19 +145,6 @@ public class ChannelVideosFragment extends BaseListInfoFragment streamItems = infoListAdapter.getItemsList().stream() .filter(StreamInfoItem.class::isInstance) @@ -514,14 +154,4 @@ public class ChannelVideosFragment extends BaseListInfoFragment - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/fragment_channel.xml b/app/src/main/res/layout/fragment_channel.xml index db77391bc..29d9143c5 100644 --- a/app/src/main/res/layout/fragment_channel.xml +++ b/app/src/main/res/layout/fragment_channel.xml @@ -1,75 +1,207 @@ - - + app:elevation="0dp"> - + + + + + + + + + + + + + + + + + + + + + - - - - + 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 + + + + + + + + \ 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 77e18695d..46244b3c9 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 e47b72c9a..0e5fd126f 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