diff --git a/app/src/main/java/org/schabi/newpipe/database/Converters.java b/app/src/main/java/org/schabi/newpipe/database/Converters.java new file mode 100644 index 000000000..3203e2b3c --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/database/Converters.java @@ -0,0 +1,17 @@ +package org.schabi.newpipe.database; + +import android.arch.persistence.room.TypeConverter; + +import java.util.Date; + +public class Converters { + @TypeConverter + public static Date fromTimestamp(Long value) { + return value == null ? null : new Date(value); + } + + @TypeConverter + public static Long dateToTimestamp(Date date) { + return date == null ? null : date.getTime(); + } +} \ No newline at end of file diff --git a/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistEntity.java b/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistEntity.java new file mode 100644 index 000000000..76cef7ef5 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistEntity.java @@ -0,0 +1,70 @@ +package org.schabi.newpipe.database.playlist; + +import android.arch.persistence.room.ColumnInfo; +import android.arch.persistence.room.Entity; +import android.arch.persistence.room.Ignore; +import android.arch.persistence.room.PrimaryKey; + +import static org.schabi.newpipe.database.playlist.PlaylistEntity.PLAYLIST_TABLE; + +@Entity(tableName = PLAYLIST_TABLE) +public class PlaylistEntity { + + final static String PLAYLIST_TABLE = "playlists"; + final static String PLAYLIST_URL = "url"; + final static String PLAYLIST_TITLE = "title"; + final static String PLAYLIST_THUMBNAIL_URL = "thumbnail_url"; + + @PrimaryKey(autoGenerate = true) + private long uid = 0; + + @ColumnInfo(name = PLAYLIST_TITLE) + private String title; + + /* This is used as a reference to the source, should this playlist be dynamic */ + @ColumnInfo(name = PLAYLIST_URL) + private String url; + + @ColumnInfo(name = PLAYLIST_THUMBNAIL_URL) + private String thumbnailUrl; + + public long getUid() { + return uid; + } + + /* Keep this package-private since UID should always be auto generated by Room impl */ + void setUid(long uid) { + this.uid = uid; + } + + public String getUrl() { + return url; + } + + public void setUrl(String url) { + this.url = url; + } + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + public String getThumbnailUrl() { + return thumbnailUrl; + } + + public void setThumbnailUrl(String thumbnailUrl) { + this.thumbnailUrl = thumbnailUrl; + } + + @Ignore + public void setData(final String title, + final String thumbnailUrl) { + this.setTitle(title); + this.setThumbnailUrl(thumbnailUrl); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/database/stream/StreamDAO.java b/app/src/main/java/org/schabi/newpipe/database/stream/StreamDAO.java new file mode 100644 index 000000000..31e156cc8 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/database/stream/StreamDAO.java @@ -0,0 +1,24 @@ +package org.schabi.newpipe.database.stream; + +import android.arch.persistence.room.Dao; +import android.arch.persistence.room.Query; + +import org.schabi.newpipe.database.BasicDAO; + +import java.util.List; + +import io.reactivex.Flowable; + +import static org.schabi.newpipe.database.stream.StreamEntity.STREAM_SERVICE_ID; +import static org.schabi.newpipe.database.stream.StreamEntity.STREAM_TABLE; + +@Dao +public interface StreamDAO extends BasicDAO { + @Override + @Query("SELECT * FROM " + STREAM_TABLE) + Flowable> findAll(); + + @Override + @Query("SELECT * FROM " + STREAM_TABLE + " WHERE " + STREAM_SERVICE_ID + " = :serviceId") + Flowable> listByService(int serviceId); +} diff --git a/app/src/main/java/org/schabi/newpipe/database/stream/StreamEntity.java b/app/src/main/java/org/schabi/newpipe/database/stream/StreamEntity.java new file mode 100644 index 000000000..20eddb38a --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/database/stream/StreamEntity.java @@ -0,0 +1,207 @@ +package org.schabi.newpipe.database.stream; + +import android.arch.persistence.room.ColumnInfo; +import android.arch.persistence.room.Entity; +import android.arch.persistence.room.Ignore; +import android.arch.persistence.room.Index; +import android.arch.persistence.room.PrimaryKey; + +import org.schabi.newpipe.extractor.AbstractStreamInfo; +import org.schabi.newpipe.extractor.stream_info.StreamInfoItem; + +import java.util.Date; + +import static org.schabi.newpipe.database.stream.StreamEntity.STREAM_SERVICE_ID; +import static org.schabi.newpipe.database.stream.StreamEntity.STREAM_TABLE; +import static org.schabi.newpipe.database.stream.StreamEntity.STREAM_URL; + +@Entity(tableName = STREAM_TABLE, + indices = {@Index(value = {STREAM_SERVICE_ID, STREAM_URL}, unique = true)}) +public class StreamEntity { + public final static String STREAM_UID = "uid"; + + final static String STREAM_TABLE = "streams"; + final static String STREAM_ID = "id"; + final static String STREAM_TYPE = "type"; + final static String STREAM_SERVICE_ID = "service_id"; + final static String STREAM_URL = "url"; + final static String STREAM_TITLE = "title"; + final static String STREAM_THUMBNAIL_URL = "thumbnail_url"; + final static String STREAM_VIEW_COUNT = "view_count"; + final static String STREAM_UPLOADER = "uploader"; + final static String STREAM_UPLOAD_DATE = "upload_date"; + final static String STREAM_DURATION = "duration"; + + @PrimaryKey(autoGenerate = true) + private long uid = 0; + + @ColumnInfo(name = STREAM_SERVICE_ID) + private int serviceId = -1; + + @ColumnInfo(name = STREAM_ID) + private String id; + + @ColumnInfo(name = STREAM_TYPE) + private String type; + + @ColumnInfo(name = STREAM_URL) + private String url; + + @ColumnInfo(name = STREAM_TITLE) + private String title; + + @ColumnInfo(name = STREAM_THUMBNAIL_URL) + private String thumbnailUrl; + + @ColumnInfo(name = STREAM_VIEW_COUNT) + private Long viewCount; + + @ColumnInfo(name = STREAM_UPLOADER) + private String uploader; + + @ColumnInfo(name = STREAM_UPLOAD_DATE) + private long uploadDate; + + @ColumnInfo(name = STREAM_DURATION) + private int duration; + + @Ignore + public StreamInfoItem toStreamInfoItem() { + StreamInfoItem item = new StreamInfoItem(); + + item.stream_type = AbstractStreamInfo.StreamType.valueOf( this.getType() ); + + item.service_id = this.getServiceId(); + item.id = this.getId(); + item.webpage_url = this.getUrl(); + item.title = this.getTitle(); + item.thumbnail_url = this.getThumbnailUrl(); + item.view_count = this.getViewCount(); + item.uploader = this.getUploader(); + + // TODO: temporary until upload date parsing is fleshed out + item.upload_date = "Unknown"; + item.duration = this.getDuration(); + + return item; + } + + @Ignore + public StreamEntity(final StreamInfoItem item) { + setData(item); + } + + @Ignore + public void setData(final StreamInfoItem item) { + // Do not store ordinals into db since they may change in the future + this.type = item.stream_type.name(); + + this.serviceId = item.service_id; + this.id = item.id; + this.url = item.webpage_url; + this.title = item.title; + this.thumbnailUrl = item.thumbnail_url; + this.viewCount = item.view_count; + this.uploader = item.uploader; + + // TODO: temporary until upload date parsing is fleshed out + this.uploadDate = new Date().getTime(); + this.duration = item.duration; + } + + @Ignore + public boolean is(final StreamInfoItem item) { + return this.type.equals( item.stream_type.name() ) && + this.serviceId == item.service_id && + this.id.equals( item.id ) && + this.url.equals( item.webpage_url ); + } + + public long getUid() { + return uid; + } + + void setUid(long uid) { + this.uid = uid; + } + + public int getServiceId() { + return serviceId; + } + + public void setServiceId(int serviceId) { + this.serviceId = serviceId; + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getType() { + return type; + } + + public void setType(String type) { + this.type = type; + } + + public String getUrl() { + return url; + } + + public void setUrl(String url) { + this.url = url; + } + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + public String getThumbnailUrl() { + return thumbnailUrl; + } + + public void setThumbnailUrl(String thumbnailUrl) { + this.thumbnailUrl = thumbnailUrl; + } + + public Long getViewCount() { + return viewCount; + } + + public void setViewCount(Long viewCount) { + this.viewCount = viewCount; + } + + public String getUploader() { + return uploader; + } + + public void setUploader(String uploader) { + this.uploader = uploader; + } + + public long getUploadDate() { + return uploadDate; + } + + public void setUploadDate(long uploadDate) { + this.uploadDate = uploadDate; + } + + public int getDuration() { + return duration; + } + + public void setDuration(int duration) { + this.duration = duration; + } +} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/playlist/PlaylistFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/playlist/PlaylistFragment.java new file mode 100644 index 000000000..0493fd525 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/playlist/PlaylistFragment.java @@ -0,0 +1,445 @@ +package org.schabi.newpipe.fragments.playlist; + +import android.content.Intent; +import android.net.Uri; +import android.os.Bundle; +import android.support.annotation.Nullable; +import android.support.v4.app.Fragment; +import android.support.v4.content.ContextCompat; +import android.support.v7.app.ActionBar; +import android.support.v7.widget.LinearLayoutManager; +import android.support.v7.widget.RecyclerView; +import android.text.TextUtils; +import android.util.Log; +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.ImageView; +import android.widget.TextView; + +import org.schabi.newpipe.ImageErrorLoadingListener; +import org.schabi.newpipe.MainActivity; +import org.schabi.newpipe.R; +import org.schabi.newpipe.extractor.InfoItem; +import org.schabi.newpipe.extractor.NewPipe; +import org.schabi.newpipe.extractor.StreamingService; +import org.schabi.newpipe.extractor.exceptions.ExtractionException; +import org.schabi.newpipe.extractor.playlist.PlayListExtractor; +import org.schabi.newpipe.extractor.playlist.PlayListInfo; +import org.schabi.newpipe.fragments.BaseFragment; +import org.schabi.newpipe.fragments.search.OnScrollBelowItemsListener; +import org.schabi.newpipe.info_list.InfoItemBuilder; +import org.schabi.newpipe.info_list.InfoListAdapter; +import org.schabi.newpipe.report.ErrorActivity; +import org.schabi.newpipe.report.UserAction; +import org.schabi.newpipe.util.Constants; +import org.schabi.newpipe.util.NavigationHelper; + +import java.io.IOException; +import java.io.Serializable; +import java.util.ArrayList; +import java.util.concurrent.Callable; + +import io.reactivex.Observable; +import io.reactivex.Observer; +import io.reactivex.android.schedulers.AndroidSchedulers; +import io.reactivex.annotations.NonNull; +import io.reactivex.disposables.Disposable; +import io.reactivex.schedulers.Schedulers; + +import static org.schabi.newpipe.util.AnimationUtils.animateView; + +public class PlaylistFragment extends BaseFragment { + private final String TAG = "PlaylistFragment@" + Integer.toHexString(hashCode()); + + private static final String INFO_LIST_KEY = "info_list_key"; + private static final String PLAYLIST_INFO_KEY = "playlist_info_key"; + private static final String PAGE_NUMBER_KEY = "page_number_key"; + + private InfoListAdapter infoListAdapter; + + private PlayListInfo currentPlaylistInfo; + private int serviceId = -1; + private String playlistTitle = ""; + private String playlistUrl = ""; + private int pageNumber = 0; + private boolean hasNextPage = true; + + /*////////////////////////////////////////////////////////////////////////// + // Views + //////////////////////////////////////////////////////////////////////////*/ + + private RecyclerView playlistStreams; + + private View headerRootLayout; + private ImageView headerBannerView; + private ImageView headerAvatarView; + private TextView headerTitleView; + + /*////////////////////////////////////////////////////////////////////////*/ + + public PlaylistFragment() { + } + + public static Fragment getInstance(int serviceId, String playlistUrl, String title) { + PlaylistFragment instance = new PlaylistFragment(); + instance.setPlaylist(serviceId, playlistUrl, title); + return instance; + } + + /*////////////////////////////////////////////////////////////////////////// + // Fragment's LifeCycle + //////////////////////////////////////////////////////////////////////////*/ + + @Override + public void onCreate(Bundle savedInstanceState) { + if (DEBUG) Log.d(TAG, "onCreate() called with: savedInstanceState = [" + savedInstanceState + "]"); + super.onCreate(savedInstanceState); + setHasOptionsMenu(true); + if (savedInstanceState != null) { + playlistUrl = savedInstanceState.getString(Constants.KEY_URL); + playlistTitle = savedInstanceState.getString(Constants.KEY_TITLE); + serviceId = savedInstanceState.getInt(Constants.KEY_SERVICE_ID, -1); + + pageNumber = savedInstanceState.getInt(PAGE_NUMBER_KEY, 0); + Serializable serializable = savedInstanceState.getSerializable(PLAYLIST_INFO_KEY); + if (serializable instanceof PlayListInfo) currentPlaylistInfo = (PlayListInfo) serializable; + } + } + + @Override + public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + if (DEBUG) Log.d(TAG, "onCreateView() called with: inflater = [" + inflater + "], container = [" + container + "], savedInstanceState = [" + savedInstanceState + "]"); + return inflater.inflate(R.layout.fragment_channel, container, false); + } + + @Override + public void onViewCreated(View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + + if (currentPlaylistInfo == null) loadPage(0); + else handlePlayListInfo(currentPlaylistInfo, false, false); + } + + @Override + public void onDestroyView() { + if (DEBUG) Log.d(TAG, "onDestroyView() called"); + headerAvatarView.setImageBitmap(null); + headerBannerView.setImageBitmap(null); + playlistStreams.removeAllViews(); + + playlistStreams = null; + headerRootLayout = null; + headerBannerView = null; + headerAvatarView = null; + headerTitleView = null; + + super.onDestroyView(); + } + + @Override + public void onResume() { + if (DEBUG) Log.d(TAG, "onResume() called"); + super.onResume(); + if (wasLoading.getAndSet(false)) { + loadPage(pageNumber); + } + } + + @Override + public void onStop() { + if (DEBUG) Log.d(TAG, "onStop() called"); + + disposable.dispose(); + disposable = null; + + super.onStop(); + } + + @Override + public void onSaveInstanceState(Bundle outState) { + if (DEBUG) Log.d(TAG, "onSaveInstanceState() called with: outState = [" + outState + "]"); + super.onSaveInstanceState(outState); + outState.putString(Constants.KEY_URL, playlistUrl); + outState.putString(Constants.KEY_TITLE, playlistTitle); + outState.putInt(Constants.KEY_SERVICE_ID, serviceId); + + outState.putSerializable(INFO_LIST_KEY, infoListAdapter.getItemsList()); + outState.putSerializable(PLAYLIST_INFO_KEY, currentPlaylistInfo); + outState.putInt(PAGE_NUMBER_KEY, pageNumber); + } + + /*////////////////////////////////////////////////////////////////////////// + // Menu + //////////////////////////////////////////////////////////////////////////*/ + @Override + public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { + if (DEBUG) Log.d(TAG, "onCreateOptionsMenu() called with: menu = [" + menu + "], inflater = [" + inflater + "]"); + super.onCreateOptionsMenu(menu, inflater); + inflater.inflate(R.menu.menu_channel, menu); + + ActionBar supportActionBar = activity.getSupportActionBar(); + if (supportActionBar != null) { + supportActionBar.setDisplayShowTitleEnabled(true); + supportActionBar.setDisplayHomeAsUpEnabled(true); + } + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + if (DEBUG) Log.d(TAG, "onOptionsItemSelected() called with: item = [" + item + "]"); + super.onOptionsItemSelected(item); + switch (item.getItemId()) { + case R.id.menu_item_openInBrowser: { + Intent intent = new Intent(); + intent.setAction(Intent.ACTION_VIEW); + intent.setData(Uri.parse(playlistUrl)); + startActivity(Intent.createChooser(intent, getString(R.string.choose_browser))); + return true; + } + case R.id.menu_item_share: { + Intent intent = new Intent(); + intent.setAction(Intent.ACTION_SEND); + intent.putExtra(Intent.EXTRA_TEXT, playlistUrl); + intent.setType("text/plain"); + startActivity(Intent.createChooser(intent, getString(R.string.share_dialog_title))); + return true; + } + default: + return super.onOptionsItemSelected(item); + } + } + + /*////////////////////////////////////////////////////////////////////////// + // Init's + //////////////////////////////////////////////////////////////////////////*/ + + @Override + protected void initViews(View rootView, Bundle savedInstanceState) { + super.initViews(rootView, savedInstanceState); + + playlistStreams = (RecyclerView) rootView.findViewById(R.id.channel_streams_view); + + playlistStreams.setLayoutManager(new LinearLayoutManager(activity)); + if (infoListAdapter == null) { + infoListAdapter = new InfoListAdapter(activity); + if (savedInstanceState != null) { + //noinspection unchecked + ArrayList serializable = (ArrayList) savedInstanceState.getSerializable(INFO_LIST_KEY); + infoListAdapter.addInfoItemList(serializable); + } + } + + playlistStreams.setAdapter(infoListAdapter); + headerRootLayout = activity.getLayoutInflater().inflate(R.layout.playlist_header, playlistStreams, false); + infoListAdapter.setHeader(headerRootLayout); + infoListAdapter.setFooter(activity.getLayoutInflater().inflate(R.layout.pignate_footer, playlistStreams, false)); + + headerBannerView = (ImageView) headerRootLayout.findViewById(R.id.playlist_banner_image); + headerAvatarView = (ImageView) headerRootLayout.findViewById(R.id.playlist_avatar_view); + headerTitleView = (TextView) headerRootLayout.findViewById(R.id.playlist_title_view); + } + + protected void initListeners() { + super.initListeners(); + + infoListAdapter.setOnStreamInfoItemSelectedListener(new InfoItemBuilder.OnInfoItemSelectedListener() { + @Override + public void selected(int serviceId, String url, String title) { + if (DEBUG) Log.d(TAG, "selected() called with: serviceId = [" + serviceId + "], url = [" + url + "], title = [" + title + "]"); + NavigationHelper.openVideoDetailFragment(getFragmentManager(), serviceId, url, title); + } + }); + + playlistStreams.clearOnScrollListeners(); + playlistStreams.addOnScrollListener(new OnScrollBelowItemsListener() { + @Override + public void onScrolledDown(RecyclerView recyclerView) { + loadMore(true); + } + }); + } + + + @Override + protected void reloadContent() { + if (DEBUG) Log.d(TAG, "reloadContent() called"); + currentPlaylistInfo = null; + infoListAdapter.clearStreamItemList(); + loadPage(0); + } + + /*////////////////////////////////////////////////////////////////////////// + // Playlist Loader + //////////////////////////////////////////////////////////////////////////*/ + + private StreamingService getService(final int serviceId) throws ExtractionException { + return NewPipe.getService(serviceId); + } + + Disposable disposable; + + private void loadMore(final boolean onlyVideos) { + final Callable task = new Callable() { + @Override + public PlayListInfo call() throws Exception { + final PlayListExtractor extractor = getService(serviceId) + .getPlayListExtractorInstance(playlistUrl, pageNumber); + + return PlayListInfo.getInfo(extractor); + } + }; + + + Observable.fromCallable(task) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(new Observer() { + @Override + public void onSubscribe(@NonNull Disposable d) { + if (disposable == null || disposable.isDisposed()) { + disposable = d; + isLoading.set(true); + } else { + d.dispose(); + } + } + + @Override + public void onNext(@NonNull PlayListInfo playListInfo) { + if (DEBUG) Log.d(TAG, "onReceive() called with: info = [" + playListInfo + "]"); + if (playListInfo == null || isRemoving() || !isVisible()) return; + + handlePlayListInfo(playListInfo, onlyVideos, true); + isLoading.set(false); + pageNumber++; + } + + @Override + public void onError(@NonNull Throwable e) { + onRxError(e, "Observer failure"); + } + + @Override + public void onComplete() { + if (disposable != null) { + disposable.dispose(); + disposable = null; + } + } + }); + } + + + /*////////////////////////////////////////////////////////////////////////// + // Utils + //////////////////////////////////////////////////////////////////////////*/ + + private void loadPage(int page) { + if (DEBUG) Log.d(TAG, "loadPage() called with: page = [" + page + "]"); + isLoading.set(true); + pageNumber = page; + infoListAdapter.showFooter(false); + + animateView(loadingProgressBar, true, 200); + animateView(errorPanel, false, 200); + + imageLoader.cancelDisplayTask(headerBannerView); + imageLoader.cancelDisplayTask(headerAvatarView); + + headerTitleView.setText(playlistTitle != null ? playlistTitle : ""); + headerBannerView.setImageDrawable(ContextCompat.getDrawable(activity, R.drawable.channel_banner)); + headerAvatarView.setImageDrawable(ContextCompat.getDrawable(activity, R.drawable.buddy)); + if (activity.getSupportActionBar() != null) activity.getSupportActionBar().setTitle(playlistTitle != null ? playlistTitle : ""); + + loadMore(true); + } + + private void setPlaylist(int serviceId, String playlistUrl, String title) { + this.serviceId = serviceId; + this.playlistUrl = playlistUrl; + this.playlistTitle = title; + } + + private void handlePlayListInfo(PlayListInfo info, boolean onlyVideos, boolean addVideos) { + currentPlaylistInfo = info; + + animateView(errorPanel, false, 300); + animateView(playlistStreams, true, 200); + animateView(loadingProgressBar, false, 200); + + if (!onlyVideos) { + if (activity.getSupportActionBar() != null) activity.getSupportActionBar().invalidateOptionsMenu(); + + headerRootLayout.setVisibility(View.VISIBLE); + //animateView(loadingProgressBar, false, 200, null); + + if (!TextUtils.isEmpty(info.playList_name)) { + if (activity.getSupportActionBar() != null) activity.getSupportActionBar().setTitle(info.playList_name); + headerTitleView.setText(info.playList_name); + playlistTitle = info.playList_name; + } else playlistTitle = ""; + + if (!TextUtils.isEmpty(info.banner_url)) { + imageLoader.displayImage(info.banner_url, headerBannerView, displayImageOptions, new ImageErrorLoadingListener(activity, getView(), info.service_id)); + } + + if (!TextUtils.isEmpty(info.avatar_url)) { + headerAvatarView.setVisibility(View.VISIBLE); + imageLoader.displayImage(info.avatar_url, headerAvatarView, displayImageOptions, new ImageErrorLoadingListener(activity, getView(), info.service_id)); + } + + infoListAdapter.showFooter(true); + } + + hasNextPage = info.hasNextPage; + if (!hasNextPage) infoListAdapter.showFooter(false); + + //if (!listRestored) { + if (addVideos) infoListAdapter.addInfoItemList(info.related_streams); + //} + } + + @Override + protected void setErrorMessage(String message, boolean showRetryButton) { + super.setErrorMessage(message, showRetryButton); + + animateView(playlistStreams, false, 200); + currentPlaylistInfo = null; + } + + /*////////////////////////////////////////////////////////////////////////// + // Error Handlers + //////////////////////////////////////////////////////////////////////////*/ + + private void onRxError(final Throwable exception, final String tag) { + if (exception instanceof IOException) { + onRecoverableError(R.string.network_error); + } else { + onUnrecoverableError(exception, tag); + } + } + + private void onRecoverableError(int messageId) { + if (!this.isAdded()) return; + + if (DEBUG) Log.d(TAG, "onError() called with: messageId = [" + messageId + "]"); + setErrorMessage(getString(messageId), true); + } + + private void onUnrecoverableError(Throwable exception, final String tag) { + if (DEBUG) Log.d(TAG, "onUnrecoverableError() called with: exception = [" + exception + "]"); + ErrorActivity.reportError( + getContext(), + exception, + MainActivity.class, + null, + ErrorActivity.ErrorInfo.make(UserAction.REQUESTED_PLAYLIST, "Feed", tag, R.string.general_error) + ); + + activity.finish(); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/search/PlaylistService.java b/app/src/main/java/org/schabi/newpipe/fragments/search/PlaylistService.java new file mode 100644 index 000000000..b43c7e356 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/search/PlaylistService.java @@ -0,0 +1,4 @@ +package org.schabi.newpipe.fragments.search; + +public class PlaylistService { +} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/subscription/FeedFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/subscription/FeedFragment.java new file mode 100644 index 000000000..df92449d2 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/subscription/FeedFragment.java @@ -0,0 +1,496 @@ +package org.schabi.newpipe.fragments.subscription; + +import android.os.Bundle; +import android.os.Parcelable; +import android.support.annotation.Nullable; +import android.support.v7.app.ActionBar; +import android.support.v7.widget.LinearLayoutManager; +import android.support.v7.widget.RecyclerView; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.View; +import android.view.ViewGroup; + +import com.jakewharton.rxbinding2.view.RxView; + +import org.reactivestreams.Subscriber; +import org.reactivestreams.Subscription; +import org.schabi.newpipe.MainActivity; +import org.schabi.newpipe.R; +import org.schabi.newpipe.database.subscription.SubscriptionEntity; +import org.schabi.newpipe.extractor.InfoItem; +import org.schabi.newpipe.extractor.channel.ChannelInfo; +import org.schabi.newpipe.fragments.BaseFragment; +import org.schabi.newpipe.info_list.InfoItemBuilder; +import org.schabi.newpipe.info_list.InfoListAdapter; +import org.schabi.newpipe.report.ErrorActivity; +import org.schabi.newpipe.util.NavigationHelper; + +import java.io.IOException; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +import io.reactivex.Flowable; +import io.reactivex.MaybeObserver; +import io.reactivex.android.schedulers.AndroidSchedulers; +import io.reactivex.annotations.NonNull; +import io.reactivex.disposables.Disposable; +import io.reactivex.functions.Consumer; + +import static org.schabi.newpipe.report.UserAction.REQUESTED_CHANNEL; +import static org.schabi.newpipe.util.AnimationUtils.animateView; + +public class FeedFragment extends BaseFragment { + private static final String VIEW_STATE_KEY = "view_state_key"; + private static final String INFO_ITEMS_KEY = "info_items_key"; + + private static final int FEED_LOAD_SIZE = 4; + private static final int LOAD_ITEM_DEBOUNCE_INTERVAL = 500; + + private final String TAG = "FeedFragment@" + Integer.toHexString(hashCode()); + + private View inflatedView; + private View emptyPanel; + private View loadItemFooter; + + private InfoListAdapter infoListAdapter; + private RecyclerView resultRecyclerView; + + private Parcelable viewState; + private AtomicBoolean retainFeedItems; + + private SubscriptionEngine subscriptionEngine; + + private Disposable loadItemObserver; + private Disposable subscriptionObserver; + private Subscription feedSubscriber; + + /////////////////////////////////////////////////////////////////////////// + // Fragment LifeCycle + /////////////////////////////////////////////////////////////////////////// + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + subscriptionEngine = SubscriptionEngine.getInstance(getContext()); + + retainFeedItems = new AtomicBoolean(false); + + if (infoListAdapter == null) { + infoListAdapter = new InfoListAdapter(getActivity()); + } + + if (savedInstanceState != null) { + // Get recycler view state + viewState = savedInstanceState.getParcelable(VIEW_STATE_KEY); + + // Deserialize and get recycler adapter list + final Object[] serializedInfoItems = (Object[]) savedInstanceState.getSerializable(INFO_ITEMS_KEY); + if (serializedInfoItems != null) { + final InfoItem[] infoItems = Arrays.copyOf( + serializedInfoItems, + serializedInfoItems.length, + InfoItem[].class + ); + final List feedInfos = Arrays.asList(infoItems); + infoListAdapter.addInfoItemList( feedInfos ); + } + + // Already displayed feed items survive configuration changes + retainFeedItems.set(true); + } + } + + @Nullable + @Override + public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, Bundle savedInstanceState) { + if (inflatedView == null) { + inflatedView = inflater.inflate(R.layout.fragment_subscription, container, false); + } + return inflatedView; + } + + @Override + public void onSaveInstanceState(Bundle outState) { + super.onSaveInstanceState(outState); + + if (resultRecyclerView != null) { + outState.putParcelable( + VIEW_STATE_KEY, + resultRecyclerView.getLayoutManager().onSaveInstanceState() + ); + } + + if (infoListAdapter != null) { + outState.putSerializable(INFO_ITEMS_KEY, infoListAdapter.getItemsList().toArray()); + } + } + + @Override + public void onDestroyView() { + // Do not monitor for updates when user is not viewing the feed fragment. + // This is a waste of bandwidth. + if (loadItemObserver != null) loadItemObserver.dispose(); + if (subscriptionObserver != null) subscriptionObserver.dispose(); + if (feedSubscriber != null) feedSubscriber.cancel(); + + loadItemObserver = null; + subscriptionObserver = null; + feedSubscriber = null; + + loadItemFooter = null; + + // Retain the already displayed items for backstack pops + retainFeedItems.set(true); + + super.onDestroyView(); + } + + @Override + public void onDestroy() { + subscriptionEngine = null; + + super.onDestroy(); + } + + /////////////////////////////////////////////////////////////////////////// + // Fragment Views + /////////////////////////////////////////////////////////////////////////// + + @Override + public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { + if (DEBUG) Log.d(TAG, "onCreateOptionsMenu() called with: menu = [" + menu + "], inflater = [" + inflater + "]"); + super.onCreateOptionsMenu(menu, inflater); + + ActionBar supportActionBar = activity.getSupportActionBar(); + if (supportActionBar != null) { + supportActionBar.setDisplayShowTitleEnabled(true); + supportActionBar.setDisplayHomeAsUpEnabled(true); + } + } + + private RecyclerView.OnScrollListener getOnScrollListener() { + return new RecyclerView.OnScrollListener() { + @Override + public void onScrollStateChanged(RecyclerView recyclerView, int newState) { + super.onScrollStateChanged(recyclerView, newState); + if (newState == RecyclerView.SCROLL_STATE_IDLE) { + viewState = recyclerView.getLayoutManager().onSaveInstanceState(); + } + } + }; + } + + @Override + protected void initViews(View rootView, Bundle savedInstanceState) { + super.initViews(rootView, savedInstanceState); + + if (infoListAdapter == null) return; + + animateView(errorPanel, false, 200); + animateView(loadingProgressBar, true, 200); + + emptyPanel = rootView.findViewById(R.id.empty_panel); + + resultRecyclerView = rootView.findViewById(R.id.result_list_view); + resultRecyclerView.setLayoutManager(new LinearLayoutManager(activity)); + + loadItemFooter = activity.getLayoutInflater().inflate(R.layout.load_item_footer, resultRecyclerView, false); + infoListAdapter.setFooter(loadItemFooter); + infoListAdapter.showFooter(false); + infoListAdapter.setOnStreamInfoItemSelectedListener(new InfoItemBuilder.OnInfoItemSelectedListener() { + @Override + public void selected(int serviceId, String url, String title) { + NavigationHelper.openVideoDetailFragment(getFragmentManager(), serviceId, url, title); + } + }); + + resultRecyclerView.setAdapter(infoListAdapter); + resultRecyclerView.addOnScrollListener(getOnScrollListener()); + + if (viewState != null) { + resultRecyclerView.getLayoutManager().onRestoreInstanceState(viewState); + viewState = null; + } + + if (activity.getSupportActionBar() != null) activity.getSupportActionBar().setTitle(R.string.fragment_whats_new); + + populateFeed(); + } + + private void resetFragment() { + if (subscriptionObserver != null) subscriptionObserver.dispose(); + if (infoListAdapter != null) infoListAdapter.clearStreamItemList(); + } + + @Override + protected void reloadContent() { + resetFragment(); + populateFeed(); + } + + @Override + protected void setErrorMessage(String message, boolean showRetryButton) { + super.setErrorMessage(message, showRetryButton); + + resetFragment(); + } + + /** + * Changes the state of the load item footer. + * + * If the current state of the feed is loaded, this displays the load item button and + * starts its reactor. + * + * Otherwise, show a spinner in place of the loader button. */ + private void setLoader(final boolean isLoaded) { + if (loadItemFooter == null) return; + + if (loadItemObserver != null) loadItemObserver.dispose(); + + if (isLoaded) { + loadItemObserver = getLoadItemObserver(loadItemFooter); + } + + loadItemFooter.findViewById(R.id.paginate_progress_bar).setVisibility(isLoaded ? View.GONE : View.VISIBLE); + loadItemFooter.findViewById(R.id.load_more_text).setVisibility(isLoaded ? View.VISIBLE : View.GONE); + } + + /////////////////////////////////////////////////////////////////////////// + // Feeds Loader + /////////////////////////////////////////////////////////////////////////// + + /** + * Responsible for reacting to subscription database updates and displaying feeds. + * + * Upon each update, the feed info list is cleared unless the fragment is + * recently recovered from a configuration change or backstack. + * + * All existing and pending feed requests are dropped. + * + * The newly received list of subscriptions is then transformed into a + * flowable, reacting to pulling requests. + * + * Pulled requests are transformed first into ChannelInfo, then Stream Info items and + * displayed on the feed fragment. + **/ + private void populateFeed() { + final Consumer> consumer = new Consumer>() { + @Override + public void accept(@NonNull List subscriptionEntities) throws Exception { + animateView(loadingProgressBar, false, 200); + + if (subscriptionEntities.isEmpty()) { + infoListAdapter.clearStreamItemList(); + emptyPanel.setVisibility(View.VISIBLE); + } else { + emptyPanel.setVisibility(View.INVISIBLE); + } + + // show progress bar on receiving a non-empty updated list of subscriptions + if (!retainFeedItems.get() && !subscriptionEntities.isEmpty()) { + infoListAdapter.clearStreamItemList(); + animateView(loadingProgressBar, true, 200); + } + + retainFeedItems.set(false); + Flowable.fromIterable(subscriptionEntities) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(getSubscriptionObserver()); + } + }; + + final Consumer onError = new Consumer() { + @Override + public void accept(@NonNull Throwable exception) throws Exception { + onRxError(exception, "Subscription Database Reactor"); + } + }; + + if (subscriptionObserver != null) subscriptionObserver.dispose(); + subscriptionObserver = subscriptionEngine.getSubscription() + .onErrorReturnItem(Collections.emptyList()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(consumer, onError); + } + + /** + * Responsible for reacting to user pulling request and starting a request for new feed stream. + * + * On initialization, it automatically requests the amount of feed needed to display + * a minimum amount required (FEED_LOAD_SIZE). + * + * Upon receiving a user pull, it creates a Single Observer to fetch the ChannelInfo + * containing the feed streams. + **/ + private Subscriber getSubscriptionObserver() { + return new Subscriber() { + @Override + public void onSubscribe(Subscription s) { + if (feedSubscriber != null) feedSubscriber.cancel(); + feedSubscriber = s; + + final int requestSize = FEED_LOAD_SIZE - infoListAdapter.getItemsList().size(); + if (requestSize > 0) { + requestFeed(requestSize); + } else { + setLoader(true); + } + + animateView(loadingProgressBar, false, 200); + // Footer spinner persists until subscription list is exhausted. + infoListAdapter.showFooter(true); + } + + @Override + public void onNext(SubscriptionEntity subscriptionEntity) { + setLoader(false); + + subscriptionEngine.getChannelInfo(subscriptionEntity) + .observeOn(AndroidSchedulers.mainThread()) + .onErrorComplete() + .subscribe(getChannelInfoObserver()); + } + + @Override + public void onError(Throwable exception) { + onRxError(exception, "Feed Pull Reactor"); + } + + @Override + public void onComplete() { + infoListAdapter.showFooter(false); + } + }; + } + + /** + * On each request, a subscription item from the updated table is transformed + * into a ChannelInfo, containing the latest streams from the channel. + * + * Currently, the feed uses the first into from the list of streams. + * + * If chosen feed already displayed, then we request another feed from another + * subscription, until the subscription table runs out of new items. + * + * This Observer is self-contained and will dispose itself when complete. However, this + * does not obey the fragment lifecycle and may continue running in the background + * until it is complete. This is done due to RxJava2 no longer propagate errors once + * an observer is unsubscribed while the thread process is still running. + * + * To solve the above issue, we can either set a global RxJava Error Handler, or + * manage exceptions case by case. This should be done if the current implementation is + * too costly when dealing with larger subscription sets. + **/ + private MaybeObserver getChannelInfoObserver() { + return new MaybeObserver() { + Disposable observer; + @Override + public void onSubscribe(Disposable d) { + observer = d; + } + + // Called only when response is non-empty + @Override + public void onSuccess(ChannelInfo channelInfo) { + emptyPanel.setVisibility(View.INVISIBLE); + + if (infoListAdapter == null || channelInfo.related_streams.isEmpty()) return; + + final InfoItem item = channelInfo.related_streams.get(0); + // Keep requesting new items if the current one already exists + if (!doesItemExist(infoListAdapter.getItemsList(), item)) { + infoListAdapter.addInfoItem(item); + } else { + requestFeed(1); + } + onDone(); + } + + @Override + public void onError(Throwable exception) { + onRxError(exception, "Feed Display Reactor"); + onDone(); + } + + // Called only when response is empty + @Override + public void onComplete() { + onDone(); + } + + private void onDone() { + setLoader(true); + + observer.dispose(); + observer = null; + } + }; + } + + private boolean doesItemExist(final List items, final InfoItem item) { + for (final InfoItem existingItem: items) { + if (existingItem.infoType() == item.infoType() && + existingItem.getTitle().equals(item.getTitle()) && + existingItem.getLink().equals(item.getLink())) return true; + } + return false; + } + + private void requestFeed(final int count) { + if (feedSubscriber == null) return; + + feedSubscriber.request(count); + } + + private Disposable getLoadItemObserver(@NonNull final View itemLoader) { + final Consumer onNext = new Consumer() { + @Override + public void accept(Object o) throws Exception { + requestFeed(FEED_LOAD_SIZE); + } + }; + + final Consumer onError = new Consumer() { + @Override + public void accept(Throwable throwable) throws Exception { + onRxError(throwable, "Load Button Reactor"); + } + }; + + return RxView.clicks(itemLoader) + .debounce(LOAD_ITEM_DEBOUNCE_INTERVAL, TimeUnit.MILLISECONDS) + .subscribe(onNext, onError); + } + + /////////////////////////////////////////////////////////////////////////// + // Fragment Error Handling + /////////////////////////////////////////////////////////////////////////// + + private void onRxError(final Throwable exception, final String tag) { + if (exception instanceof IOException) { + onRecoverableError(R.string.network_error); + } else { + onUnrecoverableError(exception, tag); + } + } + + private void onRecoverableError(int messageId) { + if (!this.isAdded()) return; + + if (DEBUG) Log.d(TAG, "onError() called with: messageId = [" + messageId + "]"); + setErrorMessage(getString(messageId), true); + } + + private void onUnrecoverableError(Throwable exception, final String tag) { + if (DEBUG) Log.d(TAG, "onUnrecoverableError() called with: exception = [" + exception + "]"); + ErrorActivity.reportError(getContext(), exception, MainActivity.class, null, ErrorActivity.ErrorInfo.make(REQUESTED_CHANNEL, "Feed", tag, R.string.general_error)); + + activity.finish(); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/subscription/SubscriptionEngine.java b/app/src/main/java/org/schabi/newpipe/fragments/subscription/SubscriptionEngine.java new file mode 100644 index 000000000..25285db41 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/subscription/SubscriptionEngine.java @@ -0,0 +1,170 @@ +package org.schabi.newpipe.fragments.subscription; + +import android.content.Context; + +import org.schabi.newpipe.NewPipeDatabase; +import org.schabi.newpipe.database.AppDatabase; +import org.schabi.newpipe.database.subscription.SubscriptionDAO; +import org.schabi.newpipe.database.subscription.SubscriptionEntity; +import org.schabi.newpipe.extractor.NewPipe; +import org.schabi.newpipe.extractor.StreamingService; +import org.schabi.newpipe.extractor.channel.ChannelExtractor; +import org.schabi.newpipe.extractor.channel.ChannelInfo; +import org.schabi.newpipe.extractor.exceptions.ExtractionException; + +import java.util.List; +import java.util.concurrent.Callable; +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +import io.reactivex.Completable; +import io.reactivex.CompletableSource; +import io.reactivex.Flowable; +import io.reactivex.Maybe; +import io.reactivex.Scheduler; +import io.reactivex.annotations.NonNull; +import io.reactivex.functions.Function; +import io.reactivex.schedulers.Schedulers; + +/** Subscription Service singleton: + * Provides a basis for channel Subscriptions. + * Provides access to subscription table in database as well as + * up-to-date observations on the subscribed channels + * */ +public class SubscriptionEngine { + + private static SubscriptionEngine sInstance; + private static final Object LOCK = new Object(); + + public static SubscriptionEngine getInstance(Context context) { + if (sInstance == null) { + synchronized (LOCK) { + if (sInstance == null) { + sInstance = new SubscriptionEngine(context); + } + } + } + return sInstance; + } + + protected final String TAG = "SubscriptionEngine@" + Integer.toHexString(hashCode()); + private static final int SUBSCRIPTION_DEBOUNCE_INTERVAL = 500; + private static final int SUBSCRIPTION_THREAD_POOL_SIZE = 4; + + private AppDatabase db; + private Flowable> subscription; + + private Scheduler subscriptionScheduler; + + private SubscriptionEngine(Context context) { + db = NewPipeDatabase.getInstance( context ); + subscription = getSubscriptionInfos(); + + final Executor subscriptionExecutor = Executors.newFixedThreadPool(SUBSCRIPTION_THREAD_POOL_SIZE); + subscriptionScheduler = Schedulers.from(subscriptionExecutor); + } + + /** Part of subscription observation pipeline + * @see SubscriptionEngine#getSubscription() + */ + private Flowable> getSubscriptionInfos() { + return subscriptionTable().findAll() + // Wait for a period of infrequent updates and return the latest update + .debounce(SUBSCRIPTION_DEBOUNCE_INTERVAL, TimeUnit.MILLISECONDS) + .share() // Share allows multiple subscribers on the same observable + .replay(1) // Replay synchronizes subscribers to the last emitted result + .autoConnect(); + } + + /** + * Provides an observer to the latest update to the subscription table. + * + * This observer may be subscribed multiple times, where each subscriber obtains + * the latest synchronized changes available, effectively share the same data + * across all subscribers. + * + * This observer has a debounce cooldown, meaning if multiple updates are observed + * in the cooldown interval, only the latest changes are emitted to the subscribers. + * This reduces the amount of observations caused by frequent updates to the database. + * */ + @android.support.annotation.NonNull + public Flowable> getSubscription() { + return subscription; + } + + public Maybe getChannelInfo(final SubscriptionEntity subscriptionEntity) { + final StreamingService service = getService(subscriptionEntity.getServiceId()); + if (service == null) return Maybe.empty(); + + final String url = subscriptionEntity.getUrl(); + final Callable callable = new Callable() { + @Override + public ChannelInfo call() throws Exception { + final ChannelExtractor extractor = service.getChannelExtractorInstance(url, 0); + return ChannelInfo.getInfo(extractor); + } + }; + + return Maybe.fromCallable(callable).subscribeOn(subscriptionScheduler); + } + + private StreamingService getService(final int serviceId) { + try { + return NewPipe.getService(serviceId); + } catch (ExtractionException e) { + return null; + } + } + + /** Returns the database access interface for subscription table. */ + public SubscriptionDAO subscriptionTable() { + return db.subscriptionDAO(); + } + + public Completable updateChannelInfo(final int serviceId, + final String channelUrl, + final ChannelInfo info) { + final Function, CompletableSource> update = new Function, CompletableSource>() { + @Override + public CompletableSource apply(@NonNull List subscriptionEntities) throws Exception { + if (subscriptionEntities.size() == 1) { + SubscriptionEntity subscription = subscriptionEntities.get(0); + + // Subscriber count changes very often, making this check almost unnecessary. + // Consider removing it later. + if (isSubscriptionUpToDate(channelUrl, info, subscription)) { + subscription.setData(info.channel_name, info.avatar_url, "", info.subscriberCount); + + return update(subscription); + } + } + + return Completable.complete(); + } + }; + + return subscriptionTable().findAll(serviceId, channelUrl) + .firstOrError() + .flatMapCompletable(update); + } + + private Completable update(final SubscriptionEntity updatedSubscription) { + return Completable.fromRunnable(new Runnable() { + @Override + public void run() { + subscriptionTable().update(updatedSubscription); + } + }); + } + + private boolean isSubscriptionUpToDate(final String channelUrl, + final ChannelInfo info, + final SubscriptionEntity entity) { + return channelUrl.equals( entity.getUrl() ) && + info.service_id == entity.getServiceId() && + info.channel_name.equals( entity.getTitle() ) && + info.avatar_url.equals( entity.getThumbnailUrl() ) && + info.subscriberCount == entity.getSubscriberCount(); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/subscription/SubscriptionFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/subscription/SubscriptionFragment.java index c4dfbc50d..afcb03d2b 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/subscription/SubscriptionFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/subscription/SubscriptionFragment.java @@ -1,9 +1,7 @@ package org.schabi.newpipe.fragments.subscription; -import android.content.Context; import android.os.Bundle; import android.os.Parcelable; -import android.support.annotation.NonNull; import android.support.annotation.Nullable; import android.support.v7.widget.LinearLayoutManager; import android.support.v7.widget.RecyclerView; @@ -12,43 +10,50 @@ import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; +import org.schabi.newpipe.MainActivity; import org.schabi.newpipe.R; import org.schabi.newpipe.database.subscription.SubscriptionEntity; import org.schabi.newpipe.extractor.InfoItem; import org.schabi.newpipe.extractor.channel.ChannelInfoItem; -import org.schabi.newpipe.fragments.BaseStateFragment; +import org.schabi.newpipe.fragments.BaseFragment; import org.schabi.newpipe.info_list.InfoItemBuilder; import org.schabi.newpipe.info_list.InfoListAdapter; import org.schabi.newpipe.report.UserAction; import org.schabi.newpipe.util.KioskTranslator; +import org.schabi.newpipe.report.ErrorActivity; import org.schabi.newpipe.util.NavigationHelper; +import java.io.IOException; import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; import java.util.List; -import icepick.State; import io.reactivex.Observer; import io.reactivex.android.schedulers.AndroidSchedulers; import io.reactivex.disposables.CompositeDisposable; import io.reactivex.disposables.Disposable; import io.reactivex.schedulers.Schedulers; +import static org.schabi.newpipe.report.UserAction.REQUESTED_CHANNEL; import static org.schabi.newpipe.util.AnimationUtils.animateView; -public class SubscriptionFragment extends BaseStateFragment> { +public class SubscriptionFragment extends BaseFragment { + private static final String VIEW_STATE_KEY = "view_state_key"; + private final String TAG = "SubscriptionFragment@" + Integer.toHexString(hashCode()); + + private View inflatedView; + private View emptyPanel; private View headerRootLayout; + private View whatsNewView; private InfoListAdapter infoListAdapter; - private RecyclerView itemsList; - - @State - protected Parcelable itemsListState; + private RecyclerView resultRecyclerView; + private Parcelable viewState; /* Used for independent events */ - private CompositeDisposable disposables = new CompositeDisposable(); - private SubscriptionService subscriptionService; + private CompositeDisposable disposables; + private SubscriptionEngine subscriptionEngine; /////////////////////////////////////////////////////////////////////////// // Fragment LifeCycle @@ -66,10 +71,15 @@ public class SubscriptionFragment extends BaseStateFragment() { - @Override - public void selected(ChannelInfoItem selectedItem) { - // Requires the parent fragment to find holder for fragment replacement - NavigationHelper.openChannelFragment(getParentFragment().getFragmentManager(), selectedItem.service_id, selectedItem.url, selectedItem.name); - - } - }); - - headerRootLayout.setOnClickListener(new View.OnClickListener() { + private View.OnClickListener getWhatsNewOnClickListener() { + return new View.OnClickListener() { @Override public void onClick(View view) { NavigationHelper.openWhatsNewFragment(getParentFragment().getFragmentManager()); } - }); + }; + } + + @Override + protected void initViews(View rootView, Bundle savedInstanceState) { + super.initViews(rootView, savedInstanceState); + + emptyPanel = rootView.findViewById(R.id.empty_panel); + + resultRecyclerView = rootView.findViewById(R.id.result_list_view); + resultRecyclerView.setLayoutManager(new LinearLayoutManager(activity)); + resultRecyclerView.addOnScrollListener(getOnScrollListener()); + + if (infoListAdapter == null) { + infoListAdapter = new InfoListAdapter(getActivity()); + infoListAdapter.setFooter(activity.getLayoutInflater().inflate(R.layout.pignate_footer, resultRecyclerView, false)); + infoListAdapter.showFooter(false); + infoListAdapter.setOnChannelInfoItemSelectedListener(new InfoItemBuilder.OnInfoItemSelectedListener() { + @Override + public void selected(int serviceId, String url, String title) { + /* Requires the parent fragment to find holder for fragment replacement */ + NavigationHelper.openChannelFragment(getParentFragment().getFragmentManager(), serviceId, url, title); + } + }); + } + + headerRootLayout = activity.getLayoutInflater().inflate(R.layout.subscription_header, resultRecyclerView, false); + infoListAdapter.setHeader(headerRootLayout); + + whatsNewView = headerRootLayout.findViewById(R.id.whatsNew); + whatsNewView.setOnClickListener(getWhatsNewOnClickListener()); + + resultRecyclerView.setAdapter(infoListAdapter); + + populateView(); + } + + @Override + protected void reloadContent() { + populateView(); + } + + @Override + protected void setErrorMessage(String message, boolean showRetryButton) { + super.setErrorMessage(message, showRetryButton); + resetFragment(); } private void resetFragment() { @@ -153,12 +199,13 @@ public class SubscriptionFragment extends BaseStateFragment>() { @Override public void onSubscribe(Disposable d) { - showLoading(); - disposables.add(d); + animateView(loadingProgressBar, true, 200); + + disposables.add( d ); } @Override public void onNext(List subscriptions) { - handleResult(subscriptions); + animateView(loadingProgressBar, true, 200); + + infoListAdapter.clearStreamItemList(); + infoListAdapter.addInfoItemList( getSubscriptionItems(subscriptions) ); + + animateView(loadingProgressBar, false, 200); + + emptyPanel.setVisibility(subscriptions.isEmpty() ? View.VISIBLE : View.INVISIBLE); + + if (viewState != null && resultRecyclerView != null) { + resultRecyclerView.getLayoutManager().onRestoreInstanceState(viewState); + } } @Override public void onError(Throwable exception) { - SubscriptionFragment.this.onError(exception); + if (exception instanceof IOException) { + onRecoverableError(R.string.network_error); + } else { + onUnrecoverableError(exception); + } } @Override public void onComplete() { + } }; } - @Override - public void handleResult(@NonNull List result) { - super.handleResult(result); - - infoListAdapter.clearStreamItemList(); - - if (result.isEmpty()) { - showEmptyState(); - } else { - infoListAdapter.addInfoItemList(getSubscriptionItems(result)); - if (itemsListState != null) { - itemsList.getLayoutManager().onRestoreInstanceState(itemsListState); - itemsListState = null; - } - - hideLoading(); - } - } - - private List getSubscriptionItems(List subscriptions) { List items = new ArrayList<>(); - for (final SubscriptionEntity subscription : subscriptions) items.add(subscription.toChannelInfoItem()); + for (final SubscriptionEntity subscription: subscriptions) { + ChannelInfoItem item = new ChannelInfoItem(); + item.webPageUrl = subscription.getUrl(); + item.serviceId = subscription.getServiceId(); + item.channelName = subscription.getTitle(); + item.thumbnailUrl = subscription.getThumbnailUrl(); + item.subscriberCount = subscription.getSubscriberCount(); + item.description = subscription.getDescription(); + items.add( item ); + } Collections.sort(items, new Comparator() { @Override public int compare(InfoItem o1, InfoItem o2) { - return o1.name.compareToIgnoreCase(o2.name); + return o1.getTitle().compareToIgnoreCase(o2.getTitle()); } }); + return items; } - /*////////////////////////////////////////////////////////////////////////// - // Contract - //////////////////////////////////////////////////////////////////////////*/ - - @Override - public void showLoading() { - super.showLoading(); - animateView(itemsList, false, 100); - } - - @Override - public void hideLoading() { - super.hideLoading(); - animateView(itemsList, true, 200); - } - - @Override - public void showEmptyState() { - super.showEmptyState(); - animateView(itemsList, false, 200); - } - /////////////////////////////////////////////////////////////////////////// // Fragment Error Handling /////////////////////////////////////////////////////////////////////////// - @Override - protected boolean onError(Throwable exception) { - resetFragment(); - if (super.onError(exception)) return true; + private void onRecoverableError(int messageId) { + if (!this.isAdded()) return; - onUnrecoverableError(exception, UserAction.SOMETHING_ELSE, "none", "Subscriptions", R.string.general_error); - return true; + if (DEBUG) Log.d(TAG, "onError() called with: messageId = [" + messageId + "]"); + setErrorMessage(getString(messageId), true); + } + + private void onUnrecoverableError(Throwable exception) { + if (DEBUG) Log.d(TAG, "onUnrecoverableError() called with: exception = [" + exception + "]"); + ErrorActivity.reportError(getContext(), exception, MainActivity.class, null, ErrorActivity.ErrorInfo.make(REQUESTED_CHANNEL, "unknown", "unknown", R.string.general_error)); + activity.finish(); } } diff --git a/app/src/main/java/org/schabi/newpipe/info_list/PlaylistInfoItemHolder.java b/app/src/main/java/org/schabi/newpipe/info_list/PlaylistInfoItemHolder.java new file mode 100644 index 000000000..ffd3b09e8 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/info_list/PlaylistInfoItemHolder.java @@ -0,0 +1,49 @@ +package org.schabi.newpipe.info_list; + +import android.view.View; +import android.widget.ImageView; +import android.widget.TextView; + +import org.schabi.newpipe.R; +import org.schabi.newpipe.extractor.InfoItem; + +/** + * Created by Christian Schabesberger on 12.02.17. + * + * Copyright (C) Christian Schabesberger 2016 + * ChannelInfoItemHolder .java is part of NewPipe. + * + * NewPipe is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * NewPipe is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with NewPipe. If not, see . + */ + +public class PlaylistInfoItemHolder extends InfoItemHolder { + public final ImageView itemThumbnailView; + public final TextView itemPlaylistTitleView; + public final TextView itemAdditionalDetailView; + + public final View itemRoot; + + PlaylistInfoItemHolder(View v) { + super(v); + itemRoot = v.findViewById(R.id.itemRoot); + itemThumbnailView = v.findViewById(R.id.itemThumbnailView); + itemPlaylistTitleView = v.findViewById(R.id.itemPlaylistTitleView); + itemAdditionalDetailView = v.findViewById(R.id.itemAdditionalDetails); + } + + @Override + public InfoItem.InfoType infoType() { + return InfoItem.InfoType.PLAYLIST; + } +} diff --git a/app/src/main/res/layout/playlist_item.xml b/app/src/main/res/layout/playlist_item.xml new file mode 100644 index 000000000..cb734ae15 --- /dev/null +++ b/app/src/main/res/layout/playlist_item.xml @@ -0,0 +1,51 @@ + + + + + + + + + + \ No newline at end of file