Added basic channel subscription and feed pages (#620)

Added basic channel subscription and feed pages

- Room Persistence for sqlite support.
- RxJava2 for reactive async support.
- Stetho for database inspection support.
- Enabled Multidex for debug build.
This commit is contained in:
Tonelico 2017-08-07 06:02:30 -07:00 committed by Mauricio Colli
parent cb5b0ff764
commit 10c4f7b465
33 changed files with 1775 additions and 32 deletions

View file

@ -18,6 +18,12 @@ android {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
debug {
multiDexEnabled true
debuggable true
applicationIdSuffix ".debug"
}
}
lintOptions {
@ -58,4 +64,16 @@ dependencies {
compile 'com.github.nirhart:parallaxscroll:1.0'
compile 'com.nononsenseapps:filepicker:3.0.0'
compile 'com.google.android.exoplayer:exoplayer:r2.4.2'
debugCompile 'com.facebook.stetho:stetho:1.5.0'
debugCompile 'com.facebook.stetho:stetho-urlconnection:1.5.0'
debugCompile 'com.android.support:multidex:1.0.1'
compile "android.arch.persistence.room:runtime:1.0.0-alpha8"
annotationProcessor "android.arch.persistence.room:compiler:1.0.0-alpha8"
compile "io.reactivex.rxjava2:rxjava:2.1.2"
compile "io.reactivex.rxjava2:rxandroid:2.0.1"
compile 'com.jakewharton.rxbinding2:rxbinding:2.0.0'
compile "android.arch.persistence.room:rxjava2:1.0.0-alpha8"
}

View file

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest
package="org.schabi.newpipe"
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.WAKE_LOCK"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
<application
tools:replace="android:name"
android:name=".DebugApp"/>
</manifest>

View file

@ -0,0 +1,63 @@
package org.schabi.newpipe;
import android.content.Context;
import android.support.multidex.MultiDex;
import com.facebook.stetho.Stetho;
/**
* Copyright (C) Hans-Christoph Steiner 2016 <hans@eds.org>
* App.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 <http://www.gnu.org/licenses/>.
*/
public class DebugApp extends App {
private static final String TAG = DebugApp.class.toString();
@Override
protected void attachBaseContext(Context base) {
super.attachBaseContext(base);
MultiDex.install(this);
}
@Override
public void onCreate() {
super.onCreate();
initStetho();
}
private void initStetho() {
// Create an InitializerBuilder
Stetho.InitializerBuilder initializerBuilder =
Stetho.newInitializerBuilder(this);
// Enable Chrome DevTools
initializerBuilder.enableWebKitInspector(
Stetho.defaultInspectorModulesProvider(this)
);
// Enable command line interface
initializerBuilder.enableDumpapp(
Stetho.defaultDumperPluginsProvider(getApplicationContext())
);
// Use the InitializerBuilder to generate an Initializer
Stetho.Initializer initializer = initializerBuilder.build();
// Initialize Stetho with the Initializer
Stetho.initialize(initializer);
}
}

View file

@ -3,6 +3,7 @@ package org.schabi.newpipe;
import android.app.Application;
import android.content.Context;
import com.facebook.stetho.Stetho;
import com.nostra13.universalimageloader.core.ImageLoader;
import com.nostra13.universalimageloader.core.ImageLoaderConfiguration;
@ -64,6 +65,8 @@ public class App extends Application {
"Could not initialize ACRA crash report", R.string.app_ui_crash));
}
NewPipeDatabase.getInstance( getApplicationContext() );
//init NewPipe
NewPipe.init(Downloader.getInstance());

View file

@ -24,7 +24,11 @@ import android.content.Intent;
import android.content.SharedPreferences;
import android.os.Bundle;
import android.preference.PreferenceManager;
import android.support.design.widget.TabLayout;
import android.support.v4.app.Fragment;
import android.support.v4.app.FragmentManager;
import android.support.v4.app.FragmentStatePagerAdapter;
import android.support.v4.view.ViewPager;
import android.support.v7.app.ActionBar;
import android.support.v7.app.AppCompatActivity;
import android.support.v7.widget.Toolbar;
@ -36,6 +40,9 @@ import android.view.View;
import org.schabi.newpipe.download.DownloadActivity;
import org.schabi.newpipe.extractor.StreamingService;
import org.schabi.newpipe.fragments.FeedFragment;
import org.schabi.newpipe.fragments.MainFragment;
import org.schabi.newpipe.fragments.SubscriptionFragment;
import org.schabi.newpipe.fragments.detail.VideoDetailFragment;
import org.schabi.newpipe.fragments.search.SearchFragment;
import org.schabi.newpipe.settings.SettingsActivity;

View file

@ -0,0 +1,34 @@
package org.schabi.newpipe;
import android.arch.persistence.room.Room;
import android.content.Context;
import android.support.annotation.NonNull;
import org.schabi.newpipe.database.AppDatabase;
import static org.schabi.newpipe.database.AppDatabase.DATABASE_NAME;
public class NewPipeDatabase {
private static AppDatabase sInstance;
// For Singleton instantiation
private static final Object LOCK = new Object();
@NonNull
public synchronized static AppDatabase getInstance(Context context) {
if (sInstance == null) {
synchronized (LOCK) {
if (sInstance == null) {
sInstance = Room.databaseBuilder(
context.getApplicationContext(),
AppDatabase.class,
DATABASE_NAME
).build();
}
}
}
return sInstance;
}
}

View file

@ -0,0 +1,15 @@
package org.schabi.newpipe.database;
import android.arch.persistence.room.Database;
import android.arch.persistence.room.RoomDatabase;
import org.schabi.newpipe.database.subscription.SubscriptionDAO;
import org.schabi.newpipe.database.subscription.SubscriptionEntity;
@Database(entities = {SubscriptionEntity.class}, version = 1, exportSchema = false)
public abstract class AppDatabase extends RoomDatabase{
public static final String DATABASE_NAME = "newpipe.db";
public abstract SubscriptionDAO subscriptionDAO();
}

View file

@ -0,0 +1,48 @@
package org.schabi.newpipe.database;
import android.arch.persistence.room.Dao;
import android.arch.persistence.room.Delete;
import android.arch.persistence.room.Insert;
import android.arch.persistence.room.OnConflictStrategy;
import android.arch.persistence.room.Update;
import java.util.Collection;
import java.util.List;
import io.reactivex.Completable;
import io.reactivex.Flowable;
@Dao
public interface BasicDAO<Entity> {
/* Inserts */
@Insert(onConflict = OnConflictStrategy.FAIL)
long insert(final Entity entity);
@Insert(onConflict = OnConflictStrategy.FAIL)
List<Long> insertAll(final Entity... entities);
@Insert(onConflict = OnConflictStrategy.FAIL)
List<Long> insertAll(final Collection<Entity> entities);
@Insert(onConflict = OnConflictStrategy.REPLACE)
long upsert(final Entity entity);
/* Searches */
Flowable<List<Entity>> findAll();
Flowable<List<Entity>> listByService(int serviceId);
/* Deletes */
@Delete
int delete(final Entity entity);
@Delete
int delete(final Collection<Entity> entities);
/* Updates */
@Update
int update(final Entity entity);
@Update
int update(final Collection<Entity> entities);
}

View file

@ -0,0 +1,30 @@
package org.schabi.newpipe.database.subscription;
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.subscription.SubscriptionEntity.SUBSCRIPTION_SERVICE_ID;
import static org.schabi.newpipe.database.subscription.SubscriptionEntity.SUBSCRIPTION_TABLE;
import static org.schabi.newpipe.database.subscription.SubscriptionEntity.SUBSCRIPTION_URL;
@Dao
public interface SubscriptionDAO extends BasicDAO<SubscriptionEntity> {
@Override
@Query("SELECT * FROM " + SUBSCRIPTION_TABLE)
Flowable<List<SubscriptionEntity>> findAll();
@Override
@Query("SELECT * FROM " + SUBSCRIPTION_TABLE + " WHERE " + SUBSCRIPTION_SERVICE_ID + " = :serviceId")
Flowable<List<SubscriptionEntity>> listByService(int serviceId);
@Query("SELECT * FROM " + SUBSCRIPTION_TABLE + " WHERE " +
SUBSCRIPTION_URL + " LIKE :url AND " +
SUBSCRIPTION_SERVICE_ID + " = :serviceId")
Flowable<List<SubscriptionEntity>> findAll(int serviceId, String url);
}

View file

@ -0,0 +1,113 @@
package org.schabi.newpipe.database.subscription;
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 static org.schabi.newpipe.database.subscription.SubscriptionEntity.SUBSCRIPTION_SERVICE_ID;
import static org.schabi.newpipe.database.subscription.SubscriptionEntity.SUBSCRIPTION_TABLE;
import static org.schabi.newpipe.database.subscription.SubscriptionEntity.SUBSCRIPTION_URL;
@Entity(tableName = SUBSCRIPTION_TABLE,
indices = {@Index(value = {SUBSCRIPTION_SERVICE_ID, SUBSCRIPTION_URL}, unique = true)})
public class SubscriptionEntity {
final static String SUBSCRIPTION_TABLE = "subscriptions";
final static String SUBSCRIPTION_SERVICE_ID = "service_id";
final static String SUBSCRIPTION_URL = "url";
final static String SUBSCRIPTION_TITLE = "title";
final static String SUBSCRIPTION_THUMBNAIL_URL = "thumbnail_url";
final static String SUBSCRIPTION_SUBSCRIBER_COUNT = "subscriber_count";
final static String SUBSCRIPTION_DESCRIPTION = "description";
@PrimaryKey(autoGenerate = true)
private long uid = 0;
@ColumnInfo(name = SUBSCRIPTION_SERVICE_ID)
private int serviceId = -1;
@ColumnInfo(name = SUBSCRIPTION_URL)
private String url;
@ColumnInfo(name = SUBSCRIPTION_TITLE)
private String title;
@ColumnInfo(name = SUBSCRIPTION_THUMBNAIL_URL)
private String thumbnailUrl;
@ColumnInfo(name = SUBSCRIPTION_SUBSCRIBER_COUNT)
private Long subscriberCount;
@ColumnInfo(name = SUBSCRIPTION_DESCRIPTION)
private String description;
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 int getServiceId() {
return serviceId;
}
public void setServiceId(int serviceId) {
this.serviceId = serviceId;
}
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 getSubscriberCount() {
return subscriberCount;
}
public void setSubscriberCount(Long subscriberCount) {
this.subscriberCount = subscriberCount;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
@Ignore
public void setData(final String title,
final String thumbnailUrl,
final String description,
final Long subscriberCount) {
this.setTitle(title);
this.setThumbnailUrl(thumbnailUrl);
this.setDescription(description);
this.setSubscriberCount(subscriberCount);
}
}

View file

@ -0,0 +1,22 @@
package org.schabi.newpipe.fragments;
import android.os.Bundle;
import android.support.annotation.Nullable;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import org.schabi.newpipe.R;
public class BlankFragment extends BaseFragment {
@Nullable
@Override
public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, Bundle savedInstanceState) {
return inflater.inflate(R.layout.fragment_blank, container, false);
}
@Override
protected void reloadContent() {
}
}

View file

@ -0,0 +1,495 @@
package org.schabi.newpipe.fragments;
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.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 SubscriptionService subscriptionService;
private Disposable loadItemObserver;
private Disposable subscriptionObserver;
private Subscription feedSubscriber;
///////////////////////////////////////////////////////////////////////////
// Fragment LifeCycle
///////////////////////////////////////////////////////////////////////////
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
subscriptionService = SubscriptionService.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<InfoItem> 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() {
subscriptionService = 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<List<SubscriptionEntity>> consumer = new Consumer<List<SubscriptionEntity>>() {
@Override
public void accept(@NonNull List<SubscriptionEntity> 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<Throwable> onError = new Consumer<Throwable>() {
@Override
public void accept(@NonNull Throwable exception) throws Exception {
onRxError(exception, "Subscription Database Reactor");
}
};
if (subscriptionObserver != null) subscriptionObserver.dispose();
subscriptionObserver = subscriptionService.getSubscription()
.onErrorReturnItem(Collections.<SubscriptionEntity>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<SubscriptionEntity> getSubscriptionObserver() {
return new Subscriber<SubscriptionEntity>() {
@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);
subscriptionService.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<ChannelInfo> getChannelInfoObserver() {
return new MaybeObserver<ChannelInfo>() {
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<InfoItem> 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<Object> onNext = new Consumer<Object>() {
@Override
public void accept(Object o) throws Exception {
requestFeed(FEED_LOAD_SIZE);
}
};
final Consumer<Throwable> onError = new Consumer<Throwable>() {
@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();
}
}

View file

@ -3,7 +3,11 @@ package org.schabi.newpipe.fragments;
import android.content.Context;
import android.os.Bundle;
import android.support.annotation.Nullable;
import android.support.design.widget.TabLayout;
import android.support.v4.app.Fragment;
import android.support.v4.app.FragmentManager;
import android.support.v4.app.FragmentPagerAdapter;
import android.support.v4.view.ViewPager;
import android.support.v7.app.ActionBar;
import android.support.v7.app.AppCompatActivity;
import android.util.Log;
@ -18,12 +22,14 @@ import org.schabi.newpipe.MainActivity;
import org.schabi.newpipe.R;
import org.schabi.newpipe.util.NavigationHelper;
public class MainFragment extends Fragment {
public class MainFragment extends Fragment implements TabLayout.OnTabSelectedListener {
private final String TAG = "MainFragment@" + Integer.toHexString(hashCode());
private static final boolean DEBUG = MainActivity.DEBUG;
private AppCompatActivity activity;
private ViewPager viewPager;
/*//////////////////////////////////////////////////////////////////////////
// Fragment's LifeCycle
//////////////////////////////////////////////////////////////////////////*/
@ -45,7 +51,19 @@ public class MainFragment extends Fragment {
@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_main, container, false);
View inflatedView = inflater.inflate(R.layout.fragment_main, container, false);
TabLayout tabLayout = (TabLayout) inflatedView.findViewById(R.id.main_tab_layout);
viewPager = (ViewPager) inflatedView.findViewById(R.id.pager);
/* Nested fragment, use child fragment here to maintain backstack in view pager. */
PagerAdapter adapter = new PagerAdapter(getChildFragmentManager());
viewPager.setAdapter(adapter);
viewPager.setOffscreenPageLimit(adapter.getCount());
tabLayout.setupWithViewPager(viewPager);
return inflatedView;
}
/*//////////////////////////////////////////////////////////////////////////
@ -74,4 +92,47 @@ public class MainFragment extends Fragment {
}
return super.onOptionsItemSelected(item);
}
@Override
public void onTabSelected(TabLayout.Tab tab) {
viewPager.setCurrentItem(tab.getPosition());
}
@Override
public void onTabUnselected(TabLayout.Tab tab) {}
@Override
public void onTabReselected(TabLayout.Tab tab) {}
private class PagerAdapter extends FragmentPagerAdapter {
private int[] tabTitles = new int[]{
R.string.tab_main,
R.string.tab_subscriptions
};
PagerAdapter(FragmentManager fm) {
super(fm);
}
@Override
public Fragment getItem(int position) {
switch ( position ) {
case 1:
return new SubscriptionFragment();
default:
return new BlankFragment();
}
}
@Override
public CharSequence getPageTitle(int position) {
return getString(this.tabTitles[position]);
}
@Override
public int getCount() {
return this.tabTitles.length;
}
}
}

View file

@ -0,0 +1,278 @@
package org.schabi.newpipe.fragments;
import android.os.Bundle;
import android.os.Parcelable;
import android.support.annotation.Nullable;
import android.support.v7.widget.LinearLayoutManager;
import android.support.v7.widget.RecyclerView;
import android.util.Log;
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.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.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
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 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 resultRecyclerView;
private Parcelable viewState;
/* Used for independent events */
private CompositeDisposable disposables;
private SubscriptionService subscriptionService;
///////////////////////////////////////////////////////////////////////////
// Fragment LifeCycle
///////////////////////////////////////////////////////////////////////////
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
disposables = new CompositeDisposable();
subscriptionService = SubscriptionService.getInstance( getContext() );
if (savedInstanceState != null) {
viewState = savedInstanceState.getParcelable(VIEW_STATE_KEY);
}
}
@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);
outState.putParcelable(VIEW_STATE_KEY, viewState);
}
@Override
public void onDestroyView() {
if (disposables != null) disposables.clear();
headerRootLayout = null;
whatsNewView = null;
super.onDestroyView();
}
@Override
public void onDestroy() {
if (disposables != null) disposables.dispose();
disposables = null;
subscriptionService = null;
super.onDestroy();
}
///////////////////////////////////////////////////////////////////////////
// Fragment Views
///////////////////////////////////////////////////////////////////////////
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();
}
}
};
}
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() {
if (disposables != null) disposables.clear();
if (infoListAdapter != null) infoListAdapter.clearStreamItemList();
}
///////////////////////////////////////////////////////////////////////////
// Subscriptions Loader
///////////////////////////////////////////////////////////////////////////
private void populateView() {
resetFragment();
animateView(loadingProgressBar, true, 200);
animateView(errorPanel, false, 200);
subscriptionService.getSubscription().toObservable()
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(getSubscriptionObserver());
}
private Observer<List<SubscriptionEntity>> getSubscriptionObserver() {
return new Observer<List<SubscriptionEntity>>() {
@Override
public void onSubscribe(Disposable d) {
animateView(loadingProgressBar, true, 200);
disposables.add( d );
}
@Override
public void onNext(List<SubscriptionEntity> 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) {
if (exception instanceof IOException) {
onRecoverableError(R.string.network_error);
} else {
onUnrecoverableError(exception);
}
}
@Override
public void onComplete() {
}
};
}
private List<InfoItem> getSubscriptionItems(List<SubscriptionEntity> subscriptions) {
List<InfoItem> items = new ArrayList<>();
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<InfoItem>() {
@Override
public int compare(InfoItem o1, InfoItem o2) {
return o1.getTitle().compareToIgnoreCase(o2.getTitle());
}
});
return items;
}
///////////////////////////////////////////////////////////////////////////
// Fragment Error Handling
///////////////////////////////////////////////////////////////////////////
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) {
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();
}
}

View file

@ -0,0 +1,170 @@
package org.schabi.newpipe.fragments;
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 SubscriptionService {
private static SubscriptionService sInstance;
private static final Object LOCK = new Object();
public static SubscriptionService getInstance(Context context) {
if (sInstance == null) {
synchronized (LOCK) {
if (sInstance == null) {
sInstance = new SubscriptionService(context);
}
}
}
return sInstance;
}
protected final String TAG = "SubscriptionService@" + 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<List<SubscriptionEntity>> subscription;
private Scheduler subscriptionScheduler;
private SubscriptionService(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 SubscriptionService#getSubscription()
*/
private Flowable<List<SubscriptionEntity>> 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<List<SubscriptionEntity>> getSubscription() {
return subscription;
}
public Maybe<ChannelInfo> getChannelInfo(final SubscriptionEntity subscriptionEntity) {
final StreamingService service = getService(subscriptionEntity.getServiceId());
if (service == null) return Maybe.empty();
final String url = subscriptionEntity.getUrl();
final Callable<ChannelInfo> callable = new Callable<ChannelInfo>() {
@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<List<SubscriptionEntity>, CompletableSource> update = new Function<List<SubscriptionEntity>, CompletableSource>() {
@Override
public CompletableSource apply(@NonNull List<SubscriptionEntity> 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();
}
}

View file

@ -20,12 +20,17 @@ import android.view.ViewGroup;
import android.widget.Button;
import android.widget.ImageView;
import android.widget.TextView;
import android.widget.Toast;
import com.jakewharton.rxbinding2.view.RxView;
import org.schabi.newpipe.ImageErrorLoadingListener;
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.fragments.SubscriptionService;
import org.schabi.newpipe.fragments.search.OnScrollBelowItemsListener;
import org.schabi.newpipe.info_list.InfoItemBuilder;
import org.schabi.newpipe.info_list.InfoListAdapter;
@ -36,16 +41,30 @@ import org.schabi.newpipe.workers.ChannelExtractorWorker;
import java.io.Serializable;
import java.text.NumberFormat;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;
import io.reactivex.Observer;
import io.reactivex.android.schedulers.AndroidSchedulers;
import io.reactivex.annotations.NonNull;
import io.reactivex.disposables.CompositeDisposable;
import io.reactivex.disposables.Disposable;
import io.reactivex.functions.Action;
import io.reactivex.functions.Consumer;
import io.reactivex.functions.Function;
import io.reactivex.schedulers.Schedulers;
import static org.schabi.newpipe.util.AnimationUtils.animateView;
public class ChannelFragment extends BaseFragment implements ChannelExtractorWorker.OnChannelInfoReceive {
private final String TAG = "ChannelFragment@" + Integer.toHexString(hashCode());
private final String TAG = "ChannelFragment@" + Integer.toHexString(hashCode());
private static final String INFO_LIST_KEY = "info_list_key";
private static final String CHANNEL_INFO_KEY = "channel_info_key";
private static final String PAGE_NUMBER_KEY = "page_number_key";
private static final int BUTTON_DEBOUNCE_INTERVAL = 100;
private InfoListAdapter infoListAdapter;
private ChannelExtractorWorker currentChannelWorker;
@ -53,9 +72,15 @@ public class ChannelFragment extends BaseFragment implements ChannelExtractorWor
private int serviceId = -1;
private String channelName = "";
private String channelUrl = "";
private String feedUrl = "";
private int pageNumber = 0;
private boolean hasNextPage = true;
private SubscriptionService subscriptionService;
private CompositeDisposable disposables;
private Disposable subscribeButtonMonitor;
/*//////////////////////////////////////////////////////////////////////////
// Views
//////////////////////////////////////////////////////////////////////////*/
@ -67,7 +92,7 @@ public class ChannelFragment extends BaseFragment implements ChannelExtractorWor
private ImageView headerAvatarView;
private TextView headerTitleView;
private TextView headerSubscribersTextView;
private Button headerRssButton;
private Button headerSubscribeButton;
/*////////////////////////////////////////////////////////////////////////*/
@ -127,7 +152,13 @@ public class ChannelFragment extends BaseFragment implements ChannelExtractorWor
headerAvatarView = null;
headerTitleView = null;
headerSubscribersTextView = null;
headerRssButton = null;
headerSubscribeButton = null;
if (disposables != null) disposables.dispose();
if (subscribeButtonMonitor != null) subscribeButtonMonitor.dispose();
disposables = null;
subscribeButtonMonitor = null;
subscriptionService = null;
super.onDestroyView();
}
@ -176,6 +207,7 @@ public class ChannelFragment extends BaseFragment implements ChannelExtractorWor
supportActionBar.setDisplayShowTitleEnabled(true);
supportActionBar.setDisplayHomeAsUpEnabled(true);
}
menu.findItem(R.id.menu_item_rss).setVisible( !TextUtils.isEmpty(feedUrl) );
}
@Override
@ -190,13 +222,21 @@ public class ChannelFragment extends BaseFragment implements ChannelExtractorWor
startActivity(Intent.createChooser(intent, getString(R.string.choose_browser)));
return true;
}
case R.id.menu_item_share:
case R.id.menu_item_rss: {
Intent intent = new Intent();
intent.setAction(Intent.ACTION_VIEW);
intent.setData(Uri.parse(currentChannelInfo.feed_url));
startActivity(intent);
return true;
}
case R.id.menu_item_share: {
Intent intent = new Intent();
intent.setAction(Intent.ACTION_SEND);
intent.putExtra(Intent.EXTRA_TEXT, channelUrl);
intent.setType("text/plain");
startActivity(Intent.createChooser(intent, getString(R.string.share_dialog_title)));
return true;
}
default:
return super.onOptionsItemSelected(item);
}
@ -231,7 +271,10 @@ public class ChannelFragment extends BaseFragment implements ChannelExtractorWor
headerAvatarView = (ImageView) headerRootLayout.findViewById(R.id.channel_avatar_view);
headerTitleView = (TextView) headerRootLayout.findViewById(R.id.channel_title_view);
headerSubscribersTextView = (TextView) headerRootLayout.findViewById(R.id.channel_subscriber_view);
headerRssButton = (Button) headerRootLayout.findViewById(R.id.channel_rss_button);
headerSubscribeButton = (Button) headerRootLayout.findViewById(R.id.channel_subscribe_button);
disposables = new CompositeDisposable();
subscriptionService = SubscriptionService.getInstance( getContext() );
}
protected void initListeners() {
@ -255,17 +298,9 @@ public class ChannelFragment extends BaseFragment implements ChannelExtractorWor
}
}
});
headerRssButton.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
if (DEBUG) Log.d(TAG, "onClick() called with: view = [" + view + "] feed url > " + currentChannelInfo.feed_url);
Intent i = new Intent(Intent.ACTION_VIEW, Uri.parse(currentChannelInfo.feed_url));
startActivity(i);
}
});
}
@Override
protected void reloadContent() {
if (DEBUG) Log.d(TAG, "reloadContent() called");
@ -274,6 +309,133 @@ public class ChannelFragment extends BaseFragment implements ChannelExtractorWor
loadPage(0);
}
/*//////////////////////////////////////////////////////////////////////////
// Channel Subscription
//////////////////////////////////////////////////////////////////////////*/
private void monitorSubscription(final int serviceId,
final String channelUrl,
final ChannelInfo info) {
subscriptionService.subscriptionTable().findAll(serviceId, channelUrl)
.toObservable()
.observeOn(AndroidSchedulers.mainThread())
.subscribe(getSubscribeButtonMonitor(serviceId, channelUrl, info));
}
private Function<Object, Object> mapOnSubscribe(final SubscriptionEntity subscription) {
return new Function<Object, Object>() {
@Override
public Object apply(@NonNull Object o) throws Exception {
subscriptionService.subscriptionTable().insert( subscription );
return o;
}
};
}
private Function<Object, Object> mapOnUnsubscribe(final SubscriptionEntity subscription) {
return new Function<Object, Object>() {
@Override
public Object apply(@NonNull Object o) throws Exception {
subscriptionService.subscriptionTable().delete( subscription );
return o;
}
};
}
private Observer<List<SubscriptionEntity>> getSubscribeButtonMonitor(final int serviceId,
final String channelUrl,
final ChannelInfo info) {
return new Observer<List<SubscriptionEntity>>() {
@Override
public void onSubscribe(Disposable d) {
disposables.add( d );
}
@Override
public void onNext(List<SubscriptionEntity> subscriptionEntities) {
if (subscribeButtonMonitor != null) subscribeButtonMonitor.dispose();
if (subscriptionEntities.isEmpty()) {
if (DEBUG) Log.d(TAG, "No subscription to this channel!");
SubscriptionEntity channel = new SubscriptionEntity();
channel.setServiceId( serviceId );
channel.setUrl( channelUrl );
channel.setData(info.channel_name, info.avatar_url, "", info.subscriberCount);
subscribeButtonMonitor = monitorSubscribeButton(headerSubscribeButton, mapOnSubscribe(channel));
headerSubscribeButton.setText(R.string.subscribe_button_title);
} else {
if (DEBUG) Log.d(TAG, "Found subscription to this channel!");
final SubscriptionEntity subscription = subscriptionEntities.get(0);
subscribeButtonMonitor = monitorSubscribeButton(headerSubscribeButton, mapOnUnsubscribe(subscription));
headerSubscribeButton.setText(R.string.subscribed_button_title);
}
headerSubscribeButton.setVisibility(View.VISIBLE);
}
@Override
public void onError(Throwable throwable) {
Log.e(TAG, "Status get failed", throwable);
headerSubscribeButton.setVisibility(View.INVISIBLE);
}
@Override
public void onComplete() {}
};
}
private Disposable monitorSubscribeButton(final Button subscribeButton,
final Function<Object, Object> action) {
final Consumer<Object> onNext = new Consumer<Object>() {
@Override
public void accept(@NonNull Object o) throws Exception {
if (DEBUG) Log.d(TAG, "Changed subscription status to this channel!");
}
};
final Consumer<Throwable> onError = new Consumer<Throwable>() {
@Override
public void accept(@NonNull Throwable throwable) throws Exception {
if (DEBUG) Log.e(TAG, "Subscription Fatal Error: ", throwable.getCause());
Toast.makeText(getContext(), R.string.subscription_change_failed, Toast.LENGTH_SHORT).show();
}
};
/* 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 Disposable updateSubscription(final int serviceId,
final String channelUrl,
final ChannelInfo info) {
final Action onComplete = new Action() {
@Override
public void run() throws Exception {
if (DEBUG) Log.d(TAG, "Updated subscription: " + channelUrl);
}
};
final Consumer<Throwable> onError = new Consumer<Throwable>() {
@Override
public void accept(@NonNull Throwable throwable) throws Exception {
Log.e(TAG, "Subscription Update Fatal Error: ", throwable);
Toast.makeText(getContext(), R.string.subscription_update_failed, Toast.LENGTH_SHORT).show();
}
};
return subscriptionService.updateChannelInfo(serviceId, channelUrl, info)
.observeOn(AndroidSchedulers.mainThread())
.subscribe(onComplete, onError);
}
/*//////////////////////////////////////////////////////////////////////////
// Utils
//////////////////////////////////////////////////////////////////////////*/
@ -297,7 +459,7 @@ public class ChannelFragment extends BaseFragment implements ChannelExtractorWor
imageLoader.cancelDisplayTask(headerChannelBanner);
imageLoader.cancelDisplayTask(headerAvatarView);
headerRssButton.setVisibility(View.GONE);
headerSubscribeButton.setVisibility(View.GONE);
headerSubscribersTextView.setVisibility(View.GONE);
headerTitleView.setText(channelName != null ? channelName : "");
@ -331,6 +493,9 @@ public class ChannelFragment extends BaseFragment implements ChannelExtractorWor
animateView(loadingProgressBar, false, 200);
if (!onlyVideos) {
feedUrl = info.feed_url;
if (activity.getSupportActionBar() != null) activity.getSupportActionBar().invalidateOptionsMenu();
headerRootLayout.setVisibility(View.VISIBLE);
//animateView(loadingProgressBar, false, 200, null);
@ -354,8 +519,10 @@ public class ChannelFragment extends BaseFragment implements ChannelExtractorWor
headerSubscribersTextView.setVisibility(View.VISIBLE);
} else headerSubscribersTextView.setVisibility(View.GONE);
if (!TextUtils.isEmpty(info.feed_url)) headerRssButton.setVisibility(View.VISIBLE);
else headerRssButton.setVisibility(View.INVISIBLE);
if (disposables != null) disposables.clear();
if (subscribeButtonMonitor != null) subscribeButtonMonitor.dispose();
disposables.add( updateSubscription(serviceId, channelUrl, info) );
monitorSubscription(serviceId, channelUrl, info);
infoListAdapter.showFooter(true);
}

View file

@ -231,14 +231,13 @@ public class InfoItemBuilder {
holder.itemRoot.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
if(onStreamInfoItemSelectedListener != null) {
if(onChannelInfoItemSelectedListener != null) {
onChannelInfoItemSelectedListener.selected(info.serviceId, info.getLink(), info.channelName);
}
}
});
}
public String shortViewCount(Long viewCount) {
if (viewCount >= 1000000000) {
return Long.toString(viewCount / 1000000000) + billion + " " + viewsS;

View file

@ -2,6 +2,7 @@ package org.schabi.newpipe.info_list;
import android.app.Activity;
import android.support.v7.widget.RecyclerView;
import android.text.Layout;
import android.util.Log;
import android.view.LayoutInflater;
import android.view.View;
@ -9,6 +10,7 @@ import android.view.ViewGroup;
import org.schabi.newpipe.R;
import org.schabi.newpipe.extractor.InfoItem;
import org.schabi.newpipe.extractor.channel.ChannelInfoItem;
import java.util.ArrayList;
import java.util.List;
@ -77,6 +79,13 @@ public class InfoListAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolde
}
}
public void addInfoItem(InfoItem data) {
if (data != null) {
infoItemList.add( data );
notifyDataSetChanged();
}
}
public void clearStreamItemList() {
if(infoItemList.isEmpty()) {
return;
@ -118,7 +127,8 @@ public class InfoListAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolde
if(footer != null && position == infoItemList.size() && showFooter) {
return 1;
}
switch(infoItemList.get(position).infoType()) {
InfoItem item = infoItemList.get(position);
switch(item.infoType()) {
case STREAM:
return 2;
case CHANNEL:

View file

@ -18,6 +18,7 @@ import org.schabi.newpipe.extractor.NewPipe;
import org.schabi.newpipe.extractor.StreamingService;
import org.schabi.newpipe.extractor.stream_info.AudioStream;
import org.schabi.newpipe.extractor.stream_info.StreamInfo;
import org.schabi.newpipe.fragments.FeedFragment;
import org.schabi.newpipe.fragments.MainFragment;
import org.schabi.newpipe.fragments.channel.ChannelFragment;
import org.schabi.newpipe.fragments.detail.VideoDetailFragment;
@ -139,6 +140,14 @@ public class NavigationHelper {
.commit();
}
public static void openWhatsNewFragment(FragmentManager fragmentManager) {
fragmentManager.beginTransaction()
.setCustomAnimations(R.anim.custom_fade_in, R.anim.custom_fade_out, R.anim.custom_fade_in, R.anim.custom_fade_out)
.replace(R.id.fragment_holder, new FeedFragment())
.addToBackStack(null)
.commit();
}
/*//////////////////////////////////////////////////////////////////////////
// Through Intents
//////////////////////////////////////////////////////////////////////////*/

View file

@ -68,7 +68,7 @@
tools:visibility="visible"/>
<Button
android:id="@+id/channel_rss_button"
android:id="@+id/channel_subscribe_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentRight="true"
@ -76,8 +76,7 @@
android:layout_gravity="center_vertical|right"
android:layout_marginRight="4dp"
android:layout_marginTop="2dp"
android:drawableLeft="@drawable/ic_rss_feed_white_24dp"
android:text="@string/rss_button_title"
android:text="@string/subscribe_button_title"
android:textSize="@dimen/channel_rss_title_size"
android:theme="@style/RedButton"
android:visibility="gone"

View file

@ -49,7 +49,7 @@
android:layout_below="@id/channel_banner_image"
android:layout_marginLeft="8dp"
android:layout_marginTop="6dp"
android:layout_toLeftOf="@+id/channel_rss_button"
android:layout_toLeftOf="@+id/channel_subscribe_button"
android:layout_toRightOf="@+id/channel_avatar_layout"
android:textAppearance="?android:attr/textAppearanceLarge"
android:textSize="@dimen/video_item_detail_title_text_size"
@ -70,15 +70,14 @@
tools:visibility="visible"/>
<Button
android:id="@+id/channel_rss_button"
android:id="@+id/channel_subscribe_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentRight="true"
android:layout_below="@+id/channel_banner_image"
android:layout_gravity="center_vertical|right"
android:layout_marginRight="2dp"
android:drawableLeft="@drawable/ic_rss_feed_white_24dp"
android:text="@string/rss_button_title"
android:text="@string/subscribe_button_title"
android:textSize="@dimen/channel_rss_title_size"
android:theme="@style/RedButton"
android:visibility="gone"

View file

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:gravity="center">
<TextView
android:textAppearance="?android:attr/textAppearanceLarge"
android:text="¯\\_(ツ)_/¯"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
<TextView
android:text="Nothing Here But Crickets"
android:layout_gravity="center"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
</LinearLayout>

View file

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
xmlns:tools="http://schemas.android.com/tools">
<include layout="@layout/main_bg" />
<include
android:id="@+id/error_panel"
layout="@layout/error_retry"
tools:visibility="visible"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerHorizontal="true"
android:layout_marginTop="50dp"
android:visibility="gone" />
</RelativeLayout>

View file

@ -32,4 +32,14 @@
android:visibility="gone"
tools:visibility="visible" />
<include
android:id="@+id/empty_panel"
layout="@layout/empty_view_panel"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerInParent="true"
android:layout_marginTop="50dp"
android:visibility="gone"
tools:visibility="visible"/>
</RelativeLayout>

View file

@ -1,8 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_height="match_parent"
android:layout_width="match_parent">
android:layout_width="match_parent"
xmlns:app="http://schemas.android.com/apk/res-auto">
<include layout="@layout/main_bg" />
<android.support.design.widget.TabLayout
android:id="@+id/main_tab_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_alignParentBottom="true"
app:tabGravity="fill"/>
<android.support.v4.view.ViewPager
android:id="@+id/pager"
android:layout_width="match_parent"
android:layout_height="fill_parent"
android:layout_above="@id/main_tab_layout"/>
</RelativeLayout>

View file

@ -0,0 +1,49 @@
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:focusable="true"
android:focusableInTouchMode="true">
<android.support.v7.widget.RecyclerView
android:id="@+id/result_list_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scrollbars="vertical"
app:layoutManager="LinearLayoutManager"
tools:listitem="@layout/channel_item"/>
<ProgressBar
android:id="@+id/loading_progress_bar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerInParent="true"
android:indeterminate="true"
android:visibility="gone"
tools:visibility="visible"/>
<!--ERROR PANEL-->
<include
android:id="@+id/error_panel"
layout="@layout/error_retry"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerHorizontal="true"
android:layout_marginTop="50dp"
android:visibility="gone"
tools:visibility="visible"/>
<include
android:id="@+id/empty_panel"
layout="@layout/empty_view_panel"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerInParent="true"
android:layout_marginTop="50dp"
android:visibility="gone"
tools:visibility="visible"/>
</RelativeLayout>

View file

@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/itemRoot"
android:layout_width="match_parent"
android:layout_height="@dimen/video_item_search_height"
android:background="?attr/selectableItemBackground"
android:clickable="false"
android:padding="@dimen/video_item_search_padding">
<TextView
android:id="@+id/load_more_text"
android:text="Load More"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerInParent="true"
android:textAppearance="?android:attr/textAppearanceLarge"
android:textSize="@dimen/header_footer_text_size"
android:visibility="gone"/>
<ProgressBar
android:id="@+id/paginate_progress_bar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerInParent="true"
android:visibility="gone"/>
</RelativeLayout>

View file

@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/channel_header_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp">
<TextView
android:id="@+id/whatsNew"
android:layout_width="match_parent"
android:layout_height="50dp"
android:paddingLeft="12dp"
android:paddingRight="12dp"
android:drawableLeft="?attr/rss"
android:drawablePadding="5dp"
android:text="@string/fragment_whats_new"
android:textAppearance="?android:attr/textAppearanceLarge"
android:textSize="@dimen/header_footer_text_size"
android:gravity="left|center"
android:clickable="true"
tools:ignore="RtlHardcoded"/>
<View
android:layout_width="match_parent"
android:layout_height="1px"
android:layout_marginLeft="8dp"
android:layout_marginRight="8dp"
android:layout_below="@id/whatsNew"
android:background="?attr/colorAccent" />
</RelativeLayout>

View file

@ -7,6 +7,11 @@
app:showAsAction="never"
android:title="@string/open_in_browser" />
<item android:id="@+id/menu_item_rss"
app:showAsAction="ifRoom"
android:title="@string/rss_button_title"
android:icon="?attr/rss"/>
<item android:id="@+id/menu_item_share"
android:title="@string/share"
app:showAsAction="ifRoom"

View file

@ -8,6 +8,7 @@
<dimen name="video_item_search_duration_text_size">12sp</dimen>
<dimen name="video_item_search_uploader_text_size">14sp</dimen>
<dimen name="video_item_search_upload_date_text_size">14sp</dimen>
<dimen name="header_footer_text_size">18sp</dimen>
<!-- Elements Size -->
<!-- 16 / 9 ratio-->
<dimen name="video_item_search_thumbnail_image_width">142dp</dimen>

View file

@ -7,6 +7,7 @@
<dimen name="video_item_search_duration_text_size">11sp</dimen>
<dimen name="video_item_search_uploader_text_size">11sp</dimen>
<dimen name="video_item_search_upload_date_text_size">12sp</dimen>
<dimen name="header_footer_text_size">16sp</dimen>
<!-- Elements Size -->
<!-- 16 / 9 ratio-->
<dimen name="video_item_search_thumbnail_image_width">124dp</dimen>

View file

@ -23,6 +23,16 @@
<string name="use_external_audio_player_title">Use external audio player</string>
<string name="popup_mode_share_menu_title">NewPipe Popup mode</string>
<string name="rss_button_title" translatable="false">RSS</string>
<string name="subscribe_button_title">Subscribe</string>
<string name="subscribed_button_title">Subscribed</string>
<string name="channel_unsubscribed">Channel unsubscribed</string>
<string name="subscription_change_failed">Unable to change subscription</string>
<string name="subscription_update_failed">Unable to update subscription</string>
<string name="tab_main">Main</string>
<string name="tab_subscriptions">Subscriptions</string>
<string name="fragment_whats_new">What\'s New</string>
<string name="controls_background_title">Background</string>
<string name="controls_popup_title">Popup</string>

View file

@ -44,7 +44,7 @@
<item name="download">@drawable/ic_file_download_white_24dp</item>
<item name="share">@drawable/ic_share_white_24dp</item>
<item name="cast">@drawable/ic_cast_white_24dp</item>
<item name="rss">@drawable/ic_rss_feed_black_24dp</item>
<item name="rss">@drawable/ic_rss_feed_white_24dp</item>
<item name="search">@drawable/ic_search_white_24dp</item>
<item name="close">@drawable/ic_close_white_24dp</item>
<item name="filter">@drawable/ic_filter_list_white_24dp</item>