() {
- @Override
- public void onSubscribe(Subscription s) {
- if (feedSubscriber != null) feedSubscriber.cancel();
- feedSubscriber = s;
-
- int requestSize = FEED_LOAD_COUNT - infoListAdapter.getItemsList().size();
- if (wasLoading.getAndSet(false)) requestSize = FEED_LOAD_COUNT;
-
- boolean hasToLoad = requestSize > 0;
- if (hasToLoad) {
- requestLoadedAtomic.set(infoListAdapter.getItemsList().size());
- requestFeed(requestSize);
- }
- isLoading.set(hasToLoad);
- }
-
- @Override
- public void onNext(SubscriptionEntity subscriptionEntity) {
- if (!itemsLoaded.contains(subscriptionEntity.getServiceId() + subscriptionEntity.getUrl())) {
- subscriptionService.getChannelInfo(subscriptionEntity)
- .observeOn(AndroidSchedulers.mainThread())
- .onErrorComplete(
- (@io.reactivex.annotations.NonNull Throwable throwable) ->
- FeedFragment.super.onError(throwable))
- .subscribe(
- getChannelInfoObserver(subscriptionEntity.getServiceId(),
- subscriptionEntity.getUrl()));
- } else {
- requestFeed(1);
- }
- }
-
- @Override
- public void onError(Throwable exception) {
- FeedFragment.this.onError(exception);
- }
-
- @Override
- public void onComplete() {
- if (DEBUG) Log.d(TAG, "getSubscriptionObserver > onComplete() called");
- }
- };
- }
-
- /**
- * 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 close 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.
- *
- * @param url + serviceId to put in {@link #allItemsLoaded} to signal that this specific entity has been loaded.
- */
- private MaybeObserver getChannelInfoObserver(final int serviceId, final String url) {
- return new MaybeObserver() {
- private Disposable observer;
-
- @Override
- public void onSubscribe(Disposable d) {
- observer = d;
- compositeDisposable.add(d);
- isLoading.set(true);
- }
-
- // Called only when response is non-empty
- @Override
- public void onSuccess(final ChannelInfo channelInfo) {
- if (infoListAdapter == null || channelInfo.getRelatedItems().isEmpty()) {
- onDone();
- return;
- }
-
- final InfoItem item = channelInfo.getRelatedItems().get(0);
- // Keep requesting new items if the current one already exists
- boolean itemExists = doesItemExist(infoListAdapter.getItemsList(), item);
- if (!itemExists) {
- infoListAdapter.addInfoItem(item);
- //updateSubscription(channelInfo);
- } else {
- requestFeed(1);
- }
- onDone();
- }
-
- @Override
- public void onError(Throwable exception) {
- showSnackBarError(exception,
- UserAction.SUBSCRIPTION,
- NewPipe.getNameOfService(serviceId),
- url, 0);
- requestFeed(1);
- onDone();
- }
-
- // Called only when response is empty
- @Override
- public void onComplete() {
- onDone();
- }
-
- private void onDone() {
- if (observer.isDisposed()) {
- return;
- }
-
- itemsLoaded.add(serviceId + url);
- compositeDisposable.remove(observer);
-
- int loaded = requestLoadedAtomic.incrementAndGet();
- if (loaded >= Math.min(FEED_LOAD_COUNT, subscriptionPoolSize)) {
- requestLoadedAtomic.set(0);
- isLoading.set(false);
- }
-
- if (itemsLoaded.size() == subscriptionPoolSize) {
- if (DEBUG) Log.d(TAG, "getChannelInfoObserver > All Items Loaded");
- allItemsLoaded.set(true);
- showListFooter(false);
- isLoading.set(false);
- hideLoading();
- if (infoListAdapter.getItemsList().size() == 0) {
- showEmptyState();
- }
- }
- }
- };
- }
-
- @Override
- protected void loadMoreItems() {
- isLoading.set(true);
- delayHandler.removeCallbacksAndMessages(null);
- // Add a little of a delay when requesting more items because the cache is so fast,
- // that the view seems stuck to the user when he scroll to the bottom
- delayHandler.postDelayed(() -> requestFeed(FEED_LOAD_COUNT), 300);
- }
-
- @Override
- protected boolean hasMoreItems() {
- return !allItemsLoaded.get();
- }
-
- private final Handler delayHandler = new Handler();
-
- private void requestFeed(final int count) {
- if (DEBUG) Log.d(TAG, "requestFeed() called with: count = [" + count + "], feedSubscriber = [" + feedSubscriber + "]");
- if (feedSubscriber == null) return;
-
- isLoading.set(true);
- delayHandler.removeCallbacksAndMessages(null);
- feedSubscriber.request(count);
- }
-
- /*//////////////////////////////////////////////////////////////////////////
- // Utils
- //////////////////////////////////////////////////////////////////////////*/
-
- private void resetFragment() {
- if (DEBUG) Log.d(TAG, "resetFragment() called");
- if (subscriptionObserver != null) subscriptionObserver.dispose();
- if (compositeDisposable != null) compositeDisposable.clear();
- if (infoListAdapter != null) infoListAdapter.clearStreamItemList();
-
- delayHandler.removeCallbacksAndMessages(null);
- requestLoadedAtomic.set(0);
- allItemsLoaded.set(false);
- showListFooter(false);
- itemsLoaded.clear();
- }
-
- private void disposeEverything() {
- if (subscriptionObserver != null) subscriptionObserver.dispose();
- if (compositeDisposable != null) compositeDisposable.clear();
- if (feedSubscriber != null) feedSubscriber.cancel();
- delayHandler.removeCallbacksAndMessages(null);
- }
-
- private boolean doesItemExist(final List items, final InfoItem item) {
- for (final InfoItem existingItem : items) {
- if (existingItem.getInfoType() == item.getInfoType() &&
- existingItem.getServiceId() == item.getServiceId() &&
- existingItem.getName().equals(item.getName()) &&
- existingItem.getUrl().equals(item.getUrl())) return true;
- }
- return false;
- }
-
- private int howManyItemsToLoad() {
- int heightPixels = getResources().getDisplayMetrics().heightPixels;
- int itemHeightPixels = activity.getResources().getDimensionPixelSize(R.dimen.video_item_search_height);
-
- int items = itemHeightPixels > 0
- ? heightPixels / itemHeightPixels + OFF_SCREEN_ITEMS_COUNT
- : MIN_ITEMS_INITIAL_LOAD;
- return Math.max(MIN_ITEMS_INITIAL_LOAD, items);
- }
-
- /*//////////////////////////////////////////////////////////////////////////
- // Fragment Error Handling
- //////////////////////////////////////////////////////////////////////////*/
-
- @Override
- public void showError(String message, boolean showRetryButton) {
- resetFragment();
- super.showError(message, showRetryButton);
- }
-
- @Override
- protected boolean onError(Throwable exception) {
- if (super.onError(exception)) return true;
-
- int errorId = exception instanceof ExtractionException
- ? R.string.parsing_error
- : R.string.general_error;
- onUnrecoverableError(exception,
- UserAction.SOMETHING_ELSE,
- "none",
- "Requesting feed",
- errorId);
- return true;
- }
-}
diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/FeedFragment.kt b/app/src/main/java/org/schabi/newpipe/local/feed/FeedFragment.kt
new file mode 100644
index 000000000..64020d14c
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/local/feed/FeedFragment.kt
@@ -0,0 +1,327 @@
+/*
+ * Copyright 2019 Mauricio Colli
+ * FeedFragment.kt is part of NewPipe
+ *
+ * License: GPL-3.0+
+ * This program 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.
+ *
+ * This program 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 this program. If not, see .
+ */
+
+package org.schabi.newpipe.local.feed
+
+import android.content.Intent
+import android.os.Bundle
+import android.os.Parcelable
+import android.view.*
+import androidx.appcompat.app.AlertDialog
+import androidx.lifecycle.Observer
+import androidx.lifecycle.ViewModelProviders
+import androidx.preference.PreferenceManager
+import icepick.State
+import kotlinx.android.synthetic.main.error_retry.*
+import kotlinx.android.synthetic.main.fragment_feed.*
+import org.schabi.newpipe.R
+import org.schabi.newpipe.database.feed.model.FeedGroupEntity
+import org.schabi.newpipe.fragments.list.BaseListFragment
+import org.schabi.newpipe.local.feed.service.FeedLoadService
+import org.schabi.newpipe.report.UserAction
+import org.schabi.newpipe.util.AnimationUtils.animateView
+import org.schabi.newpipe.util.Localization
+import java.util.*
+
+class FeedFragment : BaseListFragment() {
+ private lateinit var viewModel: FeedViewModel
+ @State @JvmField var listState: Parcelable? = null
+
+ private var groupId = FeedGroupEntity.GROUP_ALL_ID
+ private var groupName = ""
+ private var oldestSubscriptionUpdate: Calendar? = null
+
+ init {
+ setHasOptionsMenu(true)
+ useDefaultStateSaving(false)
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ groupId = arguments?.getLong(KEY_GROUP_ID, FeedGroupEntity.GROUP_ALL_ID) ?: FeedGroupEntity.GROUP_ALL_ID
+ groupName = arguments?.getString(KEY_GROUP_NAME) ?: ""
+ }
+
+ override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
+ return inflater.inflate(R.layout.fragment_feed, container, false)
+ }
+
+ override fun onViewCreated(rootView: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(rootView, savedInstanceState)
+
+ viewModel = ViewModelProviders.of(this, FeedViewModel.Factory(requireContext(), groupId)).get(FeedViewModel::class.java)
+ viewModel.stateLiveData.observe(viewLifecycleOwner, Observer { it?.let(::handleResult) })
+ }
+
+ override fun onPause() {
+ super.onPause()
+ listState = items_list?.layoutManager?.onSaveInstanceState()
+ }
+
+ override fun onResume() {
+ super.onResume()
+ updateRelativeTimeViews()
+ }
+
+ override fun setUserVisibleHint(isVisibleToUser: Boolean) {
+ super.setUserVisibleHint(isVisibleToUser)
+
+ if (!isVisibleToUser && view != null) {
+ updateRelativeTimeViews()
+ }
+ }
+
+ override fun initListeners() {
+ super.initListeners()
+ refresh_root_view.setOnClickListener {
+ triggerUpdate()
+ }
+ }
+
+ ///////////////////////////////////////////////////////////////////////////
+ // Menu
+ ///////////////////////////////////////////////////////////////////////////
+
+ override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
+ super.onCreateOptionsMenu(menu, inflater)
+ activity.supportActionBar?.setTitle(R.string.fragment_feed_title)
+ activity.supportActionBar?.subtitle = groupName
+
+ inflater.inflate(R.menu.menu_feed_fragment, menu)
+
+ if (useAsFrontPage) {
+ menu.findItem(R.id.menu_item_feed_help).setShowAsAction(MenuItem.SHOW_AS_ACTION_NEVER);
+ }
+ }
+
+ override fun onOptionsItemSelected(item: MenuItem): Boolean {
+ if (item.itemId == R.id.menu_item_feed_help) {
+ val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(requireContext())
+
+ val usingDedicatedMethod = sharedPreferences.getBoolean(getString(R.string.feed_use_dedicated_fetch_method_key), false)
+ val enableDisableButtonText = when {
+ usingDedicatedMethod -> R.string.feed_use_dedicated_fetch_method_disable_button
+ else -> R.string.feed_use_dedicated_fetch_method_enable_button
+ }
+
+ AlertDialog.Builder(requireContext())
+ .setMessage(R.string.feed_use_dedicated_fetch_method_help_text)
+ .setNeutralButton(enableDisableButtonText) { _, _ ->
+ sharedPreferences.edit()
+ .putBoolean(getString(R.string.feed_use_dedicated_fetch_method_key), !usingDedicatedMethod)
+ .apply()
+ }
+ .setPositiveButton(android.R.string.ok, null)
+ .create()
+ .show()
+ return true
+ }
+
+ return super.onOptionsItemSelected(item)
+ }
+
+ override fun onDestroyOptionsMenu() {
+ super.onDestroyOptionsMenu()
+ activity?.supportActionBar?.subtitle = null
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+ activity?.supportActionBar?.subtitle = null
+ }
+
+ ///////////////////////////////////////////////////////////////////////////
+ // Handling
+ ///////////////////////////////////////////////////////////////////////////
+
+ override fun showLoading() {
+ animateView(refresh_root_view, false, 0)
+ animateView(items_list, false, 0)
+
+ animateView(loading_progress_bar, true, 200)
+ animateView(loading_progress_text, true, 200)
+
+ empty_state_view?.let { animateView(it, false, 0) }
+ animateView(error_panel, false, 0)
+ }
+
+ override fun hideLoading() {
+ animateView(refresh_root_view, true, 200)
+ animateView(items_list, true, 300)
+
+ animateView(loading_progress_bar, false, 0)
+ animateView(loading_progress_text, false, 0)
+
+ empty_state_view?.let { animateView(it, false, 0) }
+ animateView(error_panel, false, 0)
+ }
+
+ override fun showEmptyState() {
+ animateView(refresh_root_view, true, 200)
+ animateView(items_list, false, 0)
+
+ animateView(loading_progress_bar, false, 0)
+ animateView(loading_progress_text, false, 0)
+
+ empty_state_view?.let { animateView(it, true, 800) }
+ animateView(error_panel, false, 0)
+ }
+
+ override fun showError(message: String, showRetryButton: Boolean) {
+ infoListAdapter.clearStreamItemList()
+ animateView(refresh_root_view, false, 120)
+ animateView(items_list, false, 120)
+
+ animateView(loading_progress_bar, false, 120)
+ animateView(loading_progress_text, false, 120)
+
+ error_message_view.text = message
+ animateView(error_button_retry, showRetryButton, if (showRetryButton) 600 else 0)
+ animateView(error_panel, true, 300)
+ }
+
+ override fun handleResult(result: FeedState) {
+ when (result) {
+ is FeedState.ProgressState -> handleProgressState(result)
+ is FeedState.LoadedState -> handleLoadedState(result)
+ is FeedState.ErrorState -> if (handleErrorState(result)) return
+ }
+
+ updateRefreshViewState()
+ }
+
+ private fun handleProgressState(progressState: FeedState.ProgressState) {
+ showLoading()
+
+ val isIndeterminate = progressState.currentProgress == -1 &&
+ progressState.maxProgress == -1
+
+ if (!isIndeterminate) {
+ loading_progress_text.text = "${progressState.currentProgress}/${progressState.maxProgress}"
+ } else if (progressState.progressMessage > 0) {
+ loading_progress_text?.setText(progressState.progressMessage)
+ } else {
+ loading_progress_text?.text = "∞/∞"
+ }
+
+ loading_progress_bar.isIndeterminate = isIndeterminate ||
+ (progressState.maxProgress > 0 && progressState.currentProgress == 0)
+ loading_progress_bar.progress = progressState.currentProgress
+
+ loading_progress_bar.max = progressState.maxProgress
+ }
+
+ private fun handleLoadedState(loadedState: FeedState.LoadedState) {
+ infoListAdapter.setInfoItemList(loadedState.items)
+ listState?.run {
+ items_list.layoutManager?.onRestoreInstanceState(listState)
+ listState = null
+ }
+
+ oldestSubscriptionUpdate = loadedState.oldestUpdate
+
+ if (loadedState.notLoadedCount > 0) {
+ refresh_subtitle_text.visibility = View.VISIBLE
+ refresh_subtitle_text.text = getString(R.string.feed_subscription_not_loaded_count, loadedState.notLoadedCount)
+ } else {
+ refresh_subtitle_text.visibility = View.GONE
+ }
+
+ if (loadedState.itemsErrors.isNotEmpty()) {
+ showSnackBarError(loadedState.itemsErrors, UserAction.REQUESTED_FEED,
+ "none", "Loading feed", R.string.general_error)
+ }
+
+ if (loadedState.items.isEmpty()) {
+ showEmptyState()
+ } else {
+ hideLoading()
+ }
+ }
+
+
+ private fun handleErrorState(errorState: FeedState.ErrorState): Boolean {
+ hideLoading()
+ errorState.error?.let {
+ onError(errorState.error)
+ return true
+ }
+ return false
+ }
+
+ private fun updateRelativeTimeViews() {
+ updateRefreshViewState()
+ infoListAdapter.notifyDataSetChanged()
+ }
+
+ private fun updateRefreshViewState() {
+ val oldestSubscriptionUpdateText = when {
+ oldestSubscriptionUpdate != null -> Localization.relativeTime(oldestSubscriptionUpdate!!)
+ else -> "—"
+ }
+
+ refresh_text?.text = getString(R.string.feed_oldest_subscription_update, oldestSubscriptionUpdateText)
+ }
+
+ ///////////////////////////////////////////////////////////////////////////
+ // Load Service Handling
+ ///////////////////////////////////////////////////////////////////////////
+
+ override fun doInitialLoadLogic() {}
+ override fun reloadContent() = triggerUpdate()
+ override fun loadMoreItems() {}
+ override fun hasMoreItems() = false
+
+ private fun triggerUpdate() {
+ getActivity()?.startService(Intent(requireContext(), FeedLoadService::class.java).apply {
+ putExtra(FeedLoadService.EXTRA_GROUP_ID, groupId)
+ })
+ listState = null
+ }
+
+ override fun onError(exception: Throwable): Boolean {
+ if (super.onError(exception)) return true
+
+ if (useAsFrontPage) {
+ showSnackBarError(exception, UserAction.REQUESTED_FEED, "none", "Loading Feed", 0)
+ return true
+ }
+
+ onUnrecoverableError(exception, UserAction.REQUESTED_FEED, "none", "Loading Feed", 0)
+ return true
+ }
+
+ companion object {
+ const val KEY_GROUP_ID = "ARG_GROUP_ID"
+ const val KEY_GROUP_NAME = "ARG_GROUP_NAME"
+
+ @JvmStatic
+ fun newInstance(groupId: Long = FeedGroupEntity.GROUP_ALL_ID, groupName: String? = null): FeedFragment {
+ val feedFragment = FeedFragment()
+
+ feedFragment.arguments = Bundle().apply {
+ putLong(KEY_GROUP_ID, groupId)
+ putString(KEY_GROUP_NAME, groupName)
+ }
+
+ return feedFragment
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/FeedState.kt b/app/src/main/java/org/schabi/newpipe/local/feed/FeedState.kt
new file mode 100644
index 000000000..c37d6a0b3
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/local/feed/FeedState.kt
@@ -0,0 +1,24 @@
+package org.schabi.newpipe.local.feed
+
+import androidx.annotation.StringRes
+import org.schabi.newpipe.extractor.stream.StreamInfoItem
+import java.util.*
+
+sealed class FeedState {
+ data class ProgressState(
+ val currentProgress: Int = -1,
+ val maxProgress: Int = -1,
+ @StringRes val progressMessage: Int = 0
+ ) : FeedState()
+
+ data class LoadedState(
+ val items: List,
+ val oldestUpdate: Calendar? = null,
+ val notLoadedCount: Long,
+ val itemsErrors: List = emptyList()
+ ) : FeedState()
+
+ data class ErrorState(
+ val error: Throwable? = null
+ ) : FeedState()
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/FeedViewModel.kt b/app/src/main/java/org/schabi/newpipe/local/feed/FeedViewModel.kt
new file mode 100644
index 000000000..adc262ecb
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/local/feed/FeedViewModel.kt
@@ -0,0 +1,71 @@
+package org.schabi.newpipe.local.feed
+
+import android.content.Context
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.ViewModelProvider
+import io.reactivex.Flowable
+import io.reactivex.android.schedulers.AndroidSchedulers
+import io.reactivex.functions.Function4
+import io.reactivex.schedulers.Schedulers
+import org.schabi.newpipe.database.feed.model.FeedGroupEntity
+import org.schabi.newpipe.extractor.stream.StreamInfoItem
+import org.schabi.newpipe.local.feed.service.FeedEventManager
+import org.schabi.newpipe.local.feed.service.FeedEventManager.Event.*
+import org.schabi.newpipe.util.DEFAULT_THROTTLE_TIMEOUT
+import java.util.*
+import java.util.concurrent.TimeUnit
+
+class FeedViewModel(applicationContext: Context, val groupId: Long = FeedGroupEntity.GROUP_ALL_ID) : ViewModel() {
+ class Factory(val context: Context, val groupId: Long = FeedGroupEntity.GROUP_ALL_ID) : ViewModelProvider.Factory {
+ @Suppress("UNCHECKED_CAST")
+ override fun create(modelClass: Class): T {
+ return FeedViewModel(context.applicationContext, groupId) as T
+ }
+ }
+
+ private var feedDatabaseManager: FeedDatabaseManager = FeedDatabaseManager(applicationContext)
+
+ private val mutableStateLiveData = MutableLiveData()
+ val stateLiveData: LiveData = mutableStateLiveData
+
+ private var combineDisposable = Flowable
+ .combineLatest(
+ FeedEventManager.events(),
+ feedDatabaseManager.asStreamItems(groupId),
+ feedDatabaseManager.notLoadedCount(groupId),
+ feedDatabaseManager.oldestSubscriptionUpdate(groupId),
+
+ Function4 { t1: FeedEventManager.Event, t2: List, t3: Long, t4: List ->
+ return@Function4 CombineResultHolder(t1, t2, t3, t4.firstOrNull())
+ }
+ )
+ .throttleLatest(DEFAULT_THROTTLE_TIMEOUT, TimeUnit.MILLISECONDS)
+ .subscribeOn(Schedulers.io())
+ .observeOn(AndroidSchedulers.mainThread())
+ .subscribe {
+ val (event, listFromDB, notLoadedCount, oldestUpdate) = it
+
+ val oldestUpdateCalendar =
+ oldestUpdate?.let { Calendar.getInstance().apply { time = it } }
+
+ mutableStateLiveData.postValue(when (event) {
+ is IdleEvent -> FeedState.LoadedState(listFromDB, oldestUpdateCalendar, notLoadedCount)
+ is ProgressEvent -> FeedState.ProgressState(event.currentProgress, event.maxProgress, event.progressMessage)
+ is SuccessResultEvent -> FeedState.LoadedState(listFromDB, oldestUpdateCalendar, notLoadedCount, event.itemsErrors)
+ is ErrorResultEvent -> FeedState.ErrorState(event.error)
+ })
+
+ if (event is ErrorResultEvent || event is SuccessResultEvent) {
+ FeedEventManager.reset()
+ }
+ }
+
+ override fun onCleared() {
+ super.onCleared()
+ combineDisposable.dispose()
+ }
+
+ private data class CombineResultHolder(val t1: FeedEventManager.Event, val t2: List, val t3: Long, val t4: Date?)
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedEventManager.kt b/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedEventManager.kt
new file mode 100644
index 000000000..e9012ff37
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedEventManager.kt
@@ -0,0 +1,38 @@
+package org.schabi.newpipe.local.feed.service
+
+import androidx.annotation.StringRes
+import io.reactivex.Flowable
+import io.reactivex.processors.BehaviorProcessor
+import org.schabi.newpipe.local.feed.service.FeedEventManager.Event.IdleEvent
+import java.util.concurrent.atomic.AtomicBoolean
+
+object FeedEventManager {
+ private var processor: BehaviorProcessor = BehaviorProcessor.create()
+ private var ignoreUpstream = AtomicBoolean()
+ private var eventsFlowable = processor.startWith(IdleEvent)
+
+ fun postEvent(event: Event) {
+ processor.onNext(event)
+ }
+
+ fun events(): Flowable {
+ return eventsFlowable.filter { !ignoreUpstream.get() }
+ }
+
+ fun reset() {
+ ignoreUpstream.set(true)
+ postEvent(IdleEvent)
+ ignoreUpstream.set(false)
+ }
+
+ sealed class Event {
+ object IdleEvent : Event()
+ data class ProgressEvent(val currentProgress: Int = -1, val maxProgress: Int = -1, @StringRes val progressMessage: Int = 0) : Event() {
+ constructor(@StringRes progressMessage: Int) : this(-1, -1, progressMessage)
+ }
+
+ data class SuccessResultEvent(val itemsErrors: List = emptyList()) : Event()
+ data class ErrorResultEvent(val error: Throwable) : Event()
+ }
+
+}
diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedLoadService.kt b/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedLoadService.kt
new file mode 100644
index 000000000..294a7fcd5
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedLoadService.kt
@@ -0,0 +1,464 @@
+/*
+ * Copyright 2019 Mauricio Colli
+ * FeedLoadService.kt is part of NewPipe
+ *
+ * License: GPL-3.0+
+ * This program 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.
+ *
+ * This program 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 this program. If not, see .
+ */
+
+package org.schabi.newpipe.local.feed.service
+
+import android.app.PendingIntent
+import android.app.Service
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.os.Build
+import android.os.IBinder
+import android.preference.PreferenceManager
+import android.util.Log
+import androidx.core.app.NotificationCompat
+import androidx.core.app.NotificationManagerCompat
+import io.reactivex.Flowable
+import io.reactivex.Notification
+import io.reactivex.Single
+import io.reactivex.android.schedulers.AndroidSchedulers
+import io.reactivex.disposables.CompositeDisposable
+import io.reactivex.functions.Consumer
+import io.reactivex.functions.Function
+import io.reactivex.processors.PublishProcessor
+import io.reactivex.schedulers.Schedulers
+import org.reactivestreams.Subscriber
+import org.reactivestreams.Subscription
+import org.schabi.newpipe.MainActivity.DEBUG
+import org.schabi.newpipe.R
+import org.schabi.newpipe.database.feed.model.FeedGroupEntity
+import org.schabi.newpipe.extractor.ListInfo
+import org.schabi.newpipe.extractor.exceptions.ReCaptchaException
+import org.schabi.newpipe.extractor.stream.StreamInfoItem
+import org.schabi.newpipe.local.feed.FeedDatabaseManager
+import org.schabi.newpipe.local.feed.service.FeedEventManager.Event.*
+import org.schabi.newpipe.local.feed.service.FeedEventManager.postEvent
+import org.schabi.newpipe.local.subscription.SubscriptionManager
+import org.schabi.newpipe.util.ExtractorHelper
+import java.io.IOException
+import java.util.*
+import java.util.concurrent.TimeUnit
+import java.util.concurrent.atomic.AtomicBoolean
+import java.util.concurrent.atomic.AtomicInteger
+import kotlin.collections.ArrayList
+
+class FeedLoadService : Service() {
+ companion object {
+ private val TAG = FeedLoadService::class.java.simpleName
+ private const val NOTIFICATION_ID = 7293450
+ private const val ACTION_CANCEL = "org.schabi.newpipe.local.feed.service.FeedLoadService.CANCEL"
+
+ /**
+ * How often the notification will be updated.
+ */
+ private const val NOTIFICATION_SAMPLING_PERIOD = 1500
+
+ /**
+ * How many extractions will be running in parallel.
+ */
+ private const val PARALLEL_EXTRACTIONS = 6
+
+ /**
+ * Number of items to buffer to mass-insert in the database.
+ */
+ private const val BUFFER_COUNT_BEFORE_INSERT = 20
+
+ const val EXTRA_GROUP_ID: String = "FeedLoadService.EXTRA_GROUP_ID"
+ }
+
+ private var loadingSubscription: Subscription? = null
+ private lateinit var subscriptionManager: SubscriptionManager
+
+ private lateinit var feedDatabaseManager: FeedDatabaseManager
+ private lateinit var feedResultsHolder: ResultsHolder
+
+ private var disposables = CompositeDisposable()
+ private var notificationUpdater = PublishProcessor.create()
+
+ ///////////////////////////////////////////////////////////////////////////
+ // Lifecycle
+ ///////////////////////////////////////////////////////////////////////////
+
+ override fun onCreate() {
+ super.onCreate()
+ subscriptionManager = SubscriptionManager(this)
+ feedDatabaseManager = FeedDatabaseManager(this)
+ }
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ if (DEBUG) {
+ Log.d(TAG, "onStartCommand() called with: intent = [" + intent + "]," +
+ " flags = [" + flags + "], startId = [" + startId + "]")
+ }
+
+ if (intent == null || loadingSubscription != null) {
+ return START_NOT_STICKY
+ }
+
+ setupNotification()
+ setupBroadcastReceiver()
+ val defaultSharedPreferences = PreferenceManager.getDefaultSharedPreferences(this)
+
+ val groupId = intent.getLongExtra(EXTRA_GROUP_ID, FeedGroupEntity.GROUP_ALL_ID)
+ val useFeedExtractor = defaultSharedPreferences
+ .getBoolean(getString(R.string.feed_use_dedicated_fetch_method_key), false)
+
+ val thresholdOutdatedSecondsString = defaultSharedPreferences
+ .getString(getString(R.string.feed_update_threshold_key), getString(R.string.feed_update_threshold_default_value))
+ val thresholdOutdatedSeconds = thresholdOutdatedSecondsString!!.toInt()
+
+ startLoading(groupId, useFeedExtractor, thresholdOutdatedSeconds)
+
+ return START_NOT_STICKY
+ }
+
+ private fun disposeAll() {
+ unregisterReceiver(broadcastReceiver)
+
+ loadingSubscription?.cancel()
+ loadingSubscription = null
+
+ disposables.dispose()
+ }
+
+ private fun stopService() {
+ disposeAll()
+ stopForeground(true)
+ notificationManager.cancel(NOTIFICATION_ID)
+ stopSelf()
+ }
+
+ override fun onBind(intent: Intent): IBinder? {
+ return null
+ }
+
+ ///////////////////////////////////////////////////////////////////////////
+ // Loading & Handling
+ ///////////////////////////////////////////////////////////////////////////
+
+ private class RequestException(val subscriptionId: Long, message: String, cause: Throwable) : Exception(message, cause) {
+ companion object {
+ fun wrapList(subscriptionId: Long, info: ListInfo): List {
+ val toReturn = ArrayList(info.errors.size)
+ for (error in info.errors) {
+ toReturn.add(RequestException(subscriptionId, info.serviceId.toString() + ":" + info.url, error))
+ }
+ return toReturn
+ }
+ }
+ }
+
+ private fun startLoading(groupId: Long = FeedGroupEntity.GROUP_ALL_ID, useFeedExtractor: Boolean, thresholdOutdatedSeconds: Int) {
+ feedResultsHolder = ResultsHolder()
+
+ val outdatedThreshold = Calendar.getInstance().apply {
+ add(Calendar.SECOND, -thresholdOutdatedSeconds)
+ }.time
+
+ val subscriptions = when (groupId) {
+ FeedGroupEntity.GROUP_ALL_ID -> feedDatabaseManager.outdatedSubscriptions(outdatedThreshold)
+ else -> feedDatabaseManager.outdatedSubscriptionsForGroup(groupId, outdatedThreshold)
+ }
+
+ subscriptions
+ .limit(1)
+
+ .doOnNext {
+ currentProgress.set(0)
+ maxProgress.set(it.size)
+ }
+ .filter { it.isNotEmpty() }
+
+ .observeOn(AndroidSchedulers.mainThread())
+ .doOnNext {
+ startForeground(NOTIFICATION_ID, notificationBuilder.build())
+ updateNotificationProgress(null)
+ broadcastProgress()
+ }
+
+ .observeOn(Schedulers.io())
+ .flatMap { Flowable.fromIterable(it) }
+ .takeWhile { !cancelSignal.get() }
+
+ .parallel(PARALLEL_EXTRACTIONS, PARALLEL_EXTRACTIONS * 2)
+ .runOn(Schedulers.io(), PARALLEL_EXTRACTIONS * 2)
+ .filter { !cancelSignal.get() }
+
+ .map { subscriptionEntity ->
+ try {
+ val listInfo = if (useFeedExtractor) {
+ ExtractorHelper
+ .getFeedInfoFallbackToChannelInfo(subscriptionEntity.serviceId, subscriptionEntity.url)
+ .blockingGet()
+ } else {
+ ExtractorHelper
+ .getChannelInfo(subscriptionEntity.serviceId, subscriptionEntity.url, true)
+ .blockingGet()
+ } as ListInfo
+
+ return@map Notification.createOnNext(Pair(subscriptionEntity.uid, listInfo))
+ } catch (e: Throwable) {
+ val request = "${subscriptionEntity.serviceId}:${subscriptionEntity.url}"
+ val wrapper = RequestException(subscriptionEntity.uid, request, e)
+ return@map Notification.createOnError>>(wrapper)
+ }
+ }
+ .sequential()
+
+ .observeOn(AndroidSchedulers.mainThread())
+ .doOnNext(errorHandlingConsumer)
+
+ .observeOn(AndroidSchedulers.mainThread())
+ .doOnNext(notificationsConsumer)
+
+ .observeOn(Schedulers.io())
+ .buffer(BUFFER_COUNT_BEFORE_INSERT)
+ .doOnNext(databaseConsumer)
+
+ .subscribeOn(Schedulers.io())
+ .observeOn(AndroidSchedulers.mainThread())
+ .subscribe(resultSubscriber)
+ }
+
+ private fun broadcastProgress() {
+ postEvent(ProgressEvent(currentProgress.get(), maxProgress.get()))
+ }
+
+ private val resultSubscriber
+ get() = object : Subscriber>>>> {
+
+ override fun onSubscribe(s: Subscription) {
+ loadingSubscription = s
+ s.request(java.lang.Long.MAX_VALUE)
+ }
+
+ override fun onNext(notification: List>>>) {
+ if (DEBUG) Log.v(TAG, "onNext() → $notification")
+ }
+
+ override fun onError(error: Throwable) {
+ handleError(error)
+ }
+
+ override fun onComplete() {
+ if (maxProgress.get() == 0) {
+ postEvent(IdleEvent)
+ stopService()
+
+ return
+ }
+
+ currentProgress.set(-1)
+ maxProgress.set(-1)
+
+ notificationUpdater.onNext(getString(R.string.feed_processing_message))
+ postEvent(ProgressEvent(R.string.feed_processing_message))
+
+ disposables.add(Single
+ .fromCallable {
+ feedResultsHolder.ready()
+
+ postEvent(ProgressEvent(R.string.feed_processing_message))
+ feedDatabaseManager.removeOrphansOrOlderStreams()
+
+ postEvent(SuccessResultEvent(feedResultsHolder.itemsErrors))
+ true
+ }
+ .subscribeOn(Schedulers.io())
+ .observeOn(AndroidSchedulers.mainThread())
+ .subscribe { _, throwable ->
+ if (throwable != null) {
+ Log.e(TAG, "Error while storing result", throwable)
+ handleError(throwable)
+ return@subscribe
+ }
+ stopService()
+ })
+ }
+ }
+
+ private val databaseConsumer: Consumer>>>>
+ get() = Consumer {
+ feedDatabaseManager.database().runInTransaction {
+ for (notification in it) {
+
+ if (notification.isOnNext) {
+ val subscriptionId = notification.value!!.first
+ val info = notification.value!!.second
+
+ feedDatabaseManager.upsertAll(subscriptionId, info.relatedItems)
+ subscriptionManager.updateFromInfo(subscriptionId, info)
+
+ if (info.errors.isNotEmpty()) {
+ feedResultsHolder.addErrors(RequestException.wrapList(subscriptionId, info))
+ feedDatabaseManager.markAsOutdated(subscriptionId)
+ }
+
+ } else if (notification.isOnError) {
+ val error = notification.error!!
+ feedResultsHolder.addError(error)
+
+ if (error is RequestException) {
+ feedDatabaseManager.markAsOutdated(error.subscriptionId)
+ }
+ }
+ }
+ }
+ }
+
+
+ private val errorHandlingConsumer: Consumer>>>
+ get() = Consumer {
+ if (it.isOnError) {
+ var error = it.error!!
+ if (error is RequestException) error = error.cause!!
+ val cause = error.cause
+
+ when {
+ error is IOException -> throw error
+ cause is IOException -> throw cause
+
+ error is ReCaptchaException -> throw error
+ cause is ReCaptchaException -> throw cause
+ }
+ }
+ }
+
+ private val notificationsConsumer: Consumer>>>
+ get() = Consumer { onItemCompleted(it.value?.second?.name) }
+
+ private fun onItemCompleted(updateDescription: String?) {
+ currentProgress.incrementAndGet()
+ notificationUpdater.onNext(updateDescription ?: "")
+
+ broadcastProgress()
+ }
+
+ ///////////////////////////////////////////////////////////////////////////
+ // Notification
+ ///////////////////////////////////////////////////////////////////////////
+
+ private lateinit var notificationManager: NotificationManagerCompat
+ private lateinit var notificationBuilder: NotificationCompat.Builder
+
+ private var currentProgress = AtomicInteger(-1)
+ private var maxProgress = AtomicInteger(-1)
+
+ private fun createNotification(): NotificationCompat.Builder {
+ val cancelActionIntent = PendingIntent.getBroadcast(this,
+ NOTIFICATION_ID, Intent(ACTION_CANCEL), 0)
+
+ return NotificationCompat.Builder(this, getString(R.string.notification_channel_id))
+ .setOngoing(true)
+ .setProgress(-1, -1, true)
+ .setSmallIcon(R.drawable.ic_newpipe_triangle_white)
+ .setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
+ .addAction(0, getString(R.string.cancel), cancelActionIntent)
+ .setContentTitle(getString(R.string.feed_notification_loading))
+ }
+
+ private fun setupNotification() {
+ notificationManager = NotificationManagerCompat.from(this)
+ notificationBuilder = createNotification()
+
+ val throttleAfterFirstEmission = Function { flow: Flowable ->
+ flow.limit(1).concatWith(flow.skip(1).throttleLatest(NOTIFICATION_SAMPLING_PERIOD.toLong(), TimeUnit.MILLISECONDS))
+ }
+
+ disposables.add(notificationUpdater
+ .publish(throttleAfterFirstEmission)
+ .observeOn(AndroidSchedulers.mainThread())
+ .subscribe(this::updateNotificationProgress))
+ }
+
+ private fun updateNotificationProgress(updateDescription: String?) {
+ notificationBuilder.setProgress(maxProgress.get(), currentProgress.get(), maxProgress.get() == -1)
+
+ if (maxProgress.get() == -1) {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) notificationBuilder.setContentInfo(null)
+ if (!updateDescription.isNullOrEmpty()) notificationBuilder.setContentText(updateDescription)
+ notificationBuilder.setContentText(updateDescription)
+ } else {
+ val progressText = this.currentProgress.toString() + "/" + maxProgress
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
+ if (!updateDescription.isNullOrEmpty()) notificationBuilder.setContentText("$updateDescription ($progressText)")
+ } else {
+ notificationBuilder.setContentInfo(progressText)
+ if (!updateDescription.isNullOrEmpty()) notificationBuilder.setContentText(updateDescription)
+ }
+ }
+
+ notificationManager.notify(NOTIFICATION_ID, notificationBuilder.build())
+ }
+
+ ///////////////////////////////////////////////////////////////////////////
+ // Notification Actions
+ ///////////////////////////////////////////////////////////////////////////
+
+ private lateinit var broadcastReceiver: BroadcastReceiver
+ private val cancelSignal = AtomicBoolean()
+
+ private fun setupBroadcastReceiver() {
+ broadcastReceiver = object : BroadcastReceiver() {
+ override fun onReceive(context: Context?, intent: Intent?) {
+ if (intent?.action == ACTION_CANCEL) {
+ cancelSignal.set(true)
+ }
+ }
+ }
+ registerReceiver(broadcastReceiver, IntentFilter(ACTION_CANCEL))
+ }
+
+ ///////////////////////////////////////////////////////////////////////////
+ // Error handling
+ ///////////////////////////////////////////////////////////////////////////
+
+ private fun handleError(error: Throwable) {
+ postEvent(ErrorResultEvent(error))
+ stopService()
+ }
+
+ ///////////////////////////////////////////////////////////////////////////
+ // Results Holder
+ ///////////////////////////////////////////////////////////////////////////
+
+ class ResultsHolder {
+ /**
+ * List of errors that may have happen during loading.
+ */
+ internal lateinit var itemsErrors: List
+
+ private val itemsErrorsHolder: MutableList = ArrayList()
+
+ fun addError(error: Throwable) {
+ itemsErrorsHolder.add(error)
+ }
+
+ fun addErrors(errors: List) {
+ itemsErrorsHolder.addAll(errors)
+ }
+
+ fun ready() {
+ itemsErrors = itemsErrorsHolder.toList()
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/schabi/newpipe/local/history/HistoryRecordManager.java b/app/src/main/java/org/schabi/newpipe/local/history/HistoryRecordManager.java
index d84fe0195..d208f92b3 100644
--- a/app/src/main/java/org/schabi/newpipe/local/history/HistoryRecordManager.java
+++ b/app/src/main/java/org/schabi/newpipe/local/history/HistoryRecordManager.java
@@ -269,11 +269,11 @@ public class HistoryRecordManager {
for (LocalItem item : items) {
long streamId;
if (item instanceof StreamStatisticsEntry) {
- streamId = ((StreamStatisticsEntry) item).streamId;
+ streamId = ((StreamStatisticsEntry) item).getStreamId();
} else if (item instanceof PlaylistStreamEntity) {
streamId = ((PlaylistStreamEntity) item).getStreamUid();
} else if (item instanceof PlaylistStreamEntry) {
- streamId = ((PlaylistStreamEntry) item).streamId;
+ streamId = ((PlaylistStreamEntry) item).getStreamId();
} else {
result.add(null);
continue;
diff --git a/app/src/main/java/org/schabi/newpipe/local/history/StatisticsPlaylistFragment.java b/app/src/main/java/org/schabi/newpipe/local/history/StatisticsPlaylistFragment.java
index 31ae70954..a54c2a9a4 100644
--- a/app/src/main/java/org/schabi/newpipe/local/history/StatisticsPlaylistFragment.java
+++ b/app/src/main/java/org/schabi/newpipe/local/history/StatisticsPlaylistFragment.java
@@ -76,11 +76,11 @@ public class StatisticsPlaylistFragment
switch (sortMode) {
case LAST_PLAYED:
Collections.sort(results, (left, right) ->
- right.latestAccessDate.compareTo(left.latestAccessDate));
+ right.getLatestAccessDate().compareTo(left.getLatestAccessDate()));
return results;
case MOST_PLAYED:
Collections.sort(results, (left, right) ->
- Long.compare(right.watchCount, left.watchCount));
+ Long.compare(right.getWatchCount(), left.getWatchCount()));
return results;
default: return null;
}
@@ -153,9 +153,9 @@ public class StatisticsPlaylistFragment
if (selectedItem instanceof StreamStatisticsEntry) {
final StreamStatisticsEntry item = (StreamStatisticsEntry) selectedItem;
NavigationHelper.openVideoDetailFragment(getFM(),
- item.serviceId,
- item.url,
- item.title);
+ item.getStreamEntity().getServiceId(),
+ item.getStreamEntity().getUrl(),
+ item.getStreamEntity().getTitle());
}
}
@@ -402,7 +402,7 @@ public class StatisticsPlaylistFragment
.get(index);
if(infoItem instanceof StreamStatisticsEntry) {
final StreamStatisticsEntry entry = (StreamStatisticsEntry) infoItem;
- final Disposable onDelete = recordManager.deleteStreamHistory(entry.streamId)
+ final Disposable onDelete = recordManager.deleteStreamHistory(entry.getStreamId())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
howManyDeleted -> {
diff --git a/app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistStreamItemHolder.java b/app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistStreamItemHolder.java
index 30cc6de32..7eef3e67e 100644
--- a/app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistStreamItemHolder.java
+++ b/app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistStreamItemHolder.java
@@ -52,12 +52,12 @@ public class LocalPlaylistStreamItemHolder extends LocalItemHolder {
if (!(localItem instanceof PlaylistStreamEntry)) return;
final PlaylistStreamEntry item = (PlaylistStreamEntry) localItem;
- itemVideoTitleView.setText(item.title);
- itemAdditionalDetailsView.setText(Localization.concatenateStrings(item.uploader,
- NewPipe.getNameOfService(item.serviceId)));
+ itemVideoTitleView.setText(item.getStreamEntity().getTitle());
+ itemAdditionalDetailsView.setText(Localization.concatenateStrings(item.getStreamEntity().getUploader(),
+ NewPipe.getNameOfService(item.getStreamEntity().getServiceId())));
- if (item.duration > 0) {
- itemDurationView.setText(Localization.getDurationString(item.duration));
+ if (item.getStreamEntity().getDuration() > 0) {
+ itemDurationView.setText(Localization.getDurationString(item.getStreamEntity().getDuration()));
itemDurationView.setBackgroundColor(ContextCompat.getColor(itemBuilder.getContext(),
R.color.duration_background_color));
itemDurationView.setVisibility(View.VISIBLE);
@@ -65,7 +65,7 @@ public class LocalPlaylistStreamItemHolder extends LocalItemHolder {
StreamStateEntity state = historyRecordManager.loadLocalStreamStateBatch(new ArrayList() {{ add(localItem); }}).blockingGet().get(0);
if (state != null) {
itemProgressView.setVisibility(View.VISIBLE);
- itemProgressView.setMax((int) item.duration);
+ itemProgressView.setMax((int) item.getStreamEntity().getDuration());
itemProgressView.setProgress((int) TimeUnit.MILLISECONDS.toSeconds(state.getProgressTime()));
} else {
itemProgressView.setVisibility(View.GONE);
@@ -75,7 +75,7 @@ public class LocalPlaylistStreamItemHolder extends LocalItemHolder {
}
// Default thumbnail is shown on error, while loading and if the url is empty
- itemBuilder.displayImage(item.thumbnailUrl, itemThumbnailView,
+ itemBuilder.displayImage(item.getStreamEntity().getThumbnailUrl(), itemThumbnailView,
ImageDisplayConstants.DISPLAY_THUMBNAIL_OPTIONS);
itemView.setOnClickListener(view -> {
@@ -102,8 +102,8 @@ public class LocalPlaylistStreamItemHolder extends LocalItemHolder {
final PlaylistStreamEntry item = (PlaylistStreamEntry) localItem;
StreamStateEntity state = historyRecordManager.loadLocalStreamStateBatch(new ArrayList() {{ add(localItem); }}).blockingGet().get(0);
- if (state != null && item.duration > 0) {
- itemProgressView.setMax((int) item.duration);
+ if (state != null && item.getStreamEntity().getDuration() > 0) {
+ itemProgressView.setMax((int) item.getStreamEntity().getDuration());
if (itemProgressView.getVisibility() == View.VISIBLE) {
itemProgressView.setProgressAnimated((int) TimeUnit.MILLISECONDS.toSeconds(state.getProgressTime()));
} else {
diff --git a/app/src/main/java/org/schabi/newpipe/local/holder/LocalStatisticStreamItemHolder.java b/app/src/main/java/org/schabi/newpipe/local/holder/LocalStatisticStreamItemHolder.java
index 75fbf13ea..77f947031 100644
--- a/app/src/main/java/org/schabi/newpipe/local/holder/LocalStatisticStreamItemHolder.java
+++ b/app/src/main/java/org/schabi/newpipe/local/holder/LocalStatisticStreamItemHolder.java
@@ -71,9 +71,9 @@ public class LocalStatisticStreamItemHolder extends LocalItemHolder {
private String getStreamInfoDetailLine(final StreamStatisticsEntry entry,
final DateFormat dateFormat) {
final String watchCount = Localization.shortViewCount(itemBuilder.getContext(),
- entry.watchCount);
- final String uploadDate = dateFormat.format(entry.latestAccessDate);
- final String serviceName = NewPipe.getNameOfService(entry.serviceId);
+ entry.getWatchCount());
+ final String uploadDate = dateFormat.format(entry.getLatestAccessDate());
+ final String serviceName = NewPipe.getNameOfService(entry.getStreamEntity().getServiceId());
return Localization.concatenateStrings(watchCount, uploadDate, serviceName);
}
@@ -82,11 +82,11 @@ public class LocalStatisticStreamItemHolder extends LocalItemHolder {
if (!(localItem instanceof StreamStatisticsEntry)) return;
final StreamStatisticsEntry item = (StreamStatisticsEntry) localItem;
- itemVideoTitleView.setText(item.title);
- itemUploaderView.setText(item.uploader);
+ itemVideoTitleView.setText(item.getStreamEntity().getTitle());
+ itemUploaderView.setText(item.getStreamEntity().getUploader());
- if (item.duration > 0) {
- itemDurationView.setText(Localization.getDurationString(item.duration));
+ if (item.getStreamEntity().getDuration() > 0) {
+ itemDurationView.setText(Localization.getDurationString(item.getStreamEntity().getDuration()));
itemDurationView.setBackgroundColor(ContextCompat.getColor(itemBuilder.getContext(),
R.color.duration_background_color));
itemDurationView.setVisibility(View.VISIBLE);
@@ -94,7 +94,7 @@ public class LocalStatisticStreamItemHolder extends LocalItemHolder {
StreamStateEntity state = historyRecordManager.loadLocalStreamStateBatch(new ArrayList() {{ add(localItem); }}).blockingGet().get(0);
if (state != null) {
itemProgressView.setVisibility(View.VISIBLE);
- itemProgressView.setMax((int) item.duration);
+ itemProgressView.setMax((int) item.getStreamEntity().getDuration());
itemProgressView.setProgress((int) TimeUnit.MILLISECONDS.toSeconds(state.getProgressTime()));
} else {
itemProgressView.setVisibility(View.GONE);
@@ -109,7 +109,7 @@ public class LocalStatisticStreamItemHolder extends LocalItemHolder {
}
// Default thumbnail is shown on error, while loading and if the url is empty
- itemBuilder.displayImage(item.thumbnailUrl, itemThumbnailView,
+ itemBuilder.displayImage(item.getStreamEntity().getThumbnailUrl(), itemThumbnailView,
ImageDisplayConstants.DISPLAY_THUMBNAIL_OPTIONS);
itemView.setOnClickListener(view -> {
@@ -133,8 +133,8 @@ public class LocalStatisticStreamItemHolder extends LocalItemHolder {
final StreamStatisticsEntry item = (StreamStatisticsEntry) localItem;
StreamStateEntity state = historyRecordManager.loadLocalStreamStateBatch(new ArrayList() {{ add(localItem); }}).blockingGet().get(0);
- if (state != null && item.duration > 0) {
- itemProgressView.setMax((int) item.duration);
+ if (state != null && item.getStreamEntity().getDuration() > 0) {
+ itemProgressView.setMax((int) item.getStreamEntity().getDuration());
if (itemProgressView.getVisibility() == View.VISIBLE) {
itemProgressView.setProgressAnimated((int) TimeUnit.MILLISECONDS.toSeconds(state.getProgressTime()));
} else {
diff --git a/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistFragment.java b/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistFragment.java
index 17599a1ca..dd9958486 100644
--- a/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistFragment.java
+++ b/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistFragment.java
@@ -168,7 +168,7 @@ public class LocalPlaylistFragment extends BaseLocalListFragment streamIds = new ArrayList<>(items.size());
for (final LocalItem item : items) {
if (item instanceof PlaylistStreamEntry) {
- streamIds.add(((PlaylistStreamEntry) item).streamId);
+ streamIds.add(((PlaylistStreamEntry) item).getStreamId());
}
}
@@ -579,7 +579,7 @@ public class LocalPlaylistFragment extends BaseLocalListFragment NavigationHelper.playOnBackgroundPlayer(context, getPlayQueueStartingAt(item), true));
StreamDialogEntry.set_as_playlist_thumbnail.setCustomAction(
- (fragment, infoItemDuplicate) -> changeThumbnailUrl(item.thumbnailUrl));
+ (fragment, infoItemDuplicate) -> changeThumbnailUrl(item.getStreamEntity().getThumbnailUrl()));
StreamDialogEntry.delete.setCustomAction(
(fragment, infoItemDuplicate) -> deleteItem(item));
diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/FeedGroupIcon.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/FeedGroupIcon.kt
new file mode 100644
index 000000000..9ff08c32c
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/local/subscription/FeedGroupIcon.kt
@@ -0,0 +1,63 @@
+package org.schabi.newpipe.local.subscription
+
+import android.content.Context
+import androidx.annotation.AttrRes
+import androidx.annotation.DrawableRes
+import org.schabi.newpipe.R
+import org.schabi.newpipe.util.ThemeHelper
+
+enum class FeedGroupIcon(
+ /**
+ * The id that will be used to store and retrieve icons from some persistent storage (e.g. DB).
+ */
+ val id: Int,
+
+ /**
+ * The attribute that points to a drawable resource. "R.attr" is used here to support multiple themes.
+ */
+ @AttrRes val drawableResourceAttr: Int
+) {
+ ALL(0, R.attr.ic_asterisk),
+ MUSIC(1, R.attr.ic_music_note),
+ EDUCATION(2, R.attr.ic_school),
+ FITNESS(3, R.attr.ic_fitness),
+ SPACE(4, R.attr.ic_telescope),
+ COMPUTER(5, R.attr.ic_computer),
+ GAMING(6, R.attr.ic_videogame),
+ SPORTS(7, R.attr.ic_sports),
+ NEWS(8, R.attr.ic_megaphone),
+ FAVORITES(9, R.attr.ic_heart),
+ CAR(10, R.attr.ic_car),
+ MOTORCYCLE(11, R.attr.ic_motorcycle),
+ TREND(12, R.attr.ic_trending_up),
+ MOVIE(13, R.attr.ic_movie),
+ BACKUP(14, R.attr.ic_backup),
+ ART(15, R.attr.palette),
+ PERSON(16, R.attr.ic_person),
+ PEOPLE(17, R.attr.ic_people),
+ MONEY(18, R.attr.ic_money),
+ KIDS(19, R.attr.ic_kids),
+ FOOD(20, R.attr.ic_fastfood),
+ SMILE(21, R.attr.ic_smile),
+ EXPLORE(22, R.attr.ic_explore),
+ RESTAURANT(23, R.attr.ic_restaurant),
+ MIC(24, R.attr.ic_mic),
+ HEADSET(25, R.attr.audio),
+ RADIO(26, R.attr.ic_radio),
+ SHOPPING_CART(27, R.attr.ic_shopping_cart),
+ WATCH_LATER(28, R.attr.ic_watch_later),
+ WORK(29, R.attr.ic_work),
+ HOT(30, R.attr.ic_hot),
+ CHANNEL(31, R.attr.ic_channel),
+ BOOKMARK(32, R.attr.ic_bookmark),
+ PETS(33, R.attr.ic_pets),
+ WORLD(34, R.attr.ic_world),
+ STAR(35, R.attr.ic_stars),
+ SUN(36, R.attr.ic_sunny),
+ RSS(37, R.attr.rss);
+
+ @DrawableRes
+ fun getDrawableRes(context: Context): Int {
+ return ThemeHelper.resolveResourceIdFromAttr(context, drawableResourceAttr)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionFragment.java b/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionFragment.java
deleted file mode 100644
index bff6c1b3a..000000000
--- a/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionFragment.java
+++ /dev/null
@@ -1,595 +0,0 @@
-package org.schabi.newpipe.local.subscription;
-
-import android.annotation.SuppressLint;
-import android.app.Activity;
-import android.app.AlertDialog;
-import android.content.BroadcastReceiver;
-import android.content.Context;
-import android.content.DialogInterface;
-import android.content.Intent;
-import android.content.IntentFilter;
-import android.content.SharedPreferences;
-import android.content.res.Configuration;
-import android.content.res.Resources;
-import android.graphics.Color;
-import android.graphics.PorterDuff;
-import android.os.Bundle;
-import android.os.Environment;
-import android.os.Parcelable;
-import android.preference.PreferenceManager;
-import androidx.annotation.DrawableRes;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-import androidx.fragment.app.FragmentManager;
-import androidx.localbroadcastmanager.content.LocalBroadcastManager;
-import androidx.appcompat.app.ActionBar;
-import androidx.recyclerview.widget.GridLayoutManager;
-import androidx.recyclerview.widget.LinearLayoutManager;
-import androidx.recyclerview.widget.RecyclerView;
-import android.view.LayoutInflater;
-import android.view.Menu;
-import android.view.MenuInflater;
-import android.view.View;
-import android.view.ViewGroup;
-import android.widget.ImageView;
-import android.widget.TextView;
-import android.widget.Toast;
-
-import com.nononsenseapps.filepicker.Utils;
-
-import org.schabi.newpipe.R;
-import org.schabi.newpipe.database.subscription.SubscriptionEntity;
-import org.schabi.newpipe.extractor.InfoItem;
-import org.schabi.newpipe.extractor.NewPipe;
-import org.schabi.newpipe.extractor.StreamingService;
-import org.schabi.newpipe.extractor.channel.ChannelInfoItem;
-import org.schabi.newpipe.extractor.exceptions.ExtractionException;
-import org.schabi.newpipe.extractor.subscription.SubscriptionExtractor;
-import org.schabi.newpipe.fragments.BaseStateFragment;
-import org.schabi.newpipe.info_list.InfoListAdapter;
-import org.schabi.newpipe.local.subscription.services.SubscriptionsExportService;
-import org.schabi.newpipe.local.subscription.services.SubscriptionsImportService;
-import org.schabi.newpipe.report.UserAction;
-import org.schabi.newpipe.util.FilePickerActivityHelper;
-import org.schabi.newpipe.util.NavigationHelper;
-import org.schabi.newpipe.util.OnClickGesture;
-import org.schabi.newpipe.util.ServiceHelper;
-import org.schabi.newpipe.util.ShareUtils;
-import org.schabi.newpipe.util.ThemeHelper;
-import org.schabi.newpipe.views.CollapsibleView;
-
-import java.io.File;
-import java.text.SimpleDateFormat;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.Date;
-import java.util.List;
-import java.util.Locale;
-
-import icepick.State;
-import io.reactivex.Observer;
-import io.reactivex.android.schedulers.AndroidSchedulers;
-import io.reactivex.disposables.CompositeDisposable;
-import io.reactivex.disposables.Disposable;
-import io.reactivex.schedulers.Schedulers;
-
-import static org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.KEY_MODE;
-import static org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.KEY_VALUE;
-import static org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.PREVIOUS_EXPORT_MODE;
-import static org.schabi.newpipe.util.AnimationUtils.animateRotation;
-import static org.schabi.newpipe.util.AnimationUtils.animateView;
-
-public class SubscriptionFragment extends BaseStateFragment> implements SharedPreferences.OnSharedPreferenceChangeListener {
- private static final int REQUEST_EXPORT_CODE = 666;
- private static final int REQUEST_IMPORT_CODE = 667;
-
- private RecyclerView itemsList;
- @State
- protected Parcelable itemsListState;
- private InfoListAdapter infoListAdapter;
- private int updateFlags = 0;
-
- private static final int LIST_MODE_UPDATE_FLAG = 0x32;
-
- private View whatsNewItemListHeader;
- private View importExportListHeader;
-
- @State
- protected Parcelable importExportOptionsState;
- private CollapsibleView importExportOptions;
-
- private CompositeDisposable disposables = new CompositeDisposable();
- private SubscriptionService subscriptionService;
-
- ///////////////////////////////////////////////////////////////////////////
- // Fragment LifeCycle
- ///////////////////////////////////////////////////////////////////////////
-
- @Override
- public void onCreate(Bundle savedInstanceState) {
- super.onCreate(savedInstanceState);
- setHasOptionsMenu(true);
- PreferenceManager.getDefaultSharedPreferences(activity)
- .registerOnSharedPreferenceChangeListener(this);
- }
-
- @Override
- public void setUserVisibleHint(boolean isVisibleToUser) {
- super.setUserVisibleHint(isVisibleToUser);
- if (activity != null && isVisibleToUser) {
- setTitle(activity.getString(R.string.tab_subscriptions));
- }
- }
-
- @Override
- public void onAttach(Context context) {
- super.onAttach(context);
- infoListAdapter = new InfoListAdapter(activity);
- subscriptionService = SubscriptionService.getInstance(activity);
- }
-
- @Override
- public void onDetach() {
- super.onDetach();
- }
-
- @Nullable
- @Override
- public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, Bundle savedInstanceState) {
- return inflater.inflate(R.layout.fragment_subscription, container, false);
- }
-
- @Override
- public void onResume() {
- super.onResume();
- setupBroadcastReceiver();
- if (updateFlags != 0) {
- if ((updateFlags & LIST_MODE_UPDATE_FLAG) != 0) {
- final boolean useGrid = isGridLayout();
- itemsList.setLayoutManager(useGrid ? getGridLayoutManager() : getListLayoutManager());
- infoListAdapter.setGridItemVariants(useGrid);
- infoListAdapter.notifyDataSetChanged();
- }
- updateFlags = 0;
- }
- }
-
- @Override
- public void onPause() {
- super.onPause();
- itemsListState = itemsList.getLayoutManager().onSaveInstanceState();
- importExportOptionsState = importExportOptions.onSaveInstanceState();
-
- if (subscriptionBroadcastReceiver != null && activity != null) {
- LocalBroadcastManager.getInstance(activity).unregisterReceiver(subscriptionBroadcastReceiver);
- }
- }
-
- @Override
- public void onDestroyView() {
- if (disposables != null) disposables.clear();
-
- super.onDestroyView();
- }
-
- @Override
- public void onDestroy() {
- if (disposables != null) disposables.dispose();
- disposables = null;
- subscriptionService = null;
-
- PreferenceManager.getDefaultSharedPreferences(activity)
- .unregisterOnSharedPreferenceChangeListener(this);
- super.onDestroy();
- }
-
- protected RecyclerView.LayoutManager getListLayoutManager() {
- return new LinearLayoutManager(activity);
- }
-
- protected RecyclerView.LayoutManager getGridLayoutManager() {
- final Resources resources = activity.getResources();
- int width = resources.getDimensionPixelSize(R.dimen.video_item_grid_thumbnail_image_width);
- width += (24 * resources.getDisplayMetrics().density);
- final int spanCount = (int) Math.floor(resources.getDisplayMetrics().widthPixels / (double)width);
- final GridLayoutManager lm = new GridLayoutManager(activity, spanCount);
- lm.setSpanSizeLookup(infoListAdapter.getSpanSizeLookup(spanCount));
- return lm;
- }
-
- /*/////////////////////////////////////////////////////////////////////////
- // Menu
- /////////////////////////////////////////////////////////////////////////*/
-
- @Override
- public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) {
- super.onCreateOptionsMenu(menu, inflater);
-
- ActionBar supportActionBar = activity.getSupportActionBar();
- if (supportActionBar != null) {
- supportActionBar.setDisplayShowTitleEnabled(true);
- setTitle(getString(R.string.tab_subscriptions));
- }
- }
-
- /*//////////////////////////////////////////////////////////////////////////
- // Subscriptions import/export
- //////////////////////////////////////////////////////////////////////////*/
-
- private BroadcastReceiver subscriptionBroadcastReceiver;
-
- private void setupBroadcastReceiver() {
- if (activity == null) return;
-
- if (subscriptionBroadcastReceiver != null) {
- LocalBroadcastManager.getInstance(activity).unregisterReceiver(subscriptionBroadcastReceiver);
- }
-
- final IntentFilter filters = new IntentFilter();
- filters.addAction(SubscriptionsExportService.EXPORT_COMPLETE_ACTION);
- filters.addAction(SubscriptionsImportService.IMPORT_COMPLETE_ACTION);
- subscriptionBroadcastReceiver = new BroadcastReceiver() {
- @Override
- public void onReceive(Context context, Intent intent) {
- if (importExportOptions != null) importExportOptions.collapse();
- }
- };
-
- LocalBroadcastManager.getInstance(activity).registerReceiver(subscriptionBroadcastReceiver, filters);
- }
-
- private View addItemView(final String title, @DrawableRes final int icon, ViewGroup container) {
- final View itemRoot = View.inflate(getContext(), R.layout.subscription_import_export_item, null);
- final TextView titleView = itemRoot.findViewById(android.R.id.text1);
- final ImageView iconView = itemRoot.findViewById(android.R.id.icon1);
-
- titleView.setText(title);
- iconView.setImageResource(icon);
-
- container.addView(itemRoot);
- return itemRoot;
- }
-
- private void setupImportFromItems(final ViewGroup listHolder) {
- final View previousBackupItem = addItemView(getString(R.string.previous_export),
- ThemeHelper.resolveResourceIdFromAttr(getContext(), R.attr.ic_backup), listHolder);
- previousBackupItem.setOnClickListener(item -> onImportPreviousSelected());
-
- final int iconColor = ThemeHelper.isLightThemeSelected(getContext()) ? Color.BLACK : Color.WHITE;
- final String[] services = getResources().getStringArray(R.array.service_list);
- for (String serviceName : services) {
- try {
- final StreamingService service = NewPipe.getService(serviceName);
-
- final SubscriptionExtractor subscriptionExtractor = service.getSubscriptionExtractor();
- if (subscriptionExtractor == null) continue;
-
- final List supportedSources = subscriptionExtractor.getSupportedSources();
- if (supportedSources.isEmpty()) continue;
-
- final View itemView = addItemView(serviceName, ServiceHelper.getIcon(service.getServiceId()), listHolder);
- final ImageView iconView = itemView.findViewById(android.R.id.icon1);
- iconView.setColorFilter(iconColor, PorterDuff.Mode.SRC_IN);
-
- itemView.setOnClickListener(selectedItem -> onImportFromServiceSelected(service.getServiceId()));
- } catch (ExtractionException e) {
- throw new RuntimeException("Services array contains an entry that it's not a valid service name (" + serviceName + ")", e);
- }
- }
- }
-
- private void setupExportToItems(final ViewGroup listHolder) {
- final View previousBackupItem = addItemView(getString(R.string.file), ThemeHelper.resolveResourceIdFromAttr(getContext(), R.attr.ic_save), listHolder);
- previousBackupItem.setOnClickListener(item -> onExportSelected());
- }
-
- private void onImportFromServiceSelected(int serviceId) {
- FragmentManager fragmentManager = getFM();
- NavigationHelper.openSubscriptionsImportFragment(fragmentManager, serviceId);
- }
-
- private void onImportPreviousSelected() {
- startActivityForResult(FilePickerActivityHelper.chooseSingleFile(activity), REQUEST_IMPORT_CODE);
- }
-
- private void onExportSelected() {
- final String date = new SimpleDateFormat("yyyyMMddHHmm", Locale.ENGLISH).format(new Date());
- final String exportName = "newpipe_subscriptions_" + date + ".json";
- final File exportFile = new File(Environment.getExternalStorageDirectory(), exportName);
-
- startActivityForResult(FilePickerActivityHelper.chooseFileToSave(activity, exportFile.getAbsolutePath()), REQUEST_EXPORT_CODE);
- }
-
- @Override
- public void onActivityResult(int requestCode, int resultCode, Intent data) {
- super.onActivityResult(requestCode, resultCode, data);
- if (data != null && data.getData() != null && resultCode == Activity.RESULT_OK) {
- if (requestCode == REQUEST_EXPORT_CODE) {
- final File exportFile = Utils.getFileForUri(data.getData());
- if (!exportFile.getParentFile().canWrite() || !exportFile.getParentFile().canRead()) {
- Toast.makeText(activity, R.string.invalid_directory, Toast.LENGTH_SHORT).show();
- } else {
- activity.startService(new Intent(activity, SubscriptionsExportService.class)
- .putExtra(SubscriptionsExportService.KEY_FILE_PATH, exportFile.getAbsolutePath()));
- }
- } else if (requestCode == REQUEST_IMPORT_CODE) {
- final String path = Utils.getFileForUri(data.getData()).getAbsolutePath();
- ImportConfirmationDialog.show(this, new Intent(activity, SubscriptionsImportService.class)
- .putExtra(KEY_MODE, PREVIOUS_EXPORT_MODE)
- .putExtra(KEY_VALUE, path));
- }
- }
- }
- /*/////////////////////////////////////////////////////////////////////////
- // Fragment Views
- /////////////////////////////////////////////////////////////////////////*/
-
- @Override
- protected void initViews(View rootView, Bundle savedInstanceState) {
- super.initViews(rootView, savedInstanceState);
-
- final boolean useGrid = isGridLayout();
- infoListAdapter = new InfoListAdapter(getActivity());
- itemsList = rootView.findViewById(R.id.items_list);
- itemsList.setLayoutManager(useGrid ? getGridLayoutManager() : getListLayoutManager());
-
- View headerRootLayout;
- infoListAdapter.setHeader(headerRootLayout = activity.getLayoutInflater().inflate(R.layout.subscription_header, itemsList, false));
- whatsNewItemListHeader = headerRootLayout.findViewById(R.id.whats_new);
- importExportListHeader = headerRootLayout.findViewById(R.id.import_export);
- importExportOptions = headerRootLayout.findViewById(R.id.import_export_options);
-
- infoListAdapter.useMiniItemVariants(true);
- infoListAdapter.setGridItemVariants(useGrid);
- itemsList.setAdapter(infoListAdapter);
-
- setupImportFromItems(headerRootLayout.findViewById(R.id.import_from_options));
- setupExportToItems(headerRootLayout.findViewById(R.id.export_to_options));
-
- if (importExportOptionsState != null) {
- importExportOptions.onRestoreInstanceState(importExportOptionsState);
- importExportOptionsState = null;
- }
-
- importExportOptions.addListener(getExpandIconSyncListener(headerRootLayout.findViewById(R.id.import_export_expand_icon)));
- importExportOptions.ready();
- }
-
- private CollapsibleView.StateListener getExpandIconSyncListener(final ImageView iconView) {
- return newState -> animateRotation(iconView, 250, newState == CollapsibleView.COLLAPSED ? 0 : 180);
- }
-
- @Override
- protected void initListeners() {
- super.initListeners();
-
- infoListAdapter.setOnChannelSelectedListener(new OnClickGesture() {
-
- public void selected(ChannelInfoItem selectedItem) {
- final FragmentManager fragmentManager = getFM();
- NavigationHelper.openChannelFragment(fragmentManager,
- selectedItem.getServiceId(),
- selectedItem.getUrl(),
- selectedItem.getName());
- }
-
- public void held(ChannelInfoItem selectedItem) {
- showLongTapDialog(selectedItem);
- }
-
- });
-
- whatsNewItemListHeader.setOnClickListener(v -> {
- FragmentManager fragmentManager = getFM();
- NavigationHelper.openWhatsNewFragment(fragmentManager);
- });
- importExportListHeader.setOnClickListener(v -> importExportOptions.switchState());
- }
-
- private void showLongTapDialog(ChannelInfoItem selectedItem) {
- final Context context = getContext();
- final Activity activity = getActivity();
- if (context == null || context.getResources() == null || getActivity() == null) return;
-
- final String[] commands = new String[]{
- context.getResources().getString(R.string.unsubscribe),
- context.getResources().getString(R.string.share)
- };
-
- final DialogInterface.OnClickListener actions = (dialogInterface, i) -> {
- switch (i) {
- case 0:
- deleteChannel(selectedItem);
- break;
- case 1:
- shareChannel(selectedItem);
- break;
- default:
- break;
- }
- };
-
- final View bannerView = View.inflate(activity, R.layout.dialog_title, null);
- bannerView.setSelected(true);
-
- TextView titleView = bannerView.findViewById(R.id.itemTitleView);
- titleView.setText(selectedItem.getName());
-
- TextView detailsView = bannerView.findViewById(R.id.itemAdditionalDetails);
- detailsView.setVisibility(View.GONE);
-
- new AlertDialog.Builder(activity)
- .setCustomTitle(bannerView)
- .setItems(commands, actions)
- .create()
- .show();
-
- }
-
- private void shareChannel(ChannelInfoItem selectedItem) {
- ShareUtils.shareUrl(getContext(), selectedItem.getName(), selectedItem.getUrl());
- }
-
- @SuppressLint("CheckResult")
- private void deleteChannel(ChannelInfoItem selectedItem) {
- subscriptionService.subscriptionTable()
- .getSubscription(selectedItem.getServiceId(), selectedItem.getUrl())
- .toObservable()
- .observeOn(Schedulers.io())
- .subscribe(getDeleteObserver());
-
- Toast.makeText(activity, getString(R.string.channel_unsubscribed), Toast.LENGTH_SHORT).show();
- }
-
-
-
- private Observer> getDeleteObserver() {
- return new Observer>() {
- @Override
- public void onSubscribe(Disposable d) {
- disposables.add(d);
- }
-
- @Override
- public void onNext(List subscriptionEntities) {
- subscriptionService.subscriptionTable().delete(subscriptionEntities);
- }
-
- @Override
- public void onError(Throwable exception) {
- SubscriptionFragment.this.onError(exception);
- }
-
- @Override
- public void onComplete() { }
- };
- }
-
- private void resetFragment() {
- if (disposables != null) disposables.clear();
- if (infoListAdapter != null) infoListAdapter.clearStreamItemList();
- }
-
- ///////////////////////////////////////////////////////////////////////////
- // Subscriptions Loader
- ///////////////////////////////////////////////////////////////////////////
-
- @Override
- public void startLoading(boolean forceLoad) {
- super.startLoading(forceLoad);
- resetFragment();
-
- subscriptionService.getSubscription().toObservable()
- .subscribeOn(Schedulers.io())
- .observeOn(AndroidSchedulers.mainThread())
- .subscribe(getSubscriptionObserver());
- }
-
- private Observer> getSubscriptionObserver() {
- return new Observer>() {
- @Override
- public void onSubscribe(Disposable d) {
- showLoading();
- disposables.add(d);
- }
-
- @Override
- public void onNext(List subscriptions) {
- handleResult(subscriptions);
- }
-
- @Override
- public void onError(Throwable exception) {
- SubscriptionFragment.this.onError(exception);
- }
-
- @Override
- public void onComplete() {
- }
- };
- }
-
- @Override
- public void handleResult(@NonNull List result) {
- super.handleResult(result);
-
- infoListAdapter.clearStreamItemList();
-
- if (result.isEmpty()) {
- whatsNewItemListHeader.setVisibility(View.GONE);
- showEmptyState();
- } else {
- infoListAdapter.addInfoItemList(getSubscriptionItems(result));
- if (itemsListState != null) {
- itemsList.getLayoutManager().onRestoreInstanceState(itemsListState);
- itemsListState = null;
- }
- whatsNewItemListHeader.setVisibility(View.VISIBLE);
- hideLoading();
- }
- }
-
-
- private List getSubscriptionItems(List subscriptions) {
- List items = new ArrayList<>();
- for (final SubscriptionEntity subscription : subscriptions) {
- items.add(subscription.toChannelInfoItem());
- }
-
- Collections.sort(items,
- (InfoItem o1, InfoItem o2) ->
- o1.getName().compareToIgnoreCase(o2.getName()));
- return items;
- }
-
- /*//////////////////////////////////////////////////////////////////////////
- // Contract
- //////////////////////////////////////////////////////////////////////////*/
-
- @Override
- public void showLoading() {
- super.showLoading();
- animateView(itemsList, false, 100);
- }
-
- @Override
- public void hideLoading() {
- super.hideLoading();
- animateView(itemsList, true, 200);
- }
-
- ///////////////////////////////////////////////////////////////////////////
- // Fragment Error Handling
- ///////////////////////////////////////////////////////////////////////////
-
- @Override
- protected boolean onError(Throwable exception) {
- resetFragment();
- if (super.onError(exception)) return true;
-
- onUnrecoverableError(exception,
- UserAction.SOMETHING_ELSE,
- "none",
- "Subscriptions",
- R.string.general_error);
- return true;
- }
-
- @Override
- public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) {
- if (key.equals(getString(R.string.list_view_mode_key))) {
- updateFlags |= LIST_MODE_UPDATE_FLAG;
- }
- }
-
- protected boolean isGridLayout() {
- final String list_mode = PreferenceManager.getDefaultSharedPreferences(activity).getString(getString(R.string.list_view_mode_key), getString(R.string.list_view_mode_value));
- if ("auto".equals(list_mode)) {
- final Configuration configuration = getResources().getConfiguration();
- return configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
- && configuration.isLayoutSizeAtLeast(Configuration.SCREENLAYOUT_SIZE_LARGE);
- } else {
- return "grid".equals(list_mode);
- }
- }
-}
diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionFragment.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionFragment.kt
new file mode 100644
index 000000000..98e20a02f
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionFragment.kt
@@ -0,0 +1,421 @@
+package org.schabi.newpipe.local.subscription
+
+import android.app.Activity
+import android.app.AlertDialog
+import android.content.*
+import android.content.res.Configuration
+import android.os.Bundle
+import android.os.Environment
+import android.os.Parcelable
+import android.preference.PreferenceManager
+import android.view.*
+import android.widget.Toast
+import androidx.lifecycle.ViewModelProviders
+import androidx.localbroadcastmanager.content.LocalBroadcastManager
+import androidx.recyclerview.widget.GridLayoutManager
+import com.nononsenseapps.filepicker.Utils
+import com.xwray.groupie.Group
+import com.xwray.groupie.GroupAdapter
+import com.xwray.groupie.Item
+import com.xwray.groupie.Section
+import com.xwray.groupie.kotlinandroidextensions.GroupieViewHolder
+import icepick.State
+import io.reactivex.disposables.CompositeDisposable
+import kotlinx.android.synthetic.main.dialog_title.view.*
+import kotlinx.android.synthetic.main.fragment_subscription.*
+import org.schabi.newpipe.R
+import org.schabi.newpipe.database.feed.model.FeedGroupEntity
+import org.schabi.newpipe.extractor.channel.ChannelInfoItem
+import org.schabi.newpipe.fragments.BaseStateFragment
+import org.schabi.newpipe.local.subscription.SubscriptionViewModel.SubscriptionState
+import org.schabi.newpipe.local.subscription.dialog.FeedGroupDialog
+import org.schabi.newpipe.local.subscription.dialog.FeedGroupReorderDialog
+import org.schabi.newpipe.local.subscription.item.*
+import org.schabi.newpipe.local.subscription.services.SubscriptionsExportService
+import org.schabi.newpipe.local.subscription.services.SubscriptionsExportService.EXPORT_COMPLETE_ACTION
+import org.schabi.newpipe.local.subscription.services.SubscriptionsExportService.KEY_FILE_PATH
+import org.schabi.newpipe.local.subscription.services.SubscriptionsImportService
+import org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.*
+import org.schabi.newpipe.report.UserAction
+import org.schabi.newpipe.util.*
+import org.schabi.newpipe.util.AnimationUtils.animateView
+import java.io.File
+import java.text.SimpleDateFormat
+import java.util.*
+import kotlin.math.floor
+import kotlin.math.max
+
+class SubscriptionFragment : BaseStateFragment() {
+ private lateinit var viewModel: SubscriptionViewModel
+ private lateinit var subscriptionManager: SubscriptionManager
+ private val disposables: CompositeDisposable = CompositeDisposable()
+
+ private var subscriptionBroadcastReceiver: BroadcastReceiver? = null
+
+ private val groupAdapter = GroupAdapter()
+ private val feedGroupsSection = Section()
+ private var feedGroupsCarousel: FeedGroupCarouselItem? = null
+ private lateinit var importExportItem: FeedImportExportItem
+ private lateinit var feedGroupsSortMenuItem: HeaderWithMenuItem
+ private val subscriptionsSection = Section()
+
+ @State @JvmField var itemsListState: Parcelable? = null
+ @State @JvmField var feedGroupsListState: Parcelable? = null
+ @State @JvmField var importExportItemExpandedState: Boolean? = null
+
+ init {
+ setHasOptionsMenu(true)
+ }
+
+ ///////////////////////////////////////////////////////////////////////////
+ // Fragment LifeCycle
+ ///////////////////////////////////////////////////////////////////////////
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setupInitialLayout()
+ }
+
+ override fun setUserVisibleHint(isVisibleToUser: Boolean) {
+ super.setUserVisibleHint(isVisibleToUser)
+ if (activity != null && isVisibleToUser) {
+ setTitle(activity.getString(R.string.tab_subscriptions))
+ }
+ }
+
+ override fun onAttach(context: Context) {
+ super.onAttach(context)
+ subscriptionManager = SubscriptionManager(requireContext())
+ }
+
+ override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
+ return inflater.inflate(R.layout.fragment_subscription, container, false)
+ }
+
+ override fun onResume() {
+ super.onResume()
+ setupBroadcastReceiver()
+ }
+
+ override fun onPause() {
+ super.onPause()
+ itemsListState = items_list.layoutManager?.onSaveInstanceState()
+ feedGroupsListState = feedGroupsCarousel?.onSaveInstanceState()
+ importExportItemExpandedState = importExportItem.isExpanded
+
+ if (subscriptionBroadcastReceiver != null && activity != null) {
+ LocalBroadcastManager.getInstance(activity).unregisterReceiver(subscriptionBroadcastReceiver!!)
+ }
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+ disposables.dispose()
+ }
+
+ //////////////////////////////////////////////////////////////////////////
+ // Menu
+ //////////////////////////////////////////////////////////////////////////
+
+ override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
+ super.onCreateOptionsMenu(menu, inflater)
+
+ val supportActionBar = activity.supportActionBar
+ if (supportActionBar != null) {
+ supportActionBar.setDisplayShowTitleEnabled(true)
+ setTitle(getString(R.string.tab_subscriptions))
+ }
+ }
+
+ private fun setupBroadcastReceiver() {
+ if (activity == null) return
+
+ if (subscriptionBroadcastReceiver != null) {
+ LocalBroadcastManager.getInstance(activity).unregisterReceiver(subscriptionBroadcastReceiver!!)
+ }
+
+ val filters = IntentFilter()
+ filters.addAction(EXPORT_COMPLETE_ACTION)
+ filters.addAction(IMPORT_COMPLETE_ACTION)
+ subscriptionBroadcastReceiver = object : BroadcastReceiver() {
+ override fun onReceive(context: Context, intent: Intent) {
+ items_list?.post {
+ importExportItem.isExpanded = false
+ importExportItem.notifyChanged(FeedImportExportItem.REFRESH_EXPANDED_STATUS)
+ }
+
+ }
+ }
+
+ LocalBroadcastManager.getInstance(activity).registerReceiver(subscriptionBroadcastReceiver!!, filters)
+ }
+
+ private fun onImportFromServiceSelected(serviceId: Int) {
+ val fragmentManager = fm
+ NavigationHelper.openSubscriptionsImportFragment(fragmentManager, serviceId)
+ }
+
+ private fun onImportPreviousSelected() {
+ startActivityForResult(FilePickerActivityHelper.chooseSingleFile(activity), REQUEST_IMPORT_CODE)
+ }
+
+ private fun onExportSelected() {
+ val date = SimpleDateFormat("yyyyMMddHHmm", Locale.ENGLISH).format(Date())
+ val exportName = "newpipe_subscriptions_$date.json"
+ val exportFile = File(Environment.getExternalStorageDirectory(), exportName)
+
+ startActivityForResult(FilePickerActivityHelper.chooseFileToSave(activity, exportFile.absolutePath), REQUEST_EXPORT_CODE)
+ }
+
+ private fun openReorderDialog() {
+ FeedGroupReorderDialog().show(requireFragmentManager(), null)
+ }
+
+ override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
+ super.onActivityResult(requestCode, resultCode, data)
+ if (data != null && data.data != null && resultCode == Activity.RESULT_OK) {
+ if (requestCode == REQUEST_EXPORT_CODE) {
+ val exportFile = Utils.getFileForUri(data.data!!)
+ if (!exportFile.parentFile.canWrite() || !exportFile.parentFile.canRead()) {
+ Toast.makeText(activity, R.string.invalid_directory, Toast.LENGTH_SHORT).show()
+ } else {
+ activity.startService(Intent(activity, SubscriptionsExportService::class.java)
+ .putExtra(KEY_FILE_PATH, exportFile.absolutePath))
+ }
+ } else if (requestCode == REQUEST_IMPORT_CODE) {
+ val path = Utils.getFileForUri(data.data!!).absolutePath
+ ImportConfirmationDialog.show(this, Intent(activity, SubscriptionsImportService::class.java)
+ .putExtra(KEY_MODE, PREVIOUS_EXPORT_MODE)
+ .putExtra(KEY_VALUE, path))
+ }
+ }
+ }
+
+ //////////////////////////////////////////////////////////////////////////
+ // Fragment Views
+ //////////////////////////////////////////////////////////////////////////
+
+ private fun setupInitialLayout() {
+ Section().apply {
+ val carouselAdapter = GroupAdapter()
+
+ carouselAdapter.add(FeedGroupCardItem(-1, getString(R.string.all), FeedGroupIcon.RSS))
+ carouselAdapter.add(feedGroupsSection)
+ carouselAdapter.add(FeedGroupAddItem())
+
+ carouselAdapter.setOnItemClickListener { item, _ ->
+ listenerFeedGroups.selected(item)
+ }
+ carouselAdapter.setOnItemLongClickListener { item, _ ->
+ if (item is FeedGroupCardItem) {
+ if (item.groupId == FeedGroupEntity.GROUP_ALL_ID) {
+ return@setOnItemLongClickListener false
+ }
+ }
+ listenerFeedGroups.held(item)
+ return@setOnItemLongClickListener true
+ }
+
+ feedGroupsCarousel = FeedGroupCarouselItem(requireContext(), carouselAdapter)
+ feedGroupsSortMenuItem = HeaderWithMenuItem(
+ getString(R.string.feed_groups_header_title),
+ ThemeHelper.resolveResourceIdFromAttr(requireContext(), R.attr.ic_sort),
+ menuItemOnClickListener = ::openReorderDialog
+ )
+ add(Section(feedGroupsSortMenuItem, listOf(feedGroupsCarousel)))
+
+ groupAdapter.add(this)
+ }
+
+ subscriptionsSection.setPlaceholder(EmptyPlaceholderItem())
+ subscriptionsSection.setHideWhenEmpty(true)
+
+ importExportItem = FeedImportExportItem(
+ { onImportPreviousSelected() },
+ { onImportFromServiceSelected(it) },
+ { onExportSelected() },
+ importExportItemExpandedState ?: false)
+ groupAdapter.add(Section(importExportItem, listOf(subscriptionsSection)))
+
+ }
+
+ override fun initViews(rootView: View, savedInstanceState: Bundle?) {
+ super.initViews(rootView, savedInstanceState)
+
+ val shouldUseGridLayout = shouldUseGridLayout()
+ groupAdapter.spanCount = if (shouldUseGridLayout) getGridSpanCount() else 1
+ items_list.layoutManager = GridLayoutManager(requireContext(), groupAdapter.spanCount).apply {
+ spanSizeLookup = groupAdapter.spanSizeLookup
+ }
+ items_list.adapter = groupAdapter
+
+ viewModel = ViewModelProviders.of(this).get(SubscriptionViewModel::class.java)
+ viewModel.stateLiveData.observe(viewLifecycleOwner, androidx.lifecycle.Observer { it?.let(this::handleResult) })
+ viewModel.feedGroupsLiveData.observe(viewLifecycleOwner, androidx.lifecycle.Observer { it?.let(this::handleFeedGroups) })
+ }
+
+ private fun showLongTapDialog(selectedItem: ChannelInfoItem) {
+ val commands = arrayOf(
+ getString(R.string.share),
+ getString(R.string.unsubscribe)
+ )
+
+ val actions = DialogInterface.OnClickListener { _, i ->
+ when (i) {
+ 0 -> ShareUtils.shareUrl(requireContext(), selectedItem.name, selectedItem.url)
+ 1 -> deleteChannel(selectedItem)
+ }
+ }
+
+ val bannerView = View.inflate(requireContext(), R.layout.dialog_title, null)
+ bannerView.isSelected = true
+ bannerView.itemTitleView.text = selectedItem.name
+ bannerView.itemAdditionalDetails.visibility = View.GONE
+
+ AlertDialog.Builder(requireContext())
+ .setCustomTitle(bannerView)
+ .setItems(commands, actions)
+ .create()
+ .show()
+ }
+
+ private fun deleteChannel(selectedItem: ChannelInfoItem) {
+ disposables.add(subscriptionManager.deleteSubscription(selectedItem.serviceId, selectedItem.url).subscribe {
+ Toast.makeText(requireContext(), getString(R.string.channel_unsubscribed), Toast.LENGTH_SHORT).show()
+ })
+ }
+
+ override fun doInitialLoadLogic() = Unit
+ override fun startLoading(forceLoad: Boolean) = Unit
+
+ private val listenerFeedGroups = object : OnClickGesture- >() {
+ override fun selected(selectedItem: Item<*>?) {
+ when (selectedItem) {
+ is FeedGroupCardItem -> NavigationHelper.openFeedFragment(fm, selectedItem.groupId, selectedItem.name)
+ is FeedGroupAddItem -> FeedGroupDialog.newInstance().show(fm, null)
+ }
+ }
+
+ override fun held(selectedItem: Item<*>?) {
+ when (selectedItem) {
+ is FeedGroupCardItem -> FeedGroupDialog.newInstance(selectedItem.groupId).show(fm, null)
+ }
+ }
+ }
+
+ private val listenerChannelItem = object : OnClickGesture() {
+ override fun selected(selectedItem: ChannelInfoItem) = NavigationHelper.openChannelFragment(fm,
+ selectedItem.serviceId, selectedItem.url, selectedItem.name)
+
+ override fun held(selectedItem: ChannelInfoItem) = showLongTapDialog(selectedItem)
+ }
+
+ override fun handleResult(result: SubscriptionState) {
+ super.handleResult(result)
+
+ val shouldUseGridLayout = shouldUseGridLayout()
+ when (result) {
+ is SubscriptionState.LoadedState -> {
+ result.subscriptions.forEach {
+ if (it is ChannelItem) {
+ it.gesturesListener = listenerChannelItem
+ it.itemVersion = when {
+ shouldUseGridLayout -> ChannelItem.ItemVersion.GRID
+ else -> ChannelItem.ItemVersion.MINI
+ }
+ }
+ }
+
+ subscriptionsSection.update(result.subscriptions)
+ subscriptionsSection.setHideWhenEmpty(false)
+
+ if (result.subscriptions.isEmpty() && importExportItemExpandedState == null) {
+ items_list.post {
+ importExportItem.isExpanded = true
+ importExportItem.notifyChanged(FeedImportExportItem.REFRESH_EXPANDED_STATUS)
+ }
+ }
+
+ if (itemsListState != null) {
+ items_list.layoutManager?.onRestoreInstanceState(itemsListState)
+ itemsListState = null
+ }
+ }
+ is SubscriptionState.ErrorState -> {
+ result.error?.let { onError(result.error) }
+ }
+ }
+ }
+
+ private fun handleFeedGroups(groups: List) {
+ feedGroupsSection.update(groups)
+
+ if (feedGroupsListState != null) {
+ feedGroupsCarousel?.onRestoreInstanceState(feedGroupsListState)
+ feedGroupsListState = null
+ }
+
+ if (groups.size < 2) {
+ items_list.post { feedGroupsSortMenuItem.notifyChanged(HeaderWithMenuItem.PAYLOAD_HIDE_MENU_ITEM) }
+ } else {
+ items_list.post { feedGroupsSortMenuItem.notifyChanged(HeaderWithMenuItem.PAYLOAD_SHOW_MENU_ITEM) }
+ }
+ }
+
+ ///////////////////////////////////////////////////////////////////////////
+ // Contract
+ ///////////////////////////////////////////////////////////////////////////
+
+ override fun showLoading() {
+ super.showLoading()
+ animateView(items_list, false, 100)
+ }
+
+ override fun hideLoading() {
+ super.hideLoading()
+ animateView(items_list, true, 200)
+ }
+
+ ///////////////////////////////////////////////////////////////////////////
+ // Fragment Error Handling
+ ///////////////////////////////////////////////////////////////////////////
+
+ override fun onError(exception: Throwable): Boolean {
+ if (super.onError(exception)) return true
+
+ onUnrecoverableError(exception, UserAction.SOMETHING_ELSE, "none", "Subscriptions", R.string.general_error)
+ return true
+ }
+
+ ///////////////////////////////////////////////////////////////////////////
+ // Grid Mode
+ ///////////////////////////////////////////////////////////////////////////
+
+ // TODO: Move these out of this class, as it can be reused
+
+ private fun shouldUseGridLayout(): Boolean {
+ val listMode = PreferenceManager.getDefaultSharedPreferences(requireContext())
+ .getString(getString(R.string.list_view_mode_key), getString(R.string.list_view_mode_value))
+
+ return when (listMode) {
+ getString(R.string.list_view_mode_auto_key) -> {
+ val configuration = resources.configuration
+
+ (configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
+ && configuration.isLayoutSizeAtLeast(Configuration.SCREENLAYOUT_SIZE_LARGE))
+ }
+ getString(R.string.list_view_mode_grid_key) -> true
+ else -> false
+ }
+ }
+
+ private fun getGridSpanCount(): Int {
+ val minWidth = resources.getDimensionPixelSize(R.dimen.channel_item_grid_min_width)
+ return max(1, floor(resources.displayMetrics.widthPixels / minWidth.toDouble()).toInt())
+ }
+
+ companion object {
+ private const val REQUEST_EXPORT_CODE = 666
+ private const val REQUEST_IMPORT_CODE = 667
+ }
+}
diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionManager.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionManager.kt
new file mode 100644
index 000000000..92ab8cb0c
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionManager.kt
@@ -0,0 +1,74 @@
+package org.schabi.newpipe.local.subscription
+
+import android.content.Context
+import io.reactivex.Completable
+import io.reactivex.android.schedulers.AndroidSchedulers
+import io.reactivex.schedulers.Schedulers
+import org.schabi.newpipe.NewPipeDatabase
+import org.schabi.newpipe.database.subscription.SubscriptionDAO
+import org.schabi.newpipe.database.subscription.SubscriptionEntity
+import org.schabi.newpipe.extractor.ListInfo
+import org.schabi.newpipe.extractor.channel.ChannelInfo
+import org.schabi.newpipe.extractor.feed.FeedInfo
+import org.schabi.newpipe.extractor.stream.StreamInfoItem
+import org.schabi.newpipe.local.feed.FeedDatabaseManager
+
+class SubscriptionManager(context: Context) {
+ private val database = NewPipeDatabase.getInstance(context)
+ private val subscriptionTable = database.subscriptionDAO()
+ private val feedDatabaseManager = FeedDatabaseManager(context)
+
+ fun subscriptionTable(): SubscriptionDAO = subscriptionTable
+ fun subscriptions() = subscriptionTable.all
+
+ fun upsertAll(infoList: List): List {
+ val listEntities = subscriptionTable.upsertAll(
+ infoList.map { SubscriptionEntity.from(it) })
+
+ database.runInTransaction {
+ infoList.forEachIndexed { index, info ->
+ feedDatabaseManager.upsertAll(listEntities[index].uid, info.relatedItems)
+ }
+ }
+
+ return listEntities
+ }
+
+ fun updateChannelInfo(info: ChannelInfo): Completable = subscriptionTable.getSubscription(info.serviceId, info.url)
+ .flatMapCompletable {
+ Completable.fromRunnable {
+ it.setData(info.name, info.avatarUrl, info.description, info.subscriberCount)
+ subscriptionTable.update(it)
+ feedDatabaseManager.upsertAll(it.uid, info.relatedItems)
+ }
+ }
+
+ fun updateFromInfo(subscriptionId: Long, info: ListInfo) {
+ val subscriptionEntity = subscriptionTable.getSubscription(subscriptionId)
+
+ if (info is FeedInfo) {
+ subscriptionEntity.name = info.name
+ } else if (info is ChannelInfo) {
+ subscriptionEntity.setData(info.name, info.avatarUrl, info.description, info.subscriberCount)
+ }
+
+ subscriptionTable.update(subscriptionEntity)
+ }
+
+ fun deleteSubscription(serviceId: Int, url: String): Completable {
+ return Completable.fromCallable { subscriptionTable.deleteSubscription(serviceId, url) }
+ .subscribeOn(Schedulers.io())
+ .observeOn(AndroidSchedulers.mainThread())
+ }
+
+ fun insertSubscription(subscriptionEntity: SubscriptionEntity, info: ChannelInfo) {
+ database.runInTransaction {
+ val subscriptionId = subscriptionTable.insert(subscriptionEntity)
+ feedDatabaseManager.upsertAll(subscriptionId, info.relatedItems)
+ }
+ }
+
+ fun deleteSubscription(subscriptionEntity: SubscriptionEntity) {
+ subscriptionTable.delete(subscriptionEntity)
+ }
+}
diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionService.java b/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionService.java
deleted file mode 100644
index 7d6fa5158..000000000
--- a/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionService.java
+++ /dev/null
@@ -1,162 +0,0 @@
-package org.schabi.newpipe.local.subscription;
-
-import android.content.Context;
-import androidx.annotation.NonNull;
-import android.util.Log;
-
-import org.schabi.newpipe.MainActivity;
-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.channel.ChannelInfo;
-import org.schabi.newpipe.util.ExtractorHelper;
-
-import java.util.ArrayList;
-import java.util.List;
-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.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 volatile SubscriptionService instance;
-
- public static SubscriptionService getInstance(@NonNull Context context) {
- SubscriptionService result = instance;
- if (result == null) {
- synchronized (SubscriptionService.class) {
- result = instance;
- if (result == null) {
- instance = (result = new SubscriptionService(context));
- }
- }
- }
-
- return result;
- }
-
- protected final String TAG = "SubscriptionService@" + Integer.toHexString(hashCode());
- protected static final boolean DEBUG = MainActivity.DEBUG;
- private static final int SUBSCRIPTION_DEBOUNCE_INTERVAL = 500;
- private static final int SUBSCRIPTION_THREAD_POOL_SIZE = 4;
-
- private final AppDatabase db;
- private final Flowable
> subscription;
-
- private final Scheduler subscriptionScheduler;
-
- private SubscriptionService(Context context) {
- db = NewPipeDatabase.getInstance(context.getApplicationContext());
- 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> getSubscriptionInfos() {
- return subscriptionTable().getAll()
- // 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.
- */
- @androidx.annotation.NonNull
- public Flowable> getSubscription() {
- return subscription;
- }
-
- public Maybe getChannelInfo(final SubscriptionEntity subscriptionEntity) {
- if (DEBUG) Log.d(TAG, "getChannelInfo() called with: subscriptionEntity = [" + subscriptionEntity + "]");
-
- return Maybe.fromSingle(ExtractorHelper
- .getChannelInfo(subscriptionEntity.getServiceId(), subscriptionEntity.getUrl(), false))
- .subscribeOn(subscriptionScheduler);
- }
-
- /**
- * Returns the database access interface for subscription table.
- */
- public SubscriptionDAO subscriptionTable() {
- return db.subscriptionDAO();
- }
-
- public Completable updateChannelInfo(final ChannelInfo info) {
- final Function, CompletableSource> update = new Function, CompletableSource>() {
- @Override
- public CompletableSource apply(@NonNull List subscriptionEntities) {
- if (DEBUG) Log.d(TAG, "updateChannelInfo() called with: subscriptionEntities = [" + subscriptionEntities + "]");
- 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(info, subscription)) {
- subscription.setData(info.getName(), info.getAvatarUrl(), info.getDescription(), info.getSubscriberCount());
-
- return Completable.fromRunnable(() -> subscriptionTable().update(subscription));
- }
- }
-
- return Completable.complete();
- }
- };
-
- return subscriptionTable().getSubscription(info.getServiceId(), info.getUrl())
- .firstOrError()
- .flatMapCompletable(update);
- }
-
- public List upsertAll(final List infoList) {
- final List entityList = new ArrayList<>();
- for (ChannelInfo info : infoList) entityList.add(SubscriptionEntity.from(info));
-
- return subscriptionTable().upsertAll(entityList);
- }
-
- private boolean isSubscriptionUpToDate(final ChannelInfo info, final SubscriptionEntity entity) {
- return equalsAndNotNull(info.getUrl(), entity.getUrl()) &&
- info.getServiceId() == entity.getServiceId() &&
- info.getName().equals(entity.getName()) &&
- equalsAndNotNull(info.getAvatarUrl(), entity.getAvatarUrl()) &&
- equalsAndNotNull(info.getDescription(), entity.getDescription()) &&
- info.getSubscriberCount() == entity.getSubscriberCount();
- }
-
- private boolean equalsAndNotNull(final Object o1, final Object o2) {
- return (o1 != null && o2 != null)
- && o1.equals(o2);
- }
-}
diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionViewModel.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionViewModel.kt
new file mode 100644
index 000000000..6454cc912
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionViewModel.kt
@@ -0,0 +1,52 @@
+package org.schabi.newpipe.local.subscription
+
+import android.app.Application
+import androidx.lifecycle.AndroidViewModel
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import com.xwray.groupie.Group
+import io.reactivex.schedulers.Schedulers
+import org.schabi.newpipe.local.feed.FeedDatabaseManager
+import org.schabi.newpipe.local.subscription.item.ChannelItem
+import org.schabi.newpipe.local.subscription.item.FeedGroupCardItem
+import org.schabi.newpipe.util.DEFAULT_THROTTLE_TIMEOUT
+import java.util.concurrent.TimeUnit
+
+class SubscriptionViewModel(application: Application) : AndroidViewModel(application) {
+ private var feedDatabaseManager: FeedDatabaseManager = FeedDatabaseManager(application)
+ private var subscriptionManager = SubscriptionManager(application)
+
+ private val mutableStateLiveData = MutableLiveData()
+ private val mutableFeedGroupsLiveData = MutableLiveData>()
+ val stateLiveData: LiveData = mutableStateLiveData
+ val feedGroupsLiveData: LiveData> = mutableFeedGroupsLiveData
+
+ private var feedGroupItemsDisposable = feedDatabaseManager.groups()
+ .throttleLatest(DEFAULT_THROTTLE_TIMEOUT, TimeUnit.MILLISECONDS)
+ .map { it.map(::FeedGroupCardItem) }
+ .subscribeOn(Schedulers.io())
+ .subscribe(
+ { mutableFeedGroupsLiveData.postValue(it) },
+ { mutableStateLiveData.postValue(SubscriptionState.ErrorState(it)) }
+ )
+
+ private var stateItemsDisposable = subscriptionManager.subscriptions()
+ .throttleLatest(DEFAULT_THROTTLE_TIMEOUT, TimeUnit.MILLISECONDS)
+ .map { it.map { entity -> ChannelItem(entity.toChannelInfoItem(), entity.uid, ChannelItem.ItemVersion.MINI) } }
+ .subscribeOn(Schedulers.io())
+ .subscribe(
+ { mutableStateLiveData.postValue(SubscriptionState.LoadedState(it)) },
+ { mutableStateLiveData.postValue(SubscriptionState.ErrorState(it)) }
+ )
+
+ override fun onCleared() {
+ super.onCleared()
+ stateItemsDisposable.dispose()
+ feedGroupItemsDisposable.dispose()
+ }
+
+ sealed class SubscriptionState {
+ data class LoadedState(val subscriptions: List) : SubscriptionState()
+ data class ErrorState(val error: Throwable? = null) : SubscriptionState()
+ }
+}
diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/decoration/FeedGroupCarouselDecoration.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/decoration/FeedGroupCarouselDecoration.kt
new file mode 100644
index 000000000..24c8d9cb8
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/local/subscription/decoration/FeedGroupCarouselDecoration.kt
@@ -0,0 +1,35 @@
+package org.schabi.newpipe.local.subscription.decoration
+
+import android.content.Context
+import android.graphics.Rect
+import android.view.View
+import androidx.recyclerview.widget.RecyclerView
+import org.schabi.newpipe.R
+
+class FeedGroupCarouselDecoration(context: Context) : RecyclerView.ItemDecoration() {
+
+ private val marginStartEnd: Int
+ private val marginTopBottom: Int
+ private val marginBetweenItems: Int
+
+ init {
+ with(context.resources) {
+ marginStartEnd = getDimensionPixelOffset(R.dimen.feed_group_carousel_start_end_margin)
+ marginTopBottom = getDimensionPixelOffset(R.dimen.feed_group_carousel_top_bottom_margin)
+ marginBetweenItems = getDimensionPixelOffset(R.dimen.feed_group_carousel_between_items_margin)
+ }
+ }
+
+ override fun getItemOffsets(outRect: Rect, child: View, parent: RecyclerView, state: RecyclerView.State) {
+ val childAdapterPosition = parent.getChildAdapterPosition(child)
+ val childAdapterCount = parent.adapter?.itemCount ?: 0
+
+ outRect.set(marginBetweenItems, marginTopBottom, 0, marginTopBottom)
+
+ if (childAdapterPosition == 0) {
+ outRect.left = marginStartEnd
+ } else if (childAdapterPosition == childAdapterCount - 1) {
+ outRect.right = marginStartEnd
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/dialog/FeedGroupDialog.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/dialog/FeedGroupDialog.kt
new file mode 100644
index 000000000..27ff38a3f
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/local/subscription/dialog/FeedGroupDialog.kt
@@ -0,0 +1,354 @@
+package org.schabi.newpipe.local.subscription.dialog
+
+import android.app.Dialog
+import android.content.Context
+import android.os.Bundle
+import android.os.Parcelable
+import android.text.Editable
+import android.text.TextWatcher
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.view.inputmethod.InputMethodManager
+import android.widget.Toast
+import androidx.fragment.app.DialogFragment
+import androidx.lifecycle.Observer
+import androidx.lifecycle.ViewModelProviders
+import androidx.recyclerview.widget.GridLayoutManager
+import androidx.recyclerview.widget.LinearLayoutManager
+import androidx.recyclerview.widget.RecyclerView
+import com.xwray.groupie.GroupAdapter
+import com.xwray.groupie.Section
+import com.xwray.groupie.kotlinandroidextensions.GroupieViewHolder
+import icepick.Icepick
+import icepick.State
+import kotlinx.android.synthetic.main.dialog_feed_group_create.*
+import org.schabi.newpipe.R
+import org.schabi.newpipe.database.feed.model.FeedGroupEntity
+import org.schabi.newpipe.database.subscription.SubscriptionEntity
+import org.schabi.newpipe.local.subscription.FeedGroupIcon
+import org.schabi.newpipe.local.subscription.dialog.FeedGroupDialog.ScreenState.*
+import org.schabi.newpipe.local.subscription.dialog.FeedGroupDialogViewModel.DialogEvent.ProcessingEvent
+import org.schabi.newpipe.local.subscription.dialog.FeedGroupDialogViewModel.DialogEvent.SuccessEvent
+import org.schabi.newpipe.local.subscription.item.EmptyPlaceholderItem
+import org.schabi.newpipe.local.subscription.item.PickerIconItem
+import org.schabi.newpipe.local.subscription.item.PickerSubscriptionItem
+import org.schabi.newpipe.util.ThemeHelper
+import java.io.Serializable
+
+class FeedGroupDialog : DialogFragment() {
+ private lateinit var viewModel: FeedGroupDialogViewModel
+ private var groupId: Long = NO_GROUP_SELECTED
+ private var groupIcon: FeedGroupIcon? = null
+ private var groupSortOrder: Long = -1
+
+ sealed class ScreenState : Serializable {
+ object InitialScreen : ScreenState()
+ object IconPickerScreen : ScreenState()
+ object SubscriptionsPickerScreen : ScreenState()
+ object DeleteScreen : ScreenState()
+ }
+
+ @State @JvmField var selectedIcon: FeedGroupIcon? = null
+ @State @JvmField var selectedSubscriptions: HashSet = HashSet()
+ @State @JvmField var currentScreen: ScreenState = InitialScreen
+
+ @State @JvmField var subscriptionsListState: Parcelable? = null
+ @State @JvmField var iconsListState: Parcelable? = null
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ Icepick.restoreInstanceState(this, savedInstanceState)
+
+ setStyle(STYLE_NO_TITLE, ThemeHelper.getMinWidthDialogTheme(requireContext()))
+ groupId = arguments?.getLong(KEY_GROUP_ID, NO_GROUP_SELECTED) ?: NO_GROUP_SELECTED
+ }
+
+ override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
+ return inflater.inflate(R.layout.dialog_feed_group_create, container)
+ }
+
+ override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
+ return object : Dialog(requireActivity(), theme) {
+ override fun onBackPressed() {
+ if (currentScreen !is InitialScreen) {
+ showScreen(InitialScreen)
+ } else {
+ super.onBackPressed()
+ }
+ }
+ }
+ }
+
+ override fun onSaveInstanceState(outState: Bundle) {
+ super.onSaveInstanceState(outState)
+
+ iconsListState = icon_selector.layoutManager?.onSaveInstanceState()
+ subscriptionsListState = subscriptions_selector_list.layoutManager?.onSaveInstanceState()
+
+ Icepick.saveInstanceState(this, outState)
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ viewModel = ViewModelProviders.of(this, FeedGroupDialogViewModel.Factory(requireContext(), groupId))
+ .get(FeedGroupDialogViewModel::class.java)
+
+ viewModel.groupLiveData.observe(viewLifecycleOwner, Observer(::handleGroup))
+ viewModel.subscriptionsLiveData.observe(viewLifecycleOwner, Observer { setupSubscriptionPicker(it.first, it.second) })
+ viewModel.dialogEventLiveData.observe(viewLifecycleOwner, Observer {
+ when (it) {
+ ProcessingEvent -> disableInput()
+ SuccessEvent -> dismiss()
+ }
+ })
+
+ setupIconPicker()
+ setupListeners()
+
+ showScreen(currentScreen)
+ }
+
+ ///////////////////////////////////////////////////////////////////////////
+ // Setup
+ ///////////////////////////////////////////////////////////////////////////
+
+ private fun setupListeners() {
+ delete_button.setOnClickListener { showScreen(DeleteScreen) }
+
+ cancel_button.setOnClickListener {
+ when (currentScreen) {
+ InitialScreen -> dismiss()
+ else -> showScreen(InitialScreen)
+ }
+ }
+
+ group_name_input_container.error = null
+ group_name_input.addTextChangedListener(object : TextWatcher {
+ override fun afterTextChanged(s: Editable?) {}
+ override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
+
+ override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
+ if (group_name_input_container.isErrorEnabled && !s.isNullOrBlank()) {
+ group_name_input_container.error = null
+ }
+ }
+ })
+
+ confirm_button.setOnClickListener {
+ when (currentScreen) {
+ InitialScreen -> handlePositiveButtonInitialScreen()
+ DeleteScreen -> viewModel.deleteGroup()
+ else -> showScreen(InitialScreen)
+ }
+ }
+ }
+
+ private fun handlePositiveButtonInitialScreen() {
+ val name = group_name_input.text.toString().trim()
+ val icon = selectedIcon ?: groupIcon ?: FeedGroupIcon.ALL
+
+ if (name.isBlank()) {
+ group_name_input_container.error = getString(R.string.feed_group_dialog_empty_name)
+ group_name_input.text = null
+ group_name_input.requestFocus()
+ return
+ } else {
+ group_name_input_container.error = null
+ }
+
+ if (selectedSubscriptions.isEmpty()) {
+ Toast.makeText(requireContext(), getString(R.string.feed_group_dialog_empty_selection), Toast.LENGTH_SHORT).show()
+ return
+ }
+
+ when (groupId) {
+ NO_GROUP_SELECTED -> viewModel.createGroup(name, icon, selectedSubscriptions)
+ else -> viewModel.updateGroup(name, icon, selectedSubscriptions, groupSortOrder)
+ }
+ }
+
+ private fun handleGroup(feedGroupEntity: FeedGroupEntity? = null) {
+ val icon = feedGroupEntity?.icon ?: FeedGroupIcon.ALL
+ val name = feedGroupEntity?.name ?: ""
+ groupIcon = feedGroupEntity?.icon
+ groupSortOrder = feedGroupEntity?.sortOrder ?: -1
+
+ icon_preview.setImageResource((if (selectedIcon == null) icon else selectedIcon!!).getDrawableRes(requireContext()))
+
+ if (group_name_input.text.isNullOrBlank()) {
+ group_name_input.setText(name)
+ }
+ }
+
+ private fun setupSubscriptionPicker(subscriptions: List, selectedSubscriptions: Set) {
+ this.selectedSubscriptions.addAll(selectedSubscriptions)
+ val useGridLayout = subscriptions.isNotEmpty()
+
+ val groupAdapter = GroupAdapter()
+ groupAdapter.spanCount = if (useGridLayout) 4 else 1
+
+ val selectedCountText = getString(R.string.feed_group_dialog_selection_count, this.selectedSubscriptions.size)
+ selected_subscription_count_view.text = selectedCountText
+ subscriptions_selector_header_info.text = selectedCountText
+
+ Section().apply {
+ addAll(subscriptions.map {
+ val isSelected = this@FeedGroupDialog.selectedSubscriptions.contains(it.uid)
+ PickerSubscriptionItem(it, isSelected)
+ })
+ setPlaceholder(EmptyPlaceholderItem())
+
+ groupAdapter.add(this)
+ }
+
+ subscriptions_selector_list.apply {
+ layoutManager = if (useGridLayout) {
+ GridLayoutManager(requireContext(), groupAdapter.spanCount, RecyclerView.VERTICAL, false)
+ } else {
+ LinearLayoutManager(requireContext(), RecyclerView.VERTICAL, false)
+ }
+
+ adapter = groupAdapter
+
+ if (subscriptionsListState != null) {
+ layoutManager?.onRestoreInstanceState(subscriptionsListState)
+ subscriptionsListState = null
+ }
+ }
+
+ groupAdapter.setOnItemClickListener { item, _ ->
+ when (item) {
+ is PickerSubscriptionItem -> {
+ val subscriptionId = item.subscriptionEntity.uid
+
+ val isSelected = if (this.selectedSubscriptions.contains(subscriptionId)) {
+ this.selectedSubscriptions.remove(subscriptionId)
+ false
+ } else {
+ this.selectedSubscriptions.add(subscriptionId)
+ true
+ }
+
+ item.isSelected = isSelected
+ item.notifyChanged(PickerSubscriptionItem.UPDATE_SELECTED)
+
+ val updateSelectedCountText = getString(R.string.feed_group_dialog_selection_count, this.selectedSubscriptions.size)
+ selected_subscription_count_view.text = updateSelectedCountText
+ subscriptions_selector_header_info.text = updateSelectedCountText
+ }
+ }
+ }
+
+ select_channel_button.setOnClickListener {
+ subscriptions_selector_list.scrollToPosition(0)
+ showScreen(SubscriptionsPickerScreen)
+ }
+ }
+
+ private fun setupIconPicker() {
+ val groupAdapter = GroupAdapter()
+ groupAdapter.addAll(FeedGroupIcon.values().map { PickerIconItem(requireContext(), it) })
+
+ icon_selector.apply {
+ layoutManager = GridLayoutManager(requireContext(), 7, RecyclerView.VERTICAL, false)
+ adapter = groupAdapter
+
+ if (iconsListState != null) {
+ layoutManager?.onRestoreInstanceState(iconsListState)
+ iconsListState = null
+ }
+ }
+
+ groupAdapter.setOnItemClickListener { item, _ ->
+ when (item) {
+ is PickerIconItem -> {
+ selectedIcon = item.icon
+ icon_preview.setImageResource(item.iconRes)
+
+ showScreen(InitialScreen)
+ }
+ }
+ }
+ icon_preview.setOnClickListener {
+ icon_selector.scrollToPosition(0)
+ showScreen(IconPickerScreen)
+ }
+
+ if (groupId == NO_GROUP_SELECTED) {
+ val icon = selectedIcon ?: FeedGroupIcon.ALL
+ icon_preview.setImageResource(icon.getDrawableRes(requireContext()))
+ }
+ }
+
+ ///////////////////////////////////////////////////////////////////////////
+ // Screen Selector
+ ///////////////////////////////////////////////////////////////////////////
+
+ private fun showScreen(screen: ScreenState) {
+ currentScreen = screen
+
+ options_root.onlyVisibleIn(InitialScreen)
+ icon_selector.onlyVisibleIn(IconPickerScreen)
+ subscriptions_selector.onlyVisibleIn(SubscriptionsPickerScreen)
+ delete_screen_message.onlyVisibleIn(DeleteScreen)
+
+ separator.onlyVisibleIn(SubscriptionsPickerScreen, IconPickerScreen)
+ cancel_button.onlyVisibleIn(InitialScreen, DeleteScreen)
+
+ confirm_button.setText(when {
+ currentScreen == InitialScreen && groupId == NO_GROUP_SELECTED -> R.string.create
+ else -> android.R.string.ok
+ })
+
+ delete_button.visibility = when {
+ currentScreen != InitialScreen -> View.GONE
+ groupId == NO_GROUP_SELECTED -> View.GONE
+ else -> View.VISIBLE
+ }
+
+ if (currentScreen != InitialScreen) hideKeyboard()
+ }
+
+ private fun View.onlyVisibleIn(vararg screens: ScreenState) {
+ visibility = when (currentScreen) {
+ in screens -> View.VISIBLE
+ else -> View.GONE
+ }
+ }
+
+ ///////////////////////////////////////////////////////////////////////////
+ // Utils
+ ///////////////////////////////////////////////////////////////////////////
+
+ private fun hideKeyboard() {
+ val inputMethodManager = requireActivity().getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
+ inputMethodManager.hideSoftInputFromWindow(group_name_input.windowToken, InputMethodManager.RESULT_UNCHANGED_SHOWN)
+ group_name_input.clearFocus()
+ }
+
+ private fun disableInput() {
+ delete_button?.isEnabled = false
+ confirm_button?.isEnabled = false
+ cancel_button?.isEnabled = false
+ isCancelable = false
+
+ hideKeyboard()
+ }
+
+ companion object {
+ private const val KEY_GROUP_ID = "KEY_GROUP_ID"
+ private const val NO_GROUP_SELECTED = -1L
+
+ fun newInstance(groupId: Long = NO_GROUP_SELECTED): FeedGroupDialog {
+ val dialog = FeedGroupDialog()
+
+ dialog.arguments = Bundle().apply {
+ putLong(KEY_GROUP_ID, groupId)
+ }
+
+ return dialog
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/dialog/FeedGroupDialogViewModel.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/dialog/FeedGroupDialogViewModel.kt
new file mode 100644
index 000000000..bd57a2639
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/local/subscription/dialog/FeedGroupDialogViewModel.kt
@@ -0,0 +1,87 @@
+package org.schabi.newpipe.local.subscription.dialog
+
+import android.content.Context
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.ViewModelProvider
+import io.reactivex.Completable
+import io.reactivex.Flowable
+import io.reactivex.disposables.Disposable
+import io.reactivex.functions.BiFunction
+import io.reactivex.schedulers.Schedulers
+import org.schabi.newpipe.database.feed.model.FeedGroupEntity
+import org.schabi.newpipe.database.subscription.SubscriptionEntity
+import org.schabi.newpipe.local.feed.FeedDatabaseManager
+import org.schabi.newpipe.local.subscription.FeedGroupIcon
+import org.schabi.newpipe.local.subscription.SubscriptionManager
+
+
+class FeedGroupDialogViewModel(applicationContext: Context, val groupId: Long = FeedGroupEntity.GROUP_ALL_ID) : ViewModel() {
+ class Factory(val context: Context, val groupId: Long = FeedGroupEntity.GROUP_ALL_ID) : ViewModelProvider.Factory {
+ @Suppress("UNCHECKED_CAST")
+ override fun create(modelClass: Class): T {
+ return FeedGroupDialogViewModel(context.applicationContext, groupId) as T
+ }
+ }
+
+ private var feedDatabaseManager: FeedDatabaseManager = FeedDatabaseManager(applicationContext)
+ private var subscriptionManager = SubscriptionManager(applicationContext)
+
+ private val mutableGroupLiveData = MutableLiveData()
+ private val mutableSubscriptionsLiveData = MutableLiveData, Set>>()
+ private val mutableDialogEventLiveData = MutableLiveData()
+ val groupLiveData: LiveData = mutableGroupLiveData
+ val subscriptionsLiveData: LiveData, Set>> = mutableSubscriptionsLiveData
+ val dialogEventLiveData: LiveData = mutableDialogEventLiveData
+
+ private var actionProcessingDisposable: Disposable? = null
+
+ private var feedGroupDisposable = feedDatabaseManager.getGroup(groupId)
+ .subscribeOn(Schedulers.io())
+ .subscribe(mutableGroupLiveData::postValue)
+
+ private var subscriptionsDisposable = Flowable
+ .combineLatest(subscriptionManager.subscriptions(), feedDatabaseManager.subscriptionIdsForGroup(groupId),
+ BiFunction { t1: List, t2: List -> t1 to t2.toSet() })
+ .subscribeOn(Schedulers.io())
+ .subscribe(mutableSubscriptionsLiveData::postValue)
+
+ override fun onCleared() {
+ super.onCleared()
+ actionProcessingDisposable?.dispose()
+ subscriptionsDisposable.dispose()
+ feedGroupDisposable.dispose()
+ }
+
+ fun createGroup(name: String, selectedIcon: FeedGroupIcon, selectedSubscriptions: Set) {
+ doAction(feedDatabaseManager.createGroup(name, selectedIcon)
+ .flatMapCompletable {
+ feedDatabaseManager.updateSubscriptionsForGroup(it, selectedSubscriptions.toList())
+ })
+ }
+
+ fun updateGroup(name: String, selectedIcon: FeedGroupIcon, selectedSubscriptions: Set, sortOrder: Long) {
+ doAction(feedDatabaseManager.updateSubscriptionsForGroup(groupId, selectedSubscriptions.toList())
+ .andThen(feedDatabaseManager.updateGroup(FeedGroupEntity(groupId, name, selectedIcon, sortOrder))))
+ }
+
+ fun deleteGroup() {
+ doAction(feedDatabaseManager.deleteGroup(groupId))
+ }
+
+ private fun doAction(completable: Completable) {
+ if (actionProcessingDisposable == null) {
+ mutableDialogEventLiveData.value = DialogEvent.ProcessingEvent
+
+ actionProcessingDisposable = completable
+ .subscribeOn(Schedulers.io())
+ .subscribe { mutableDialogEventLiveData.postValue(DialogEvent.SuccessEvent) }
+ }
+ }
+
+ sealed class DialogEvent {
+ object ProcessingEvent : DialogEvent()
+ object SuccessEvent : DialogEvent()
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/dialog/FeedGroupReorderDialog.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/dialog/FeedGroupReorderDialog.kt
new file mode 100644
index 000000000..17ee89c87
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/local/subscription/dialog/FeedGroupReorderDialog.kt
@@ -0,0 +1,109 @@
+package org.schabi.newpipe.local.subscription.dialog
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.fragment.app.DialogFragment
+import androidx.lifecycle.Observer
+import androidx.lifecycle.ViewModelProviders
+import androidx.recyclerview.widget.ItemTouchHelper
+import androidx.recyclerview.widget.ItemTouchHelper.SimpleCallback
+import androidx.recyclerview.widget.LinearLayoutManager
+import androidx.recyclerview.widget.RecyclerView
+import com.xwray.groupie.GroupAdapter
+import com.xwray.groupie.TouchCallback
+import com.xwray.groupie.kotlinandroidextensions.GroupieViewHolder
+import icepick.Icepick
+import icepick.State
+import kotlinx.android.synthetic.main.dialog_feed_group_reorder.*
+import org.schabi.newpipe.R
+import org.schabi.newpipe.database.feed.model.FeedGroupEntity
+import org.schabi.newpipe.local.subscription.dialog.FeedGroupReorderDialogViewModel.DialogEvent.*
+import org.schabi.newpipe.local.subscription.item.FeedGroupReorderItem
+import org.schabi.newpipe.util.ThemeHelper
+import java.util.*
+import kotlin.collections.ArrayList
+
+class FeedGroupReorderDialog : DialogFragment() {
+ private lateinit var viewModel: FeedGroupReorderDialogViewModel
+
+ @State @JvmField var groupOrderedIdList = ArrayList()
+ private val groupAdapter = GroupAdapter()
+ private val itemTouchHelper = ItemTouchHelper(getItemTouchCallback())
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ Icepick.restoreInstanceState(this, savedInstanceState)
+
+ setStyle(STYLE_NO_TITLE, ThemeHelper.getMinWidthDialogTheme(requireContext()))
+ }
+
+ override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
+ return inflater.inflate(R.layout.dialog_feed_group_reorder, container)
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ viewModel = ViewModelProviders.of(this).get(FeedGroupReorderDialogViewModel::class.java)
+ viewModel.groupsLiveData.observe(viewLifecycleOwner, Observer(::handleGroups))
+ viewModel.dialogEventLiveData.observe(viewLifecycleOwner, Observer {
+ when (it) {
+ ProcessingEvent -> disableInput()
+ SuccessEvent -> dismiss()
+ }
+ })
+
+ feed_groups_list.layoutManager = LinearLayoutManager(requireContext())
+ feed_groups_list.adapter = groupAdapter
+ itemTouchHelper.attachToRecyclerView(feed_groups_list)
+
+ confirm_button.setOnClickListener {
+ viewModel.updateOrder(groupOrderedIdList)
+ }
+ }
+
+ override fun onSaveInstanceState(outState: Bundle) {
+ super.onSaveInstanceState(outState)
+ Icepick.saveInstanceState(this, outState)
+ }
+
+ private fun handleGroups(list: List) {
+ val groupList: List
+
+ if (groupOrderedIdList.isEmpty()) {
+ groupList = list
+ groupOrderedIdList.addAll(groupList.map { it.uid })
+ } else {
+ groupList = list.sortedBy { groupOrderedIdList.indexOf(it.uid) }
+ }
+
+ groupAdapter.update(groupList.map { FeedGroupReorderItem(it, itemTouchHelper) })
+ }
+
+ private fun disableInput() {
+ confirm_button?.isEnabled = false
+ isCancelable = false
+ }
+
+ private fun getItemTouchCallback(): SimpleCallback {
+ return object : TouchCallback() {
+
+ override fun onMove(recyclerView: RecyclerView, source: RecyclerView.ViewHolder,
+ target: RecyclerView.ViewHolder): Boolean {
+ val sourceIndex = source.adapterPosition
+ val targetIndex = target.adapterPosition
+
+ groupAdapter.notifyItemMoved(sourceIndex, targetIndex)
+ Collections.swap(groupOrderedIdList, sourceIndex, targetIndex)
+
+ return true
+ }
+
+ override fun isLongPressDragEnabled(): Boolean = false
+ override fun isItemViewSwipeEnabled(): Boolean = false
+ override fun onSwiped(viewHolder: RecyclerView.ViewHolder, swipeDir: Int) {}
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/dialog/FeedGroupReorderDialogViewModel.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/dialog/FeedGroupReorderDialogViewModel.kt
new file mode 100644
index 000000000..8ef5bb55c
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/local/subscription/dialog/FeedGroupReorderDialogViewModel.kt
@@ -0,0 +1,52 @@
+package org.schabi.newpipe.local.subscription.dialog
+
+import android.app.Application
+import androidx.lifecycle.AndroidViewModel
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import io.reactivex.Completable
+import io.reactivex.disposables.Disposable
+import io.reactivex.schedulers.Schedulers
+import org.schabi.newpipe.database.feed.model.FeedGroupEntity
+import org.schabi.newpipe.local.feed.FeedDatabaseManager
+
+class FeedGroupReorderDialogViewModel(application: Application) : AndroidViewModel(application) {
+ private var feedDatabaseManager: FeedDatabaseManager = FeedDatabaseManager(application)
+
+ private val mutableGroupsLiveData = MutableLiveData>()
+ private val mutableDialogEventLiveData = MutableLiveData()
+ val groupsLiveData: LiveData> = mutableGroupsLiveData
+ val dialogEventLiveData: LiveData = mutableDialogEventLiveData
+
+ private var actionProcessingDisposable: Disposable? = null
+
+ private var groupsDisposable = feedDatabaseManager.groups()
+ .limit(1)
+ .subscribeOn(Schedulers.io())
+ .subscribe(mutableGroupsLiveData::postValue)
+
+ override fun onCleared() {
+ super.onCleared()
+ actionProcessingDisposable?.dispose()
+ groupsDisposable.dispose()
+ }
+
+ fun updateOrder(groupIdList: List) {
+ doAction(feedDatabaseManager.updateGroupsOrder(groupIdList))
+ }
+
+ private fun doAction(completable: Completable) {
+ if (actionProcessingDisposable == null) {
+ mutableDialogEventLiveData.value = DialogEvent.ProcessingEvent
+
+ actionProcessingDisposable = completable
+ .subscribeOn(Schedulers.io())
+ .subscribe { mutableDialogEventLiveData.postValue(DialogEvent.SuccessEvent) }
+ }
+ }
+
+ sealed class DialogEvent {
+ object ProcessingEvent : DialogEvent()
+ object SuccessEvent : DialogEvent()
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/item/ChannelItem.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/item/ChannelItem.kt
new file mode 100644
index 000000000..928f93a47
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/local/subscription/item/ChannelItem.kt
@@ -0,0 +1,65 @@
+package org.schabi.newpipe.local.subscription.item
+
+import android.content.Context
+import com.nostra13.universalimageloader.core.ImageLoader
+import com.xwray.groupie.kotlinandroidextensions.Item
+import com.xwray.groupie.kotlinandroidextensions.GroupieViewHolder
+import kotlinx.android.synthetic.main.list_channel_item.*
+import org.schabi.newpipe.R
+import org.schabi.newpipe.extractor.channel.ChannelInfoItem
+import org.schabi.newpipe.util.ImageDisplayConstants
+import org.schabi.newpipe.util.Localization
+import org.schabi.newpipe.util.OnClickGesture
+
+
+class ChannelItem(
+ private val infoItem: ChannelInfoItem,
+ private val subscriptionId: Long = -1L,
+ var itemVersion: ItemVersion = ItemVersion.NORMAL,
+ var gesturesListener: OnClickGesture? = null
+) : Item() {
+
+ override fun getId(): Long = if (subscriptionId == -1L) super.getId() else subscriptionId
+
+ enum class ItemVersion { NORMAL, MINI, GRID }
+
+ override fun getLayout(): Int = when (itemVersion) {
+ ItemVersion.NORMAL -> R.layout.list_channel_item
+ ItemVersion.MINI -> R.layout.list_channel_mini_item
+ ItemVersion.GRID -> R.layout.list_channel_grid_item
+ }
+
+ override fun bind(viewHolder: GroupieViewHolder, position: Int) {
+ viewHolder.itemTitleView.text = infoItem.name
+ viewHolder.itemAdditionalDetails.text = getDetailLine(viewHolder.root.context)
+ if (itemVersion == ItemVersion.NORMAL) viewHolder.itemChannelDescriptionView.text = infoItem.description
+
+ ImageLoader.getInstance().displayImage(infoItem.thumbnailUrl, viewHolder.itemThumbnailView,
+ ImageDisplayConstants.DISPLAY_THUMBNAIL_OPTIONS)
+
+ gesturesListener?.run {
+ viewHolder.containerView.setOnClickListener { selected(infoItem) }
+ viewHolder.containerView.setOnLongClickListener { held(infoItem); true }
+ }
+ }
+
+ private fun getDetailLine(context: Context): String {
+ var details = if (infoItem.subscriberCount >= 0) {
+ Localization.shortSubscriberCount(context, infoItem.subscriberCount)
+ } else {
+ context.getString(R.string.subscribers_count_not_available)
+ }
+
+ if (itemVersion == ItemVersion.NORMAL) {
+ if (infoItem.streamCount >= 0) {
+ val formattedVideoAmount = Localization.localizeStreamCount(context, infoItem.streamCount)
+ details = Localization.concatenateStrings(details, formattedVideoAmount)
+ }
+ }
+ return details
+ }
+
+ override fun getSpanSize(spanCount: Int, position: Int): Int {
+ return if (itemVersion == ItemVersion.GRID) 1 else spanCount
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/item/EmptyPlaceholderItem.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/item/EmptyPlaceholderItem.kt
new file mode 100644
index 000000000..0c651dc69
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/local/subscription/item/EmptyPlaceholderItem.kt
@@ -0,0 +1,10 @@
+package org.schabi.newpipe.local.subscription.item
+
+import com.xwray.groupie.kotlinandroidextensions.Item
+import com.xwray.groupie.kotlinandroidextensions.GroupieViewHolder
+import org.schabi.newpipe.R
+
+class EmptyPlaceholderItem : Item() {
+ override fun getLayout(): Int = R.layout.list_empty_view
+ override fun bind(viewHolder: GroupieViewHolder, position: Int) {}
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/item/FeedGroupAddItem.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/item/FeedGroupAddItem.kt
new file mode 100644
index 000000000..309f82bbc
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/local/subscription/item/FeedGroupAddItem.kt
@@ -0,0 +1,10 @@
+package org.schabi.newpipe.local.subscription.item
+
+import com.xwray.groupie.kotlinandroidextensions.Item
+import com.xwray.groupie.kotlinandroidextensions.GroupieViewHolder
+import org.schabi.newpipe.R
+
+class FeedGroupAddItem : Item() {
+ override fun getLayout(): Int = R.layout.feed_group_add_new_item
+ override fun bind(viewHolder: GroupieViewHolder, position: Int) {}
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/item/FeedGroupCardItem.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/item/FeedGroupCardItem.kt
new file mode 100644
index 000000000..a757dc5b3
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/local/subscription/item/FeedGroupCardItem.kt
@@ -0,0 +1,30 @@
+package org.schabi.newpipe.local.subscription.item
+
+import com.xwray.groupie.kotlinandroidextensions.Item
+import com.xwray.groupie.kotlinandroidextensions.GroupieViewHolder
+import kotlinx.android.synthetic.main.feed_group_card_item.*
+import org.schabi.newpipe.R
+import org.schabi.newpipe.database.feed.model.FeedGroupEntity
+import org.schabi.newpipe.local.subscription.FeedGroupIcon
+
+data class FeedGroupCardItem(
+ val groupId: Long = FeedGroupEntity.GROUP_ALL_ID,
+ val name: String,
+ val icon: FeedGroupIcon
+) : Item() {
+ constructor (feedGroupEntity: FeedGroupEntity) : this(feedGroupEntity.uid, feedGroupEntity.name, feedGroupEntity.icon)
+
+ override fun getId(): Long {
+ return when (groupId) {
+ FeedGroupEntity.GROUP_ALL_ID -> super.getId()
+ else -> groupId
+ }
+ }
+
+ override fun getLayout(): Int = R.layout.feed_group_card_item
+
+ override fun bind(viewHolder: GroupieViewHolder, position: Int) {
+ viewHolder.title.text = name
+ viewHolder.icon.setImageResource(icon.getDrawableRes(viewHolder.containerView.context))
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/item/FeedGroupCarouselItem.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/item/FeedGroupCarouselItem.kt
new file mode 100644
index 000000000..bde3c604a
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/local/subscription/item/FeedGroupCarouselItem.kt
@@ -0,0 +1,57 @@
+package org.schabi.newpipe.local.subscription.item
+
+import android.content.Context
+import android.os.Parcelable
+import android.view.View
+import androidx.recyclerview.widget.LinearLayoutManager
+import androidx.recyclerview.widget.RecyclerView
+import com.xwray.groupie.GroupAdapter
+import com.xwray.groupie.kotlinandroidextensions.Item
+import com.xwray.groupie.kotlinandroidextensions.GroupieViewHolder
+import kotlinx.android.synthetic.main.feed_item_carousel.*
+import org.schabi.newpipe.R
+import org.schabi.newpipe.local.subscription.decoration.FeedGroupCarouselDecoration
+
+class FeedGroupCarouselItem(context: Context, private val carouselAdapter: GroupAdapter) : Item() {
+ private val feedGroupCarouselDecoration = FeedGroupCarouselDecoration(context)
+
+ private var linearLayoutManager: LinearLayoutManager? = null
+ private var listState: Parcelable? = null
+
+ override fun getLayout() = R.layout.feed_item_carousel
+
+ fun onSaveInstanceState(): Parcelable? {
+ listState = linearLayoutManager?.onSaveInstanceState()
+ return listState
+ }
+
+ fun onRestoreInstanceState(state: Parcelable?) {
+ linearLayoutManager?.onRestoreInstanceState(state)
+ listState = state
+ }
+
+ override fun createViewHolder(itemView: View): GroupieViewHolder {
+ val viewHolder = super.createViewHolder(itemView)
+
+ linearLayoutManager = LinearLayoutManager(itemView.context, RecyclerView.HORIZONTAL, false)
+
+ viewHolder.recycler_view.apply {
+ layoutManager = linearLayoutManager
+ adapter = carouselAdapter
+ addItemDecoration(feedGroupCarouselDecoration)
+ }
+
+ return viewHolder
+ }
+
+ override fun bind(viewHolder: GroupieViewHolder, position: Int) {
+ viewHolder.recycler_view.apply { adapter = carouselAdapter }
+ linearLayoutManager?.onRestoreInstanceState(listState)
+ }
+
+ override fun unbind(viewHolder: GroupieViewHolder) {
+ super.unbind(viewHolder)
+
+ listState = linearLayoutManager?.onSaveInstanceState()
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/item/FeedGroupReorderItem.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/item/FeedGroupReorderItem.kt
new file mode 100644
index 000000000..cf010af7f
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/local/subscription/item/FeedGroupReorderItem.kt
@@ -0,0 +1,48 @@
+package org.schabi.newpipe.local.subscription.item
+
+import android.view.MotionEvent
+import androidx.recyclerview.widget.ItemTouchHelper
+import androidx.recyclerview.widget.ItemTouchHelper.DOWN
+import androidx.recyclerview.widget.ItemTouchHelper.UP
+import com.xwray.groupie.kotlinandroidextensions.GroupieViewHolder
+import com.xwray.groupie.kotlinandroidextensions.Item
+import kotlinx.android.synthetic.main.feed_group_reorder_item.*
+import org.schabi.newpipe.R
+import org.schabi.newpipe.database.feed.model.FeedGroupEntity
+import org.schabi.newpipe.local.subscription.FeedGroupIcon
+
+data class FeedGroupReorderItem(
+ val groupId: Long = FeedGroupEntity.GROUP_ALL_ID,
+ val name: String,
+ val icon: FeedGroupIcon,
+ val dragCallback: ItemTouchHelper
+) : Item() {
+ constructor (feedGroupEntity: FeedGroupEntity, dragCallback: ItemTouchHelper)
+ : this(feedGroupEntity.uid, feedGroupEntity.name, feedGroupEntity.icon, dragCallback)
+
+ override fun getId(): Long {
+ return when (groupId) {
+ FeedGroupEntity.GROUP_ALL_ID -> super.getId()
+ else -> groupId
+ }
+ }
+
+ override fun getLayout(): Int = R.layout.feed_group_reorder_item
+
+ override fun bind(viewHolder: GroupieViewHolder, position: Int) {
+ viewHolder.group_name.text = name
+ viewHolder.group_icon.setImageResource(icon.getDrawableRes(viewHolder.containerView.context))
+ viewHolder.handle.setOnTouchListener { _, event ->
+ if (event.actionMasked == MotionEvent.ACTION_DOWN) {
+ dragCallback.startDrag(viewHolder)
+ return@setOnTouchListener true
+ }
+
+ false
+ }
+ }
+
+ override fun getDragDirs(): Int {
+ return UP or DOWN
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/item/FeedImportExportItem.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/item/FeedImportExportItem.kt
new file mode 100644
index 000000000..ab47564ce
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/local/subscription/item/FeedImportExportItem.kt
@@ -0,0 +1,116 @@
+package org.schabi.newpipe.local.subscription.item
+
+import android.graphics.Color
+import android.graphics.PorterDuff
+import android.view.View
+import android.view.ViewGroup
+import android.widget.ImageView
+import android.widget.TextView
+import androidx.annotation.DrawableRes
+import com.xwray.groupie.kotlinandroidextensions.GroupieViewHolder
+import com.xwray.groupie.kotlinandroidextensions.Item
+import kotlinx.android.synthetic.main.feed_import_export_group.*
+import org.schabi.newpipe.R
+import org.schabi.newpipe.extractor.NewPipe
+import org.schabi.newpipe.extractor.exceptions.ExtractionException
+import org.schabi.newpipe.util.AnimationUtils
+import org.schabi.newpipe.util.ServiceHelper
+import org.schabi.newpipe.util.ThemeHelper
+import org.schabi.newpipe.views.CollapsibleView
+
+class FeedImportExportItem(
+ val onImportPreviousSelected: () -> Unit,
+ val onImportFromServiceSelected: (Int) -> Unit,
+ val onExportSelected: () -> Unit,
+ var isExpanded: Boolean = false
+) : Item() {
+ companion object {
+ const val REFRESH_EXPANDED_STATUS = 123
+ }
+
+ override fun bind(viewHolder: GroupieViewHolder, position: Int, payloads: MutableList) {
+ if (payloads.contains(REFRESH_EXPANDED_STATUS)) {
+ viewHolder.import_export_options.apply { if (isExpanded) expand() else collapse() }
+ return
+ }
+
+ super.bind(viewHolder, position, payloads)
+ }
+
+ override fun getLayout(): Int = R.layout.feed_import_export_group
+
+ override fun bind(viewHolder: GroupieViewHolder, position: Int) {
+ if (viewHolder.import_from_options.childCount == 0) setupImportFromItems(viewHolder.import_from_options)
+ if (viewHolder.export_to_options.childCount == 0) setupExportToItems(viewHolder.export_to_options)
+
+ expandIconListener?.let { viewHolder.import_export_options.removeListener(it) }
+ expandIconListener = CollapsibleView.StateListener { newState ->
+ AnimationUtils.animateRotation(viewHolder.import_export_expand_icon,
+ 250, if (newState == CollapsibleView.COLLAPSED) 0 else 180)
+ }
+
+ viewHolder.import_export_options.currentState = if (isExpanded) CollapsibleView.EXPANDED else CollapsibleView.COLLAPSED
+ viewHolder.import_export_expand_icon.rotation = if (isExpanded) 180F else 0F
+ viewHolder.import_export_options.ready()
+
+ viewHolder.import_export_options.addListener(expandIconListener)
+ viewHolder.import_export.setOnClickListener {
+ viewHolder.import_export_options.switchState()
+ isExpanded = viewHolder.import_export_options.currentState == CollapsibleView.EXPANDED
+ }
+ }
+
+ override fun unbind(viewHolder: GroupieViewHolder) {
+ super.unbind(viewHolder)
+ expandIconListener?.let { viewHolder.import_export_options.removeListener(it) }
+ expandIconListener = null
+ }
+
+ private var expandIconListener: CollapsibleView.StateListener? = null
+
+ private fun addItemView(title: String, @DrawableRes icon: Int, container: ViewGroup): View {
+ val itemRoot = View.inflate(container.context, R.layout.subscription_import_export_item, null)
+ val titleView = itemRoot.findViewById(android.R.id.text1)
+ val iconView = itemRoot.findViewById(android.R.id.icon1)
+
+ titleView.text = title
+ iconView.setImageResource(icon)
+
+ container.addView(itemRoot)
+ return itemRoot
+ }
+
+ private fun setupImportFromItems(listHolder: ViewGroup) {
+ val previousBackupItem = addItemView(listHolder.context.getString(R.string.previous_export),
+ ThemeHelper.resolveResourceIdFromAttr(listHolder.context, R.attr.ic_backup), listHolder)
+ previousBackupItem.setOnClickListener { onImportPreviousSelected() }
+
+ val iconColor = if (ThemeHelper.isLightThemeSelected(listHolder.context)) Color.BLACK else Color.WHITE
+ val services = listHolder.context.resources.getStringArray(R.array.service_list)
+ for (serviceName in services) {
+ try {
+ val service = NewPipe.getService(serviceName)
+
+ val subscriptionExtractor = service.subscriptionExtractor ?: continue
+
+ val supportedSources = subscriptionExtractor.supportedSources
+ if (supportedSources.isEmpty()) continue
+
+ val itemView = addItemView(serviceName, ServiceHelper.getIcon(service.serviceId), listHolder)
+ val iconView = itemView.findViewById(android.R.id.icon1)
+ iconView.setColorFilter(iconColor, PorterDuff.Mode.SRC_IN)
+
+ itemView.setOnClickListener { onImportFromServiceSelected(service.serviceId) }
+ } catch (e: ExtractionException) {
+ throw RuntimeException("Services array contains an entry that it's not a valid service name ($serviceName)", e)
+ }
+
+ }
+ }
+
+ private fun setupExportToItems(listHolder: ViewGroup) {
+ val previousBackupItem = addItemView(listHolder.context.getString(R.string.file),
+ ThemeHelper.resolveResourceIdFromAttr(listHolder.context, R.attr.ic_save), listHolder)
+ previousBackupItem.setOnClickListener { onExportSelected() }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/item/HeaderItem.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/item/HeaderItem.kt
new file mode 100644
index 000000000..367605f46
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/local/subscription/item/HeaderItem.kt
@@ -0,0 +1,19 @@
+package org.schabi.newpipe.local.subscription.item
+
+import android.view.View.OnClickListener
+import com.xwray.groupie.kotlinandroidextensions.Item
+import com.xwray.groupie.kotlinandroidextensions.GroupieViewHolder
+import kotlinx.android.synthetic.main.header_item.*
+import org.schabi.newpipe.R
+
+class HeaderItem(val title: String, private val onClickListener: (() -> Unit)? = null) : Item() {
+
+ override fun getLayout(): Int = R.layout.header_item
+
+ override fun bind(viewHolder: GroupieViewHolder, position: Int) {
+ viewHolder.header_title.text = title
+
+ val listener: OnClickListener? = if (onClickListener != null) OnClickListener { onClickListener.invoke() } else null
+ viewHolder.root.setOnClickListener(listener)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/item/HeaderWithMenuItem.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/item/HeaderWithMenuItem.kt
new file mode 100644
index 000000000..5ffdfe7c1
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/local/subscription/item/HeaderWithMenuItem.kt
@@ -0,0 +1,48 @@
+package org.schabi.newpipe.local.subscription.item
+
+import android.view.View.*
+import androidx.annotation.DrawableRes
+import com.xwray.groupie.kotlinandroidextensions.GroupieViewHolder
+import com.xwray.groupie.kotlinandroidextensions.Item
+import kotlinx.android.synthetic.main.header_with_menu_item.*
+import org.schabi.newpipe.R
+
+class HeaderWithMenuItem(
+ val title: String,
+ @DrawableRes val itemIcon: Int = 0,
+ private val onClickListener: (() -> Unit)? = null,
+ private val menuItemOnClickListener: (() -> Unit)? = null
+) : Item() {
+ companion object {
+ const val PAYLOAD_SHOW_MENU_ITEM = 1
+ const val PAYLOAD_HIDE_MENU_ITEM = 2
+ }
+
+ override fun getLayout(): Int = R.layout.header_with_menu_item
+
+
+ override fun bind(viewHolder: GroupieViewHolder, position: Int, payloads: MutableList) {
+ if (payloads.contains(PAYLOAD_SHOW_MENU_ITEM)) {
+ viewHolder.header_menu_item.visibility = VISIBLE
+ return
+ } else if (payloads.contains(PAYLOAD_HIDE_MENU_ITEM)) {
+ viewHolder.header_menu_item.visibility = GONE
+ return
+ }
+
+ super.bind(viewHolder, position, payloads)
+ }
+
+ override fun bind(viewHolder: GroupieViewHolder, position: Int) {
+ viewHolder.header_title.text = title
+ viewHolder.header_menu_item.setImageResource(itemIcon)
+
+ val listener: OnClickListener? =
+ onClickListener?.let { OnClickListener { onClickListener.invoke() } }
+ viewHolder.root.setOnClickListener(listener)
+
+ val menuItemListener: OnClickListener? =
+ menuItemOnClickListener?.let { OnClickListener { menuItemOnClickListener.invoke() } }
+ viewHolder.header_menu_item.setOnClickListener(menuItemListener)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/item/PickerIconItem.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/item/PickerIconItem.kt
new file mode 100644
index 000000000..fedec9880
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/local/subscription/item/PickerIconItem.kt
@@ -0,0 +1,19 @@
+package org.schabi.newpipe.local.subscription.item
+
+import android.content.Context
+import androidx.annotation.DrawableRes
+import com.xwray.groupie.kotlinandroidextensions.Item
+import com.xwray.groupie.kotlinandroidextensions.GroupieViewHolder
+import kotlinx.android.synthetic.main.picker_icon_item.*
+import org.schabi.newpipe.R
+import org.schabi.newpipe.local.subscription.FeedGroupIcon
+
+class PickerIconItem(context: Context, val icon: FeedGroupIcon) : Item() {
+ @DrawableRes val iconRes: Int = icon.getDrawableRes(context)
+
+ override fun getLayout(): Int = R.layout.picker_icon_item
+
+ override fun bind(viewHolder: GroupieViewHolder, position: Int) {
+ viewHolder.icon_view.setImageResource(iconRes)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/item/PickerSubscriptionItem.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/item/PickerSubscriptionItem.kt
new file mode 100644
index 000000000..21c74b09f
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/local/subscription/item/PickerSubscriptionItem.kt
@@ -0,0 +1,51 @@
+package org.schabi.newpipe.local.subscription.item
+
+import android.view.View
+import com.nostra13.universalimageloader.core.DisplayImageOptions
+import com.nostra13.universalimageloader.core.ImageLoader
+import com.xwray.groupie.kotlinandroidextensions.Item
+import com.xwray.groupie.kotlinandroidextensions.GroupieViewHolder
+import kotlinx.android.synthetic.main.picker_subscription_item.*
+import org.schabi.newpipe.R
+import org.schabi.newpipe.database.subscription.SubscriptionEntity
+import org.schabi.newpipe.util.AnimationUtils
+import org.schabi.newpipe.util.AnimationUtils.animateView
+import org.schabi.newpipe.util.ImageDisplayConstants
+
+data class PickerSubscriptionItem(val subscriptionEntity: SubscriptionEntity, var isSelected: Boolean = false) : Item() {
+ companion object {
+ const val UPDATE_SELECTED = 123
+
+ val IMAGE_LOADING_OPTIONS: DisplayImageOptions = ImageDisplayConstants.DISPLAY_AVATAR_OPTIONS
+ }
+
+ override fun getLayout(): Int = R.layout.picker_subscription_item
+
+ override fun bind(viewHolder: GroupieViewHolder, position: Int, payloads: MutableList) {
+ if (payloads.contains(UPDATE_SELECTED)) {
+ animateView(viewHolder.selected_highlight, AnimationUtils.Type.LIGHT_SCALE_AND_ALPHA, isSelected, 150)
+ return
+ }
+
+ super.bind(viewHolder, position, payloads)
+ }
+
+ override fun bind(viewHolder: GroupieViewHolder, position: Int) {
+ ImageLoader.getInstance().displayImage(subscriptionEntity.avatarUrl, viewHolder.thumbnail_view, IMAGE_LOADING_OPTIONS)
+
+ viewHolder.title_view.text = subscriptionEntity.name
+ viewHolder.selected_highlight.visibility = if (isSelected) View.VISIBLE else View.GONE
+ }
+
+ override fun unbind(viewHolder: GroupieViewHolder) {
+ super.unbind(viewHolder)
+
+ viewHolder.selected_highlight.animate().setListener(null).cancel()
+ viewHolder.selected_highlight.visibility = View.GONE
+ viewHolder.selected_highlight.alpha = 1F
+ }
+
+ override fun getId(): Long {
+ return subscriptionEntity.uid
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/services/BaseImportExportService.java b/app/src/main/java/org/schabi/newpipe/local/subscription/services/BaseImportExportService.java
index 6b607cdca..e970ebfa4 100644
--- a/app/src/main/java/org/schabi/newpipe/local/subscription/services/BaseImportExportService.java
+++ b/app/src/main/java/org/schabi/newpipe/local/subscription/services/BaseImportExportService.java
@@ -34,10 +34,9 @@ import android.widget.Toast;
import org.reactivestreams.Publisher;
import org.schabi.newpipe.R;
import org.schabi.newpipe.extractor.subscription.SubscriptionExtractor;
+import org.schabi.newpipe.local.subscription.SubscriptionManager;
import org.schabi.newpipe.report.ErrorActivity;
import org.schabi.newpipe.report.UserAction;
-import org.schabi.newpipe.local.subscription.ImportExportEventListener;
-import org.schabi.newpipe.local.subscription.SubscriptionService;
import java.io.FileNotFoundException;
import java.io.IOException;
@@ -57,7 +56,7 @@ public abstract class BaseImportExportService extends Service {
protected NotificationManagerCompat notificationManager;
protected NotificationCompat.Builder notificationBuilder;
- protected SubscriptionService subscriptionService;
+ protected SubscriptionManager subscriptionManager;
protected final CompositeDisposable disposables = new CompositeDisposable();
protected final PublishProcessor notificationUpdater = PublishProcessor.create();
@@ -70,7 +69,7 @@ public abstract class BaseImportExportService extends Service {
@Override
public void onCreate() {
super.onCreate();
- subscriptionService = SubscriptionService.getInstance(this);
+ subscriptionManager = new SubscriptionManager(this);
setupNotification();
}
diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/ImportExportEventListener.java b/app/src/main/java/org/schabi/newpipe/local/subscription/services/ImportExportEventListener.java
similarity index 87%
rename from app/src/main/java/org/schabi/newpipe/local/subscription/ImportExportEventListener.java
rename to app/src/main/java/org/schabi/newpipe/local/subscription/services/ImportExportEventListener.java
index 01c0427f3..788073ee5 100644
--- a/app/src/main/java/org/schabi/newpipe/local/subscription/ImportExportEventListener.java
+++ b/app/src/main/java/org/schabi/newpipe/local/subscription/services/ImportExportEventListener.java
@@ -1,4 +1,4 @@
-package org.schabi.newpipe.local.subscription;
+package org.schabi.newpipe.local.subscription.services;
public interface ImportExportEventListener {
/**
diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/ImportExportJsonHelper.java b/app/src/main/java/org/schabi/newpipe/local/subscription/services/ImportExportJsonHelper.java
similarity index 98%
rename from app/src/main/java/org/schabi/newpipe/local/subscription/ImportExportJsonHelper.java
rename to app/src/main/java/org/schabi/newpipe/local/subscription/services/ImportExportJsonHelper.java
index ebfff9fe2..5b5ebf702 100644
--- a/app/src/main/java/org/schabi/newpipe/local/subscription/ImportExportJsonHelper.java
+++ b/app/src/main/java/org/schabi/newpipe/local/subscription/services/ImportExportJsonHelper.java
@@ -17,7 +17,7 @@
* along with this program. If not, see .
*/
-package org.schabi.newpipe.local.subscription;
+package org.schabi.newpipe.local.subscription.services;
import androidx.annotation.Nullable;
diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/services/SubscriptionsExportService.java b/app/src/main/java/org/schabi/newpipe/local/subscription/services/SubscriptionsExportService.java
index 31cd4b603..358024574 100644
--- a/app/src/main/java/org/schabi/newpipe/local/subscription/services/SubscriptionsExportService.java
+++ b/app/src/main/java/org/schabi/newpipe/local/subscription/services/SubscriptionsExportService.java
@@ -29,7 +29,6 @@ import org.reactivestreams.Subscription;
import org.schabi.newpipe.R;
import org.schabi.newpipe.database.subscription.SubscriptionEntity;
import org.schabi.newpipe.extractor.subscription.SubscriptionItem;
-import org.schabi.newpipe.local.subscription.ImportExportJsonHelper;
import java.io.File;
import java.io.FileNotFoundException;
@@ -96,7 +95,7 @@ public class SubscriptionsExportService extends BaseImportExportService {
private void startExport() {
showToast(R.string.export_ongoing);
- subscriptionService.subscriptionTable()
+ subscriptionManager.subscriptionTable()
.getAll()
.take(1)
.map(subscriptionEntities -> {
diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/services/SubscriptionsImportService.java b/app/src/main/java/org/schabi/newpipe/local/subscription/services/SubscriptionsImportService.java
index 62c1dfeb9..0d2f3757f 100644
--- a/app/src/main/java/org/schabi/newpipe/local/subscription/services/SubscriptionsImportService.java
+++ b/app/src/main/java/org/schabi/newpipe/local/subscription/services/SubscriptionsImportService.java
@@ -33,7 +33,6 @@ import org.schabi.newpipe.database.subscription.SubscriptionEntity;
import org.schabi.newpipe.extractor.NewPipe;
import org.schabi.newpipe.extractor.channel.ChannelInfo;
import org.schabi.newpipe.extractor.subscription.SubscriptionItem;
-import org.schabi.newpipe.local.subscription.ImportExportJsonHelper;
import org.schabi.newpipe.util.Constants;
import org.schabi.newpipe.util.ExtractorHelper;
@@ -180,6 +179,7 @@ public class SubscriptionsImportService extends BaseImportExportService {
.observeOn(Schedulers.io())
.doOnNext(getNotificationsConsumer())
+
.buffer(BUFFER_COUNT_BEFORE_INSERT)
.map(upsertBatch())
@@ -204,6 +204,7 @@ public class SubscriptionsImportService extends BaseImportExportService {
@Override
public void onError(Throwable error) {
+ Log.e(TAG, "Got an error!", error);
handleError(error);
}
@@ -242,7 +243,7 @@ public class SubscriptionsImportService extends BaseImportExportService {
if (n.isOnNext()) infoList.add(n.getValue());
}
- return subscriptionService.upsertAll(infoList);
+ return subscriptionManager.upsertAll(infoList);
};
}
diff --git a/app/src/main/java/org/schabi/newpipe/report/UserAction.java b/app/src/main/java/org/schabi/newpipe/report/UserAction.java
index 2cca9305a..f4f3e31b6 100644
--- a/app/src/main/java/org/schabi/newpipe/report/UserAction.java
+++ b/app/src/main/java/org/schabi/newpipe/report/UserAction.java
@@ -16,6 +16,7 @@ public enum UserAction {
REQUESTED_PLAYLIST("requested playlist"),
REQUESTED_KIOSK("requested kiosk"),
REQUESTED_COMMENTS("requested comments"),
+ REQUESTED_FEED("requested feed"),
DELETE_FROM_HISTORY("delete from history"),
PLAY_STREAM("Play stream"),
DOWNLOAD_POSTPROCESSING("download post-processing"),
diff --git a/app/src/main/java/org/schabi/newpipe/settings/NewPipeSettings.java b/app/src/main/java/org/schabi/newpipe/settings/NewPipeSettings.java
index e0003ccaa..6c765dc3d 100644
--- a/app/src/main/java/org/schabi/newpipe/settings/NewPipeSettings.java
+++ b/app/src/main/java/org/schabi/newpipe/settings/NewPipeSettings.java
@@ -23,7 +23,7 @@ package org.schabi.newpipe.settings;
import android.content.Context;
import android.content.SharedPreferences;
import android.os.Environment;
-import android.preference.PreferenceManager;
+import androidx.preference.PreferenceManager;
import androidx.annotation.NonNull;
import org.schabi.newpipe.R;
diff --git a/app/src/main/java/org/schabi/newpipe/settings/SelectChannelFragment.java b/app/src/main/java/org/schabi/newpipe/settings/SelectChannelFragment.java
index 7064aec33..9ee12facc 100644
--- a/app/src/main/java/org/schabi/newpipe/settings/SelectChannelFragment.java
+++ b/app/src/main/java/org/schabi/newpipe/settings/SelectChannelFragment.java
@@ -18,9 +18,9 @@ import com.nostra13.universalimageloader.core.ImageLoader;
import org.schabi.newpipe.R;
import org.schabi.newpipe.database.subscription.SubscriptionEntity;
+import org.schabi.newpipe.local.subscription.SubscriptionManager;
import org.schabi.newpipe.report.ErrorActivity;
import org.schabi.newpipe.report.UserAction;
-import org.schabi.newpipe.local.subscription.SubscriptionService;
import java.util.List;
import java.util.Vector;
@@ -99,8 +99,8 @@ public class SelectChannelFragment extends DialogFragment {
emptyView.setVisibility(View.GONE);
- SubscriptionService subscriptionService = SubscriptionService.getInstance(getContext());
- subscriptionService.getSubscription().toObservable()
+ SubscriptionManager subscriptionManager = new SubscriptionManager(getContext());
+ subscriptionManager.subscriptions().toObservable()
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(getSubscriptionObserver());
diff --git a/app/src/main/java/org/schabi/newpipe/settings/custom/DurationListPreference.kt b/app/src/main/java/org/schabi/newpipe/settings/custom/DurationListPreference.kt
new file mode 100644
index 000000000..4bc59fcee
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/settings/custom/DurationListPreference.kt
@@ -0,0 +1,46 @@
+package org.schabi.newpipe.settings.custom
+
+import android.content.Context
+import android.util.AttributeSet
+import androidx.preference.ListPreference
+import org.schabi.newpipe.util.Localization
+
+/**
+ * An extension of a common ListPreference where it sets the duration values to human readable strings.
+ *
+ * The values in the entry values array will be interpreted as seconds. If the value of a specific position
+ * is less than or equals to zero, its original entry title will be used.
+ *
+ * If the entry values array have anything other than numbers in it, an exception will be raised.
+ */
+class DurationListPreference : ListPreference {
+ constructor(context: Context?, attrs: AttributeSet?, defStyleAttr: Int, defStyleRes: Int) : super(context, attrs, defStyleAttr, defStyleRes)
+ constructor(context: Context?, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr)
+ constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs)
+ constructor(context: Context?) : super(context)
+
+ override fun onAttached() {
+ super.onAttached()
+
+ val originalEntryTitles = entries
+ val originalEntryValues = entryValues
+ val newEntryTitles = arrayOfNulls(originalEntryValues.size)
+
+ for (i in originalEntryValues.indices) {
+ val currentDurationValue: Int
+ try {
+ currentDurationValue = (originalEntryValues[i] as String).toInt()
+ } catch (e: NumberFormatException) {
+ throw RuntimeException("Invalid number was set in the preference entry values array", e)
+ }
+
+ if (currentDurationValue <= 0) {
+ newEntryTitles[i] = originalEntryTitles[i]
+ } else {
+ newEntryTitles[i] = Localization.localizeDuration(context, currentDurationValue)
+ }
+ }
+
+ entries = newEntryTitles
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/schabi/newpipe/settings/tabs/Tab.java b/app/src/main/java/org/schabi/newpipe/settings/tabs/Tab.java
index cba3c4534..cc40298b9 100644
--- a/app/src/main/java/org/schabi/newpipe/settings/tabs/Tab.java
+++ b/app/src/main/java/org/schabi/newpipe/settings/tabs/Tab.java
@@ -218,7 +218,7 @@ public abstract class Tab {
@Override
public String getTabName(Context context) {
- return context.getString(R.string.fragment_whats_new);
+ return context.getString(R.string.fragment_feed_title);
}
@DrawableRes
diff --git a/app/src/main/java/org/schabi/newpipe/util/ConstantsKt.kt b/app/src/main/java/org/schabi/newpipe/util/ConstantsKt.kt
new file mode 100644
index 000000000..8d24cb04e
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/util/ConstantsKt.kt
@@ -0,0 +1,6 @@
+package org.schabi.newpipe.util
+
+/**
+ * Default duration when using throttle functions across the app, in milliseconds.
+ */
+const val DEFAULT_THROTTLE_TIMEOUT = 120L
diff --git a/app/src/main/java/org/schabi/newpipe/util/ExtractorHelper.java b/app/src/main/java/org/schabi/newpipe/util/ExtractorHelper.java
index 0cebe5af3..cf4477223 100644
--- a/app/src/main/java/org/schabi/newpipe/util/ExtractorHelper.java
+++ b/app/src/main/java/org/schabi/newpipe/util/ExtractorHelper.java
@@ -31,18 +31,23 @@ import org.schabi.newpipe.ReCaptchaActivity;
import org.schabi.newpipe.extractor.Info;
import org.schabi.newpipe.extractor.InfoItem;
import org.schabi.newpipe.extractor.ListExtractor.InfoItemsPage;
+import org.schabi.newpipe.extractor.ListInfo;
import org.schabi.newpipe.extractor.NewPipe;
-import org.schabi.newpipe.extractor.suggestion.SuggestionExtractor;
+import org.schabi.newpipe.extractor.StreamingService;
import org.schabi.newpipe.extractor.channel.ChannelInfo;
import org.schabi.newpipe.extractor.comments.CommentsInfo;
import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException;
import org.schabi.newpipe.extractor.exceptions.ParsingException;
import org.schabi.newpipe.extractor.exceptions.ReCaptchaException;
+import org.schabi.newpipe.extractor.feed.FeedExtractor;
+import org.schabi.newpipe.extractor.feed.FeedInfo;
import org.schabi.newpipe.extractor.kiosk.KioskInfo;
import org.schabi.newpipe.extractor.playlist.PlaylistInfo;
import org.schabi.newpipe.extractor.search.SearchInfo;
import org.schabi.newpipe.extractor.services.youtube.extractors.YoutubeStreamExtractor;
import org.schabi.newpipe.extractor.stream.StreamInfo;
+import org.schabi.newpipe.extractor.stream.StreamInfoItem;
+import org.schabi.newpipe.extractor.suggestion.SuggestionExtractor;
import org.schabi.newpipe.report.ErrorActivity;
import org.schabi.newpipe.report.UserAction;
@@ -131,6 +136,22 @@ public final class ExtractorHelper {
ChannelInfo.getMoreItems(NewPipe.getService(serviceId), url, nextStreamsUrl));
}
+ public static Single> getFeedInfoFallbackToChannelInfo(final int serviceId,
+ final String url) {
+ final Maybe> maybeFeedInfo = Maybe.fromCallable(() -> {
+ final StreamingService service = NewPipe.getService(serviceId);
+ final FeedExtractor feedExtractor = service.getFeedExtractor(url);
+
+ if (feedExtractor == null) {
+ return null;
+ }
+
+ return FeedInfo.getInfo(feedExtractor);
+ });
+
+ return maybeFeedInfo.switchIfEmpty(getChannelInfo(serviceId, url, true));
+ }
+
public static Single getCommentsInfo(final int serviceId,
final String url,
boolean forceLoad) {
diff --git a/app/src/main/java/org/schabi/newpipe/util/Localization.java b/app/src/main/java/org/schabi/newpipe/util/Localization.java
index 47b914bde..9c8fc25b8 100644
--- a/app/src/main/java/org/schabi/newpipe/util/Localization.java
+++ b/app/src/main/java/org/schabi/newpipe/util/Localization.java
@@ -213,6 +213,42 @@ public class Localization {
return output;
}
+ /**
+ * Localize an amount of seconds into a human readable string.
+ *
+ * The seconds will be converted to the closest whole time unit.
+ *
For example, 60 seconds would give "1 minute", 119 would also give "1 minute".
+ *
+ * @param context used to get plurals resources.
+ * @param durationInSecs an amount of seconds.
+ * @return duration in a human readable string.
+ */
+ @NonNull
+ public static String localizeDuration(Context context, int durationInSecs) {
+ if (durationInSecs < 0) {
+ throw new IllegalArgumentException("duration can not be negative");
+ }
+
+ final int days = (int) (durationInSecs / (24 * 60 * 60L)); /* greater than a day */
+ durationInSecs %= (24 * 60 * 60L);
+ final int hours = (int) (durationInSecs / (60 * 60L)); /* greater than an hour */
+ durationInSecs %= (60 * 60L);
+ final int minutes = (int) (durationInSecs / 60L);
+ final int seconds = (int) (durationInSecs % 60L);
+
+ final Resources resources = context.getResources();
+
+ if (days > 0) {
+ return resources.getQuantityString(R.plurals.days, days, days);
+ } else if (hours > 0) {
+ return resources.getQuantityString(R.plurals.hours, hours, hours);
+ } else if (minutes > 0) {
+ return resources.getQuantityString(R.plurals.minutes, minutes, minutes);
+ } else {
+ return resources.getQuantityString(R.plurals.seconds, seconds, seconds);
+ }
+ }
+
/*//////////////////////////////////////////////////////////////////////////
// Pretty Time
//////////////////////////////////////////////////////////////////////////*/
diff --git a/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java b/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java
index 98264e1bf..b6f73dac7 100644
--- a/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java
+++ b/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java
@@ -23,6 +23,7 @@ import org.schabi.newpipe.MainActivity;
import org.schabi.newpipe.R;
import org.schabi.newpipe.RouterActivity;
import org.schabi.newpipe.about.AboutActivity;
+import org.schabi.newpipe.database.feed.model.FeedGroupEntity;
import org.schabi.newpipe.download.DownloadActivity;
import org.schabi.newpipe.extractor.NewPipe;
import org.schabi.newpipe.extractor.StreamingService;
@@ -343,9 +344,13 @@ public class NavigationHelper {
.commit();
}
- public static void openWhatsNewFragment(FragmentManager fragmentManager) {
+ public static void openFeedFragment(FragmentManager fragmentManager) {
+ openFeedFragment(fragmentManager, FeedGroupEntity.GROUP_ALL_ID, null);
+ }
+
+ public static void openFeedFragment(FragmentManager fragmentManager, long groupId, @Nullable String groupName) {
defaultTransaction(fragmentManager)
- .replace(R.id.fragment_holder, new FeedFragment())
+ .replace(R.id.fragment_holder, FeedFragment.newInstance(groupId, groupName))
.addToBackStack(null)
.commit();
}
diff --git a/app/src/main/java/org/schabi/newpipe/util/ThemeHelper.java b/app/src/main/java/org/schabi/newpipe/util/ThemeHelper.java
index 661aa47c1..bd51919c7 100644
--- a/app/src/main/java/org/schabi/newpipe/util/ThemeHelper.java
+++ b/app/src/main/java/org/schabi/newpipe/util/ThemeHelper.java
@@ -99,6 +99,17 @@ public class ThemeHelper {
return isLightThemeSelected(context) ? R.style.LightDialogTheme : R.style.DarkDialogTheme;
}
+ /**
+ * Return a min-width dialog theme styled according to the (default) selected theme.
+ *
+ * @param context context to get the selected theme
+ * @return the dialog style (the default one)
+ */
+ @StyleRes
+ public static int getMinWidthDialogTheme(Context context) {
+ return isLightThemeSelected(context) ? R.style.LightDialogMinWidthTheme : R.style.DarkDialogMinWidthTheme;
+ }
+
/**
* Return the selected theme styled according to the serviceId.
*
diff --git a/app/src/main/res/drawable/dark_focused_selector.xml b/app/src/main/res/drawable/dark_focused_selector.xml
new file mode 100644
index 000000000..102f40d76
--- /dev/null
+++ b/app/src/main/res/drawable/dark_focused_selector.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/drawable/dashed_border_black.xml b/app/src/main/res/drawable/dashed_border_black.xml
new file mode 100644
index 000000000..b6bac6252
--- /dev/null
+++ b/app/src/main/res/drawable/dashed_border_black.xml
@@ -0,0 +1,8 @@
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/drawable/dashed_border_dark.xml b/app/src/main/res/drawable/dashed_border_dark.xml
new file mode 100644
index 000000000..5af152ecc
--- /dev/null
+++ b/app/src/main/res/drawable/dashed_border_dark.xml
@@ -0,0 +1,8 @@
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/drawable/dashed_border_light.xml b/app/src/main/res/drawable/dashed_border_light.xml
new file mode 100644
index 000000000..5d29112bd
--- /dev/null
+++ b/app/src/main/res/drawable/dashed_border_light.xml
@@ -0,0 +1,8 @@
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/drawable/ic_asterisk_black_24dp.xml b/app/src/main/res/drawable/ic_asterisk_black_24dp.xml
new file mode 100644
index 000000000..fa16cd5e8
--- /dev/null
+++ b/app/src/main/res/drawable/ic_asterisk_black_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/drawable/ic_asterisk_white_24dp.xml b/app/src/main/res/drawable/ic_asterisk_white_24dp.xml
new file mode 100644
index 000000000..bd487cb55
--- /dev/null
+++ b/app/src/main/res/drawable/ic_asterisk_white_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/drawable/ic_car_black_24dp.xml b/app/src/main/res/drawable/ic_car_black_24dp.xml
new file mode 100644
index 000000000..6aa8cdd82
--- /dev/null
+++ b/app/src/main/res/drawable/ic_car_black_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_car_white_24dp.xml b/app/src/main/res/drawable/ic_car_white_24dp.xml
new file mode 100644
index 000000000..7ad263933
--- /dev/null
+++ b/app/src/main/res/drawable/ic_car_white_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_computer_black_24dp.xml b/app/src/main/res/drawable/ic_computer_black_24dp.xml
new file mode 100644
index 000000000..b03d9c0ce
--- /dev/null
+++ b/app/src/main/res/drawable/ic_computer_black_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_computer_white_24dp.xml b/app/src/main/res/drawable/ic_computer_white_24dp.xml
new file mode 100644
index 000000000..c4bdad688
--- /dev/null
+++ b/app/src/main/res/drawable/ic_computer_white_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_edit_black_24dp.xml b/app/src/main/res/drawable/ic_edit_black_24dp.xml
new file mode 100644
index 000000000..43489826e
--- /dev/null
+++ b/app/src/main/res/drawable/ic_edit_black_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_edit_white_24dp.xml b/app/src/main/res/drawable/ic_edit_white_24dp.xml
new file mode 100644
index 000000000..88f94780f
--- /dev/null
+++ b/app/src/main/res/drawable/ic_edit_white_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_emoticon_black_24dp.xml b/app/src/main/res/drawable/ic_emoticon_black_24dp.xml
new file mode 100644
index 000000000..45f489d80
--- /dev/null
+++ b/app/src/main/res/drawable/ic_emoticon_black_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_emoticon_white_24dp.xml b/app/src/main/res/drawable/ic_emoticon_white_24dp.xml
new file mode 100644
index 000000000..89ca90fb5
--- /dev/null
+++ b/app/src/main/res/drawable/ic_emoticon_white_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_explore_black_24dp.xml b/app/src/main/res/drawable/ic_explore_black_24dp.xml
new file mode 100644
index 000000000..c898ed9a5
--- /dev/null
+++ b/app/src/main/res/drawable/ic_explore_black_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_explore_white_24dp.xml b/app/src/main/res/drawable/ic_explore_white_24dp.xml
new file mode 100644
index 000000000..65f2818a6
--- /dev/null
+++ b/app/src/main/res/drawable/ic_explore_white_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_fastfood_black_24dp.xml b/app/src/main/res/drawable/ic_fastfood_black_24dp.xml
new file mode 100644
index 000000000..fac047550
--- /dev/null
+++ b/app/src/main/res/drawable/ic_fastfood_black_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_fastfood_white_24dp.xml b/app/src/main/res/drawable/ic_fastfood_white_24dp.xml
new file mode 100644
index 000000000..39bbee49a
--- /dev/null
+++ b/app/src/main/res/drawable/ic_fastfood_white_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_fitness_black_24dp.xml b/app/src/main/res/drawable/ic_fitness_black_24dp.xml
new file mode 100644
index 000000000..40a1cf9c1
--- /dev/null
+++ b/app/src/main/res/drawable/ic_fitness_black_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_fitness_white_24dp.xml b/app/src/main/res/drawable/ic_fitness_white_24dp.xml
new file mode 100644
index 000000000..1b2d3b4be
--- /dev/null
+++ b/app/src/main/res/drawable/ic_fitness_white_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_heart_black_24dp.xml b/app/src/main/res/drawable/ic_heart_black_24dp.xml
new file mode 100644
index 000000000..25cb46e83
--- /dev/null
+++ b/app/src/main/res/drawable/ic_heart_black_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_heart_white_24dp.xml b/app/src/main/res/drawable/ic_heart_white_24dp.xml
new file mode 100644
index 000000000..02c6396ee
--- /dev/null
+++ b/app/src/main/res/drawable/ic_heart_white_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_help_black_24dp.xml b/app/src/main/res/drawable/ic_help_black_24dp.xml
new file mode 100644
index 000000000..1517747d0
--- /dev/null
+++ b/app/src/main/res/drawable/ic_help_black_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_help_white_24dp.xml b/app/src/main/res/drawable/ic_help_white_24dp.xml
new file mode 100644
index 000000000..d813b72b8
--- /dev/null
+++ b/app/src/main/res/drawable/ic_help_white_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_kids_black_24dp.xml b/app/src/main/res/drawable/ic_kids_black_24dp.xml
new file mode 100644
index 000000000..d1d8e01e7
--- /dev/null
+++ b/app/src/main/res/drawable/ic_kids_black_24dp.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_kids_white_24dp.xml b/app/src/main/res/drawable/ic_kids_white_24dp.xml
new file mode 100644
index 000000000..c5dda16c8
--- /dev/null
+++ b/app/src/main/res/drawable/ic_kids_white_24dp.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_megaphone_black_24dp.xml b/app/src/main/res/drawable/ic_megaphone_black_24dp.xml
new file mode 100644
index 000000000..21622c162
--- /dev/null
+++ b/app/src/main/res/drawable/ic_megaphone_black_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_megaphone_white_24dp.xml b/app/src/main/res/drawable/ic_megaphone_white_24dp.xml
new file mode 100644
index 000000000..90e6ff215
--- /dev/null
+++ b/app/src/main/res/drawable/ic_megaphone_white_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_mic_black_24dp.xml b/app/src/main/res/drawable/ic_mic_black_24dp.xml
new file mode 100644
index 000000000..25d8951a7
--- /dev/null
+++ b/app/src/main/res/drawable/ic_mic_black_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_mic_white_24dp.xml b/app/src/main/res/drawable/ic_mic_white_24dp.xml
new file mode 100644
index 000000000..36ee9ff81
--- /dev/null
+++ b/app/src/main/res/drawable/ic_mic_white_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_money_black_24dp.xml b/app/src/main/res/drawable/ic_money_black_24dp.xml
new file mode 100644
index 000000000..4019c2e46
--- /dev/null
+++ b/app/src/main/res/drawable/ic_money_black_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_money_white_24dp.xml b/app/src/main/res/drawable/ic_money_white_24dp.xml
new file mode 100644
index 000000000..2407a2b73
--- /dev/null
+++ b/app/src/main/res/drawable/ic_money_white_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_motorcycle_black_24dp.xml b/app/src/main/res/drawable/ic_motorcycle_black_24dp.xml
new file mode 100644
index 000000000..6009979dd
--- /dev/null
+++ b/app/src/main/res/drawable/ic_motorcycle_black_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_motorcycle_white_24dp.xml b/app/src/main/res/drawable/ic_motorcycle_white_24dp.xml
new file mode 100644
index 000000000..b94c29f8f
--- /dev/null
+++ b/app/src/main/res/drawable/ic_motorcycle_white_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_movie_black_24dp.xml b/app/src/main/res/drawable/ic_movie_black_24dp.xml
new file mode 100644
index 000000000..d70c00f00
--- /dev/null
+++ b/app/src/main/res/drawable/ic_movie_black_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_movie_white_24dp.xml b/app/src/main/res/drawable/ic_movie_white_24dp.xml
new file mode 100644
index 000000000..f73e76774
--- /dev/null
+++ b/app/src/main/res/drawable/ic_movie_white_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_music_note_black_24dp.xml b/app/src/main/res/drawable/ic_music_note_black_24dp.xml
new file mode 100644
index 000000000..698159295
--- /dev/null
+++ b/app/src/main/res/drawable/ic_music_note_black_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_music_note_white_24dp.xml b/app/src/main/res/drawable/ic_music_note_white_24dp.xml
new file mode 100644
index 000000000..1d38e6e22
--- /dev/null
+++ b/app/src/main/res/drawable/ic_music_note_white_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_people_black_24dp.xml b/app/src/main/res/drawable/ic_people_black_24dp.xml
new file mode 100644
index 000000000..d0fe31838
--- /dev/null
+++ b/app/src/main/res/drawable/ic_people_black_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_people_white_24dp.xml b/app/src/main/res/drawable/ic_people_white_24dp.xml
new file mode 100644
index 000000000..e6fa4c583
--- /dev/null
+++ b/app/src/main/res/drawable/ic_people_white_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_person_black_24dp.xml b/app/src/main/res/drawable/ic_person_black_24dp.xml
new file mode 100644
index 000000000..f0ff6a871
--- /dev/null
+++ b/app/src/main/res/drawable/ic_person_black_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_person_white_24dp.xml b/app/src/main/res/drawable/ic_person_white_24dp.xml
new file mode 100644
index 000000000..99f299963
--- /dev/null
+++ b/app/src/main/res/drawable/ic_person_white_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_pets_black_24dp.xml b/app/src/main/res/drawable/ic_pets_black_24dp.xml
new file mode 100644
index 000000000..b6247bd87
--- /dev/null
+++ b/app/src/main/res/drawable/ic_pets_black_24dp.xml
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_pets_white_24dp.xml b/app/src/main/res/drawable/ic_pets_white_24dp.xml
new file mode 100644
index 000000000..46724a33d
--- /dev/null
+++ b/app/src/main/res/drawable/ic_pets_white_24dp.xml
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_radio_black_24dp.xml b/app/src/main/res/drawable/ic_radio_black_24dp.xml
new file mode 100644
index 000000000..00da9101f
--- /dev/null
+++ b/app/src/main/res/drawable/ic_radio_black_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_radio_white_24dp.xml b/app/src/main/res/drawable/ic_radio_white_24dp.xml
new file mode 100644
index 000000000..df563ec1d
--- /dev/null
+++ b/app/src/main/res/drawable/ic_radio_white_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_refresh_black_24dp.xml b/app/src/main/res/drawable/ic_refresh_black_24dp.xml
new file mode 100644
index 000000000..8229a9a64
--- /dev/null
+++ b/app/src/main/res/drawable/ic_refresh_black_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_refresh_white_24dp.xml b/app/src/main/res/drawable/ic_refresh_white_24dp.xml
new file mode 100644
index 000000000..a8175c316
--- /dev/null
+++ b/app/src/main/res/drawable/ic_refresh_white_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_restaurant_black_24dp.xml b/app/src/main/res/drawable/ic_restaurant_black_24dp.xml
new file mode 100644
index 000000000..0a8c6bde9
--- /dev/null
+++ b/app/src/main/res/drawable/ic_restaurant_black_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_restaurant_white_24dp.xml b/app/src/main/res/drawable/ic_restaurant_white_24dp.xml
new file mode 100644
index 000000000..c81618bb7
--- /dev/null
+++ b/app/src/main/res/drawable/ic_restaurant_white_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_school_black_24dp.xml b/app/src/main/res/drawable/ic_school_black_24dp.xml
new file mode 100644
index 000000000..8f52f0dde
--- /dev/null
+++ b/app/src/main/res/drawable/ic_school_black_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_school_white_24dp.xml b/app/src/main/res/drawable/ic_school_white_24dp.xml
new file mode 100644
index 000000000..e3888411a
--- /dev/null
+++ b/app/src/main/res/drawable/ic_school_white_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_shopping_cart_black_24dp.xml b/app/src/main/res/drawable/ic_shopping_cart_black_24dp.xml
new file mode 100644
index 000000000..452332095
--- /dev/null
+++ b/app/src/main/res/drawable/ic_shopping_cart_black_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/drawable/ic_shopping_cart_white_24dp.xml b/app/src/main/res/drawable/ic_shopping_cart_white_24dp.xml
new file mode 100644
index 000000000..a55bf8a88
--- /dev/null
+++ b/app/src/main/res/drawable/ic_shopping_cart_white_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_sort_black_24dp.xml b/app/src/main/res/drawable/ic_sort_black_24dp.xml
new file mode 100644
index 000000000..fd4c56f0e
--- /dev/null
+++ b/app/src/main/res/drawable/ic_sort_black_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_sort_white_24dp.xml b/app/src/main/res/drawable/ic_sort_white_24dp.xml
new file mode 100644
index 000000000..a0c153ad0
--- /dev/null
+++ b/app/src/main/res/drawable/ic_sort_white_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_sports_black_24dp.xml b/app/src/main/res/drawable/ic_sports_black_24dp.xml
new file mode 100644
index 000000000..5a54580c1
--- /dev/null
+++ b/app/src/main/res/drawable/ic_sports_black_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_sports_white_24dp.xml b/app/src/main/res/drawable/ic_sports_white_24dp.xml
new file mode 100644
index 000000000..611852728
--- /dev/null
+++ b/app/src/main/res/drawable/ic_sports_white_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_stars_black_24dp.xml b/app/src/main/res/drawable/ic_stars_black_24dp.xml
new file mode 100644
index 000000000..66a89110e
--- /dev/null
+++ b/app/src/main/res/drawable/ic_stars_black_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_stars_white_24dp.xml b/app/src/main/res/drawable/ic_stars_white_24dp.xml
new file mode 100644
index 000000000..2de1fd808
--- /dev/null
+++ b/app/src/main/res/drawable/ic_stars_white_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_sunny_black_24dp.xml b/app/src/main/res/drawable/ic_sunny_black_24dp.xml
new file mode 100644
index 000000000..fee59df13
--- /dev/null
+++ b/app/src/main/res/drawable/ic_sunny_black_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_sunny_white_24dp.xml b/app/src/main/res/drawable/ic_sunny_white_24dp.xml
new file mode 100644
index 000000000..c6cb469ef
--- /dev/null
+++ b/app/src/main/res/drawable/ic_sunny_white_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_telescope_black_24dp.xml b/app/src/main/res/drawable/ic_telescope_black_24dp.xml
new file mode 100644
index 000000000..9c6132ecc
--- /dev/null
+++ b/app/src/main/res/drawable/ic_telescope_black_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_telescope_white_24dp.xml b/app/src/main/res/drawable/ic_telescope_white_24dp.xml
new file mode 100644
index 000000000..ea870fd87
--- /dev/null
+++ b/app/src/main/res/drawable/ic_telescope_white_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_trending_up_black_24dp.xml b/app/src/main/res/drawable/ic_trending_up_black_24dp.xml
new file mode 100644
index 000000000..706af95a4
--- /dev/null
+++ b/app/src/main/res/drawable/ic_trending_up_black_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_trending_up_white_24dp.xml b/app/src/main/res/drawable/ic_trending_up_white_24dp.xml
new file mode 100644
index 000000000..403674223
--- /dev/null
+++ b/app/src/main/res/drawable/ic_trending_up_white_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_videogame_black_24dp.xml b/app/src/main/res/drawable/ic_videogame_black_24dp.xml
new file mode 100644
index 000000000..df872c96c
--- /dev/null
+++ b/app/src/main/res/drawable/ic_videogame_black_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_videogame_white_24dp.xml b/app/src/main/res/drawable/ic_videogame_white_24dp.xml
new file mode 100644
index 000000000..593e49e14
--- /dev/null
+++ b/app/src/main/res/drawable/ic_videogame_white_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_watch_later_black_24dp.xml b/app/src/main/res/drawable/ic_watch_later_black_24dp.xml
new file mode 100644
index 000000000..5a1b9ac74
--- /dev/null
+++ b/app/src/main/res/drawable/ic_watch_later_black_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_watch_later_white_24dp.xml b/app/src/main/res/drawable/ic_watch_later_white_24dp.xml
new file mode 100644
index 000000000..f9fffbc43
--- /dev/null
+++ b/app/src/main/res/drawable/ic_watch_later_white_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_work_black_24dp.xml b/app/src/main/res/drawable/ic_work_black_24dp.xml
new file mode 100644
index 000000000..2668f2c43
--- /dev/null
+++ b/app/src/main/res/drawable/ic_work_black_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_work_white_24dp.xml b/app/src/main/res/drawable/ic_work_white_24dp.xml
new file mode 100644
index 000000000..8a1db7828
--- /dev/null
+++ b/app/src/main/res/drawable/ic_work_white_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_world_black_24dp.xml b/app/src/main/res/drawable/ic_world_black_24dp.xml
new file mode 100644
index 000000000..48785e7d7
--- /dev/null
+++ b/app/src/main/res/drawable/ic_world_black_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_world_white_24dp.xml b/app/src/main/res/drawable/ic_world_white_24dp.xml
new file mode 100644
index 000000000..01583e467
--- /dev/null
+++ b/app/src/main/res/drawable/ic_world_white_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/light_focused_selector.xml b/app/src/main/res/drawable/light_focused_selector.xml
new file mode 100644
index 000000000..102f40d76
--- /dev/null
+++ b/app/src/main/res/drawable/light_focused_selector.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout-large-land/activity_main_player.xml b/app/src/main/res/layout-large-land/activity_main_player.xml
index 2e66507ac..f081b61a9 100644
--- a/app/src/main/res/layout-large-land/activity_main_player.xml
+++ b/app/src/main/res/layout-large-land/activity_main_player.xml
@@ -296,7 +296,7 @@
android:layout_height="wrap_content"
android:layout_marginLeft="8dp"
android:layout_marginRight="8dp"
- android:layout_toLeftOf="@id/switchBackground"
+ android:layout_toLeftOf="@id/switchMute"
android:layout_toRightOf="@id/resizeTextView"
android:gravity="center|left"
android:minHeight="35dp"
diff --git a/app/src/main/res/layout/activity_main_player.xml b/app/src/main/res/layout/activity_main_player.xml
index 7e1aaf8f9..6474f1932 100644
--- a/app/src/main/res/layout/activity_main_player.xml
+++ b/app/src/main/res/layout/activity_main_player.xml
@@ -289,7 +289,7 @@
android:layout_height="wrap_content"
android:layout_marginLeft="8dp"
android:layout_marginRight="8dp"
- android:layout_toLeftOf="@id/switchBackground"
+ android:layout_toLeftOf="@id/switchMute"
android:layout_toRightOf="@id/resizeTextView"
android:gravity="center|left"
android:minHeight="35dp"
diff --git a/app/src/main/res/layout/dialog_feed_group_create.xml b/app/src/main/res/layout/dialog_feed_group_create.xml
new file mode 100644
index 000000000..364a6c891
--- /dev/null
+++ b/app/src/main/res/layout/dialog_feed_group_create.xml
@@ -0,0 +1,206 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/dialog_feed_group_reorder.xml b/app/src/main/res/layout/dialog_feed_group_reorder.xml
new file mode 100644
index 000000000..82a9b1591
--- /dev/null
+++ b/app/src/main/res/layout/dialog_feed_group_reorder.xml
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/feed_group_add_new_item.xml b/app/src/main/res/layout/feed_group_add_new_item.xml
new file mode 100644
index 000000000..3424762e2
--- /dev/null
+++ b/app/src/main/res/layout/feed_group_add_new_item.xml
@@ -0,0 +1,46 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/feed_group_card_item.xml b/app/src/main/res/layout/feed_group_card_item.xml
new file mode 100644
index 000000000..b6bf8656b
--- /dev/null
+++ b/app/src/main/res/layout/feed_group_card_item.xml
@@ -0,0 +1,48 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/feed_group_reorder_item.xml b/app/src/main/res/layout/feed_group_reorder_item.xml
new file mode 100644
index 000000000..d3bbf8005
--- /dev/null
+++ b/app/src/main/res/layout/feed_group_reorder_item.xml
@@ -0,0 +1,62 @@
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/feed_import_export_group.xml b/app/src/main/res/layout/feed_import_export_group.xml
new file mode 100644
index 000000000..2049db65e
--- /dev/null
+++ b/app/src/main/res/layout/feed_import_export_group.xml
@@ -0,0 +1,119 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/feed_item_carousel.xml b/app/src/main/res/layout/feed_item_carousel.xml
new file mode 100644
index 000000000..db3d9cb11
--- /dev/null
+++ b/app/src/main/res/layout/feed_item_carousel.xml
@@ -0,0 +1,7 @@
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/fragment_feed.xml b/app/src/main/res/layout/fragment_feed.xml
index 71217eea3..7d166a3f5 100644
--- a/app/src/main/res/layout/fragment_feed.xml
+++ b/app/src/main/res/layout/fragment_feed.xml
@@ -1,18 +1,116 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ android:visibility="gone"
+ tools:listitem="@layout/list_stream_item"
+ tools:visibility="visible" />
+
+
+
+
+
+
+
+ tools:visibility="visible" />
+ tools:visibility="visible" />
-
+ android:layout_alignParentTop="true"
+ android:background="?attr/toolbar_shadow_drawable" />
\ No newline at end of file
diff --git a/app/src/main/res/layout/fragment_subscription.xml b/app/src/main/res/layout/fragment_subscription.xml
index 979993a56..d1281f462 100644
--- a/app/src/main/res/layout/fragment_subscription.xml
+++ b/app/src/main/res/layout/fragment_subscription.xml
@@ -1,7 +1,6 @@
@@ -9,11 +8,10 @@
+ tools:listitem="@layout/list_channel_item"/>
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/header_item.xml b/app/src/main/res/layout/header_item.xml
new file mode 100644
index 000000000..4d4e1b884
--- /dev/null
+++ b/app/src/main/res/layout/header_item.xml
@@ -0,0 +1,16 @@
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/header_with_menu_item.xml b/app/src/main/res/layout/header_with_menu_item.xml
new file mode 100644
index 000000000..580e8db4d
--- /dev/null
+++ b/app/src/main/res/layout/header_with_menu_item.xml
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/header_with_text_item.xml b/app/src/main/res/layout/header_with_text_item.xml
new file mode 100644
index 000000000..871893ad6
--- /dev/null
+++ b/app/src/main/res/layout/header_with_text_item.xml
@@ -0,0 +1,36 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/list_channel_grid_item.xml b/app/src/main/res/layout/list_channel_grid_item.xml
index 3fe642974..423bfeb9e 100644
--- a/app/src/main/res/layout/list_channel_grid_item.xml
+++ b/app/src/main/res/layout/list_channel_grid_item.xml
@@ -1,48 +1,48 @@
-
+
-
+
-
+
-
+
diff --git a/app/src/main/res/layout/list_empty_view.xml b/app/src/main/res/layout/list_empty_view.xml
index e1833f243..094353324 100644
--- a/app/src/main/res/layout/list_empty_view.xml
+++ b/app/src/main/res/layout/list_empty_view.xml
@@ -3,7 +3,8 @@
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
- android:layout_height="match_parent"
+ android:layout_height="wrap_content"
+ android:minHeight="128dp"
android:gravity="center"
android:orientation="vertical">
diff --git a/app/src/main/res/layout/list_stream_item.xml b/app/src/main/res/layout/list_stream_item.xml
index 02e8f1531..d2000381d 100644
--- a/app/src/main/res/layout/list_stream_item.xml
+++ b/app/src/main/res/layout/list_stream_item.xml
@@ -75,6 +75,7 @@
android:layout_alignParentBottom="true"
android:layout_toRightOf="@+id/itemThumbnailView"
android:layout_toEndOf="@+id/itemThumbnailView"
+ android:ellipsize="end"
android:lines="1"
android:textAppearance="?android:attr/textAppearanceSmall"
android:textSize="@dimen/video_item_search_upload_date_text_size"
diff --git a/app/src/main/res/layout/list_stream_playlist_item.xml b/app/src/main/res/layout/list_stream_playlist_item.xml
index 2747038f6..00b431cc6 100644
--- a/app/src/main/res/layout/list_stream_playlist_item.xml
+++ b/app/src/main/res/layout/list_stream_playlist_item.xml
@@ -78,6 +78,7 @@
android:layout_toStartOf="@id/itemHandle"
android:layout_toRightOf="@+id/itemThumbnailView"
android:layout_toEndOf="@+id/itemThumbnailView"
+ android:ellipsize="end"
android:lines="1"
android:textAppearance="?android:attr/textAppearanceSmall"
android:textSize="@dimen/video_item_search_uploader_text_size"
diff --git a/app/src/main/res/layout/picker_icon_item.xml b/app/src/main/res/layout/picker_icon_item.xml
new file mode 100644
index 000000000..f156772b6
--- /dev/null
+++ b/app/src/main/res/layout/picker_icon_item.xml
@@ -0,0 +1,15 @@
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/picker_subscription_item.xml b/app/src/main/res/layout/picker_subscription_item.xml
new file mode 100644
index 000000000..474f068df
--- /dev/null
+++ b/app/src/main/res/layout/picker_subscription_item.xml
@@ -0,0 +1,59 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/subscription_header.xml b/app/src/main/res/layout/subscription_header.xml
index 821e1b2f4..9deabada0 100644
--- a/app/src/main/res/layout/subscription_header.xml
+++ b/app/src/main/res/layout/subscription_header.xml
@@ -7,37 +7,6 @@
android:orientation="vertical"
android:paddingBottom="12dp">
-
-
-
-
-
-
-
+
\ No newline at end of file
diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml
index 469c13177..1e48f1800 100644
--- a/app/src/main/res/values-ar/strings.xml
+++ b/app/src/main/res/values-ar/strings.xml
@@ -62,7 +62,7 @@
مشترك
الرئيسية
الاشتراكات
- ما الجديد
+ ما الجديد
في الخلفية
تشغيل تلقائي
اسود
@@ -94,7 +94,7 @@
تذكر حجم النافذة و وضعها
تذكر آخر مكان و حجم للنافذة المنبثقة
اعدادات إيماءة المشغل
- استخدم إيماءات التحكم في سطوع وصوت المشغل
+ استخدم الإيماءات للتحكم في سطوع وصوت المشغل
اقتراحات البحث
عرض الاقتراحات عند البحث
سجل البحث
@@ -111,7 +111,7 @@
تم وضعه على قائمة الانتظار في مشغل الخلفية
تم وضعه على قائمة الانتظار في مشغل النافذة المنبثقة
محتوى مقيد بحسب العمر
- "إظهار الفيديو المقيد بحسب العمر. يمكن السماح باستخدام هذه المواد من \"الإعدادات\"."
+ إظهار الفيديو المقيد بحسب العمر. التغييرات المستقبلية ممكنة من \"الإعدادات\".
بث مباشر
تقرير خطأ
قائمة التشغيل
@@ -205,7 +205,7 @@
إذا كانت لديك أفكار؛ أو ترجمة، أو تغييرات تخص التصميم، أو تنظيف و تحسين الشفرة البرمجية ، أو تعديلات عميقة عليها، فتذكر أنّ مساعدتك دائما موضع ترحيب. وكلما أتممنا شيئا كلما كان ذلك أفضل !
عرض على GitHub
تبرع
- يتم تطوير NewPipe من قبل متطوعين يقضون وقت فراغهم لتقديم أفضل تجربة لك. حان الوقت لرد المساعدة مع المطورين وجعل NewPipe أكثر و أفضل بينما تستمتع بفنجان من القهوة.
+ يتم تطوير NewPipe من قبل متطوعين يقضون وقت فراغهم لتقديم أفضل تجربة لك. حان الوقت لرد المساعدة مع المطورين وجعل NewPipe أكثر و أفضل بينما يستمتعون بفنجان من القهوة.
تبرع
موقع الويب
قم بزيارة موقع NewPipe لمزيد من المعلومات والمستجدات.
@@ -417,7 +417,7 @@
إلغاء الاشتراك
علامة تبويب جديدة
اختر علامة التبويب
- استخدم إيماءات التحكم في سطوع وصوت المشغل
+ استخدم إيماءات التحكم في صوت المشغل
التحكم بالإيماءات السطوع
استخدام الإيماءات للتحكم في سطوع المشغل
التحديثات
@@ -463,7 +463,7 @@
لا يمكن إنشاء الملف
لا يمكن إنشاء المجلد الوجهة
تم رفضها من قبل النظام
- فشل اتصال الأمن
+ فشل الاتصال الآمن
تعذر العثور على الخادم
لا يمكن الاتصال بالخادم
الخادم لايقوم بإرسال البيانات
@@ -556,8 +556,8 @@
لا يمكن استرداد هذا التنزيل
اختيار مثيل
ابحث عن مثيلات الخوادم التي تناسبك على %s
- تمكين قفل شاشة الصور المصغرة الفيديو
- عند استخدام مشغل الخلفية ، سيتم عرض صورة مصغرة للفيديو على شاشة القفل
+ تمكين صورة العرض للفيديو في شاشة القفل
+ عند استخدام مشغل الخلفية، سيتم عرض صورة العرض للفيديو على شاشة القفل
تنظيف تاريخ التحميل
حذف الملفات التي تم تنزيلها
التنزيلات %1$s المحذوفة
@@ -569,10 +569,10 @@
الفيديوهات
- %s ثوانٍ
-
-
-
-
-
+ - %s ثوانٍ
+ - %s ثوانٍ
+ - %s ثوانٍ
+ - %s ثوانٍ
+ - %s ثوانٍ
\ No newline at end of file
diff --git a/app/src/main/res/values-az/strings.xml b/app/src/main/res/values-az/strings.xml
index d39e46d5a..bf3b0f3f2 100644
--- a/app/src/main/res/values-az/strings.xml
+++ b/app/src/main/res/values-az/strings.xml
@@ -27,7 +27,7 @@
Abunəliklər
Əlfəcinlər
- Yeni nə var
+ Yeni nə var
Arxa fon
Video yükləmə ünvanı
diff --git a/app/src/main/res/values-b+ast/strings.xml b/app/src/main/res/values-b+ast/strings.xml
index 129c5d2eb..347afa2cf 100644
--- a/app/src/main/res/values-b+ast/strings.xml
+++ b/app/src/main/res/values-b+ast/strings.xml
@@ -74,9 +74,9 @@
Soscribise
Nun pudo anovase la soscripción
Soscripciones
- Novedaes
+ Novedaes
Historial de gueta
-
+
Sigue cola reproducción dempués de les interrupciones (llamaes telefóniques, por exemplu)
Reproductor
Comportamientu
@@ -211,9 +211,9 @@
Nun hai comentarios
Llimpieza de datos
Amosar comentarios
-
+
Pa cumplir cola GDPR (Regulación Xeneral de Proteición de Datos) europea, pidímoste que revises la política de privacidá de NewPipe. Lléila con procuru.
- ¿Desaniciar tol historial de gueta\?
+\nHas aceutala unviándonos un informe de fallos.¿Desaniciar tol historial de gueta\?
\nHas aceutala unviándonos un informe de fallos.
Aición al cambiar a otra aplicación dende\'l reproductor de vídeos principal — %s
El númberu máximu d\'intentos enantes d\'encaboxar la descarga
@@ -221,11 +221,11 @@
¿Quies llimpiar l\'historial de descargues o desaniciar tolos ficheros baxaos\?
Esportación anterior
Importar el ficheru
- Importa les soscripciones de YouTube baxando\'l ficheru d\'esportación:
-\n
-\n1.- Vete a esta URL: %1$s
-\n2.- Anicia sesión cuando se te pida
-\n3.- Debería aniciar una descarga (que ye\'l ficheru d\'esportación)
+ Importa les soscripciones de YouTube baxando\'l ficheru d\'esportación:
+\n
+\n1.- Vete a esta URL: %1$s
+\n2.- Anicia sesión cuando se te pida
+\n3.- Debería aniciase una descarga (que ye\'l ficheru d\'esportación)
Importa un perfil de SoundCloud teclexando la URL o la ID de to:
\n1.- Activa\'l mou escritoriu nun restolador web (el sitiu nun ta disponible pa móviles)
\n
@@ -278,6 +278,9 @@
Vídeos
- %s segundos
-
+ - %s segundos
+ Tempu
+ Tonu
+ Refugar
\ No newline at end of file
diff --git a/app/src/main/res/values-b+zh+HANS+CN/strings.xml b/app/src/main/res/values-b+zh+HANS+CN/strings.xml
index e547220c4..fe866fd04 100644
--- a/app/src/main/res/values-b+zh+HANS+CN/strings.xml
+++ b/app/src/main/res/values-b+zh+HANS+CN/strings.xml
@@ -38,8 +38,7 @@
稍后
网络错误
- - 视频
-
+ - %s 视频
禁用
后台播放
@@ -90,8 +89,7 @@
重试
存储访问权限已被拒绝
- - %s 次观看
-
+ - %s 次观看
千
百万
@@ -130,8 +128,7 @@
没有结果
没有订阅者
- - %s 位订阅者
-
+ - %s 位订阅者
没有视频
拖动以重新排序
@@ -236,7 +233,7 @@
无法更新订阅
主页
订阅
- 最新
+ 最新
恢复前台焦点
中断后继续播放(例如突然来电后)
搜索历史记录
@@ -352,7 +349,7 @@
报告『提前结束Android生命周期』错误
强制报告处理后的未送达的Activity或Fragment生命周期之外的Rx异常
使用快速不精确搜索
- 粗略定位播放:允许播放器以略低的精确度为代价换取更快的定位速度
+ 粗略定位播放:允许播放器以略低的精确度为代价换取更快的定位速度。此功能不适用于每隔5、15或25秒定位
自动播放下一个
当播放完非循环列表中的最后一个视频时,自动加入一个相关视频到播放列表
没有此文件夹
@@ -468,8 +465,7 @@
显示评论
禁用,以停止显示评论
- - %s 条评论
-
+ - %s 条评论
无法加载评论
关闭
@@ -497,8 +493,8 @@
系统将询问您将每次下载的保存位置。
\n(如果要下载到外部 SD 卡,请选择 SAF)
使用 SAF
- 存储访问框架(SAF)允许下载文件到外部SD卡。
-\n注:一些设备不兼容SAF
+ 存储访问框架(SAF)允许下载文件到外部SD卡。
+\n一些设备不兼容SAF
删除播放位置记录
删除所有播放位置记录
删除所有播放位置记录?
@@ -506,13 +502,11 @@
『时下流行』页-默认
没有人在观看
- - %s 人在观看
-
+ - %s 人在观看
没有人在听
- - %s 人在听
-
+ - %s 人在听
重新启动应用后,语言将更改。
PeerTube 服务器
@@ -543,7 +537,9 @@
完成
视频
- - %s秒
-
+ - %s秒
+ 由于ExoPlayer的限制,搜寻间隔设置为%d秒
+ 静音
+ 取消静音
\ No newline at end of file
diff --git a/app/src/main/res/values-be/strings.xml b/app/src/main/res/values-be/strings.xml
index b3a09cb8f..58ea028df 100644
--- a/app/src/main/res/values-be/strings.xml
+++ b/app/src/main/res/values-be/strings.xml
@@ -31,7 +31,7 @@
Галоўная
Падпіскі
Адзначаныя плэйлісты
- Што новага
+ Што новага
У фоне
У акне
Дадаць да
diff --git a/app/src/main/res/values-bg/strings.xml b/app/src/main/res/values-bg/strings.xml
index 7c813d0a9..26d394254 100644
--- a/app/src/main/res/values-bg/strings.xml
+++ b/app/src/main/res/values-bg/strings.xml
@@ -26,7 +26,7 @@
Неуспешна промяна на абонамента
Неуспешно обновление на абонамента
Абонаменти
- Обновления
+ Обновления
Във фонов режим
В прозорец
Директория за изтегляне на видео
diff --git a/app/src/main/res/values-bn-rBD/strings.xml b/app/src/main/res/values-bn-rBD/strings.xml
index 60b87caa9..6ba96425a 100644
--- a/app/src/main/res/values-bn-rBD/strings.xml
+++ b/app/src/main/res/values-bn-rBD/strings.xml
@@ -144,7 +144,7 @@
কি:\\nঅনুরোধ:\\nকন্টেন্ট ভাষা:\\nসার্ভিস:\\nসময়(GMT এ):\\nপ্যাকেজ:\\nসংস্করণ:\\nওএস সংস্করণ:\\nআইপি পরিসর:
স্ট্রিম ফাইল ডাউনলোড করুন।
তথ্য দেখুন
- কি নতুন
+ কি নতুন
যুক্ত করুন
খোজ ইতিহাস
ইতিহাস
diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml
index 20deb3160..8bc945992 100644
--- a/app/src/main/res/values-ca/strings.xml
+++ b/app/src/main/res/values-ca/strings.xml
@@ -13,7 +13,7 @@
Mostra la informació
Subscripcions
Llistes de reproducció desades
- Novetats
+ Novetats
Carpeta de baixada dels vídeos
Carpeta de baixada dels fitxers d\'àudio
Reproducció automàtica
diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml
index 84d83827f..9fd68d180 100644
--- a/app/src/main/res/values-cs/strings.xml
+++ b/app/src/main/res/values-cs/strings.xml
@@ -133,7 +133,7 @@ otevření ve vyskakovacím okně
Nelze aktualizovat odběr
Hlavní
Odběry
- Co je nového
+ Co je nového
Na pozadí
V okně
Výchozí rozlišení vyskakovacího okna
@@ -321,7 +321,7 @@ otevření ve vyskakovacím okně
Nahlásit mimo-cyklické chyby
Vynutit hlášení nedoručitelných výjimek Rx mimo životnost fragmentu nebo aktivity po odstranění
Použít rychlé nepřesné hledání
- Nepřesné hledání umožní přehrávači posouvat se rychleji, ale se sníženou přesností
+ Nepřesné hledání umožní přehrávači posouvat se rychleji, ale se sníženou přesností. Posouvání po 5, 15 nebo 25 vteřinách s tímto nefunguje.
Načítat náhledy
Vypnout, aby se zabránilo načítání náhledů a tím se ušetřily data a používání paměti. Změna tohoto nastavení vyčistí mezipamět obrázků v paměti i na disku.
Mezipaměť obrázků vymazána
@@ -503,7 +503,7 @@ otevření ve vyskakovacím okně
\nZvolte SAF, pokud si přejete stahovat na externí SD kartu
Použít SAF
\"Storage Access Framework\" umožňuje stahovat na externí SD kartu.
-\nUpozornění: některá zařízení jsou nekompatibilní
+\nNěkterá zařízení nejsou kompatibilní