mirror of
				https://github.com/TeamNewPipe/NewPipe
				synced 2025-10-31 07:13:00 +00:00 
			
		
		
		
	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:
		| @@ -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" | ||||
| } | ||||
|   | ||||
							
								
								
									
										17
									
								
								app/src/debug/AndroidManifest.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								app/src/debug/AndroidManifest.xml
									
									
									
									
									
										Normal 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> | ||||
							
								
								
									
										63
									
								
								app/src/debug/java/org/schabi/newpipe/DebugApp.java
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										63
									
								
								app/src/debug/java/org/schabi/newpipe/DebugApp.java
									
									
									
									
									
										Normal 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); | ||||
|     } | ||||
| } | ||||
| @@ -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()); | ||||
|  | ||||
|   | ||||
| @@ -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; | ||||
|   | ||||
							
								
								
									
										34
									
								
								app/src/main/java/org/schabi/newpipe/NewPipeDatabase.java
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								app/src/main/java/org/schabi/newpipe/NewPipeDatabase.java
									
									
									
									
									
										Normal 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; | ||||
|     } | ||||
| } | ||||
| @@ -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(); | ||||
| } | ||||
							
								
								
									
										48
									
								
								app/src/main/java/org/schabi/newpipe/database/BasicDAO.java
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										48
									
								
								app/src/main/java/org/schabi/newpipe/database/BasicDAO.java
									
									
									
									
									
										Normal 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); | ||||
| } | ||||
| @@ -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); | ||||
| } | ||||
| @@ -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); | ||||
|     } | ||||
| } | ||||
| @@ -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() { | ||||
|  | ||||
|     } | ||||
| } | ||||
							
								
								
									
										495
									
								
								app/src/main/java/org/schabi/newpipe/fragments/FeedFragment.java
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										495
									
								
								app/src/main/java/org/schabi/newpipe/fragments/FeedFragment.java
									
									
									
									
									
										Normal 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(); | ||||
|     } | ||||
| } | ||||
| @@ -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; | ||||
|         } | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -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(); | ||||
|     } | ||||
| } | ||||
| @@ -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(); | ||||
|     } | ||||
| } | ||||
| @@ -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); | ||||
|         } | ||||
|   | ||||
| @@ -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; | ||||
|   | ||||
| @@ -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: | ||||
|   | ||||
| @@ -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 | ||||
|     //////////////////////////////////////////////////////////////////////////*/ | ||||
|   | ||||
| @@ -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" | ||||
|   | ||||
| @@ -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" | ||||
|   | ||||
							
								
								
									
										17
									
								
								app/src/main/res/layout/empty_view_panel.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								app/src/main/res/layout/empty_view_panel.xml
									
									
									
									
									
										Normal 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> | ||||
							
								
								
									
										19
									
								
								app/src/main/res/layout/fragment_blank.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								app/src/main/res/layout/fragment_blank.xml
									
									
									
									
									
										Normal 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> | ||||
| @@ -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> | ||||
|   | ||||
| @@ -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> | ||||
|   | ||||
							
								
								
									
										49
									
								
								app/src/main/res/layout/fragment_subscription.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										49
									
								
								app/src/main/res/layout/fragment_subscription.xml
									
									
									
									
									
										Normal 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> | ||||
							
								
								
									
										28
									
								
								app/src/main/res/layout/load_item_footer.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								app/src/main/res/layout/load_item_footer.xml
									
									
									
									
									
										Normal 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> | ||||
							
								
								
									
										33
									
								
								app/src/main/res/layout/subscription_header.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										33
									
								
								app/src/main/res/layout/subscription_header.xml
									
									
									
									
									
										Normal 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> | ||||
| @@ -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" | ||||
|   | ||||
| @@ -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> | ||||
|   | ||||
| @@ -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> | ||||
|   | ||||
| @@ -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> | ||||
|   | ||||
| @@ -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> | ||||
|   | ||||
		Reference in New Issue
	
	Block a user
	 Tonelico
					Tonelico