() {
- @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 00000000000..64020d14ce4
--- /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 00000000000..c37d6a0b345
--- /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 00000000000..adc262ecbd1
--- /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 00000000000..e9012ff3730
--- /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 00000000000..294a7fcd5e7
--- /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 d84fe019599..d208f92b36c 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 Single> loadLocalStreamStateBatch(final List ex
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 31ae70954e2..a54c2a9a416 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 @@ protected List processResult(final List
- 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 void selected(LocalItem selectedItem) {
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 @@ private void deleteEntry(final int index) {
.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 30cc6de32fa..7eef3e67eee 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 void updateFromItem(final LocalItem localItem, HistoryRecordManager histo
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 void updateFromItem(final LocalItem localItem, HistoryRecordManager histo
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 void updateFromItem(final LocalItem localItem, HistoryRecordManager histo
}
// 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 void updateState(LocalItem localItem, HistoryRecordManager historyRecordM
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 75fbf13ea46..77f94703170 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 LocalStatisticStreamItemHolder(LocalItemBuilder itemBuilder, ViewGroup pa
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 void updateFromItem(final LocalItem localItem, HistoryRecordManager histo
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 void updateFromItem(final LocalItem localItem, HistoryRecordManager histo
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 void updateFromItem(final LocalItem localItem, HistoryRecordManager histo
}
// 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 void updateState(LocalItem localItem, HistoryRecordManager historyRecordM
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 17599a1ca60..dd9958486c7 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 void selected(LocalItem selectedItem) {
if (selectedItem instanceof PlaylistStreamEntry) {
final PlaylistStreamEntry item = (PlaylistStreamEntry) selectedItem;
NavigationHelper.openVideoDetailFragment(getFragmentManager(),
- item.serviceId, item.url, item.title);
+ item.getStreamEntity().getServiceId(), item.getStreamEntity().getUrl(), item.getStreamEntity().getTitle());
}
}
@@ -422,7 +422,7 @@ private void updateThumbnailUrl() {
String newThumbnailUrl;
if (!itemListAdapter.getItemsList().isEmpty()) {
- newThumbnailUrl = ((PlaylistStreamEntry) itemListAdapter.getItemsList().get(0)).thumbnailUrl;
+ newThumbnailUrl = ((PlaylistStreamEntry) itemListAdapter.getItemsList().get(0)).getStreamEntity().getThumbnailUrl();
} else {
newThumbnailUrl = "drawable://" + R.drawable.dummy_thumbnail_playlist;
}
@@ -434,7 +434,7 @@ private void deleteItem(final PlaylistStreamEntry item) {
if (itemListAdapter == null) return;
itemListAdapter.removeItem(item);
- if (playlistManager.getPlaylistThumbnail(playlistId).equals(item.thumbnailUrl))
+ if (playlistManager.getPlaylistThumbnail(playlistId).equals(item.getStreamEntity().getThumbnailUrl()))
updateThumbnailUrl();
setVideoCount(itemListAdapter.getItemsList().size());
@@ -472,7 +472,7 @@ private void saveImmediate() {
List 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 @@ protected void showStreamItemDialog(final PlaylistStreamEntry item) {
StreamDialogEntry.start_here_on_background.setCustomAction(
(fragment, infoItemDuplicate) -> 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 00000000000..9ff08c32c63
--- /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 bff6c1b3a1a..00000000000
--- 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 00000000000..98e20a02f62
--- /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 00000000000..92ab8cb0ce7
--- /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 7d6fa515844..00000000000
--- 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 00000000000..6454cc91222
--- /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 00000000000..24c8d9cb8e6
--- /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 00000000000..27ff38a3f8c
--- /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 00000000000..bd57a263993
--- /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 00000000000..17ee89c87ab
--- /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 00000000000..8ef5bb55cb7
--- /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 00000000000..928f93a47d1
--- /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 00000000000..0c651dc69fc
--- /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 00000000000..309f82bbc6f
--- /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 00000000000..a757dc5b3f5
--- /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 00000000000..bde3c604a57
--- /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 00000000000..cf010af7f0b
--- /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 00000000000..ab47564cec1
--- /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 00000000000..367605f4607
--- /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 00000000000..5ffdfe7c14b
--- /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 00000000000..fedec988021
--- /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 00000000000..21c74b09fab
--- /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 6b607cdcaa9..e970ebfa49f 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 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 IBinder onBind(Intent intent) {
@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 01c0427f36f..788073ee5e5 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 ebfff9fe244..5b5ebf702a6 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 31cd4b60377..35802457403 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.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 @@ protected void disposeAll() {
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 62c1dfeb9ee..0d2f3757f8f 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.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 @@ private void startImport() {
.observeOn(Schedulers.io())
.doOnNext(getNotificationsConsumer())
+
.buffer(BUFFER_COUNT_BEFORE_INSERT)
.map(upsertBatch())
@@ -204,6 +204,7 @@ public void onNext(List successfulInserted) {
@Override
public void onError(Throwable error) {
+ Log.e(TAG, "Got an error!", error);
handleError(error);
}
@@ -242,7 +243,7 @@ private Function>, List> upse
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 2cca9305a8b..f4f3e31b6c7 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 e0003ccaae5..6c765dc3d6d 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 @@
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 7064aec3319..9ee12facc74 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 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 View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container,
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 00000000000..4bc59fcee8a
--- /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 cba3c45344a..cc40298b998 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 int getTabId() {
@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 00000000000..8d24cb04e91
--- /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 0cebe5af3b5..cf44772237a 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.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 static Single getMoreChannelItems(final int serviceId,
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 47b914bde63..9c8fc25b8f6 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 static String getDurationString(long duration) {
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 98264e1bf8e..b6f73dac741 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.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 static void openPlaylistFragment(FragmentManager fragmentManager,
.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 661aa47c1ce..bd51919c7e6 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 static int getDialogTheme(Context context) {
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 00000000000..102f40d76ee
--- /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 00000000000..b6bac6252c5
--- /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 00000000000..5af152ecc17
--- /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 00000000000..5d29112bdc7
--- /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 00000000000..fa16cd5e8e7
--- /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 00000000000..bd487cb5579
--- /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 00000000000..6aa8cdd82a8
--- /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 00000000000..7ad263933e7
--- /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 00000000000..b03d9c0ce35
--- /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 00000000000..c4bdad68806
--- /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 00000000000..43489826e01
--- /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 00000000000..88f94780ffe
--- /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 00000000000..45f489d8044
--- /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 00000000000..89ca90fb52e
--- /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 00000000000..c898ed9a570
--- /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 00000000000..65f2818a669
--- /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 00000000000..fac0475505e
--- /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 00000000000..39bbee49a04
--- /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 00000000000..40a1cf9c1c9
--- /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 00000000000..1b2d3b4be17
--- /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 00000000000..25cb46e833b
--- /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 00000000000..02c6396ee3c
--- /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 00000000000..1517747d07b
--- /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 00000000000..d813b72b81f
--- /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 00000000000..d1d8e01e713
--- /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 00000000000..c5dda16c8db
--- /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 00000000000..21622c16271
--- /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 00000000000..90e6ff21563
--- /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 00000000000..25d8951a72f
--- /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 00000000000..36ee9ff81e4
--- /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 00000000000..4019c2e4671
--- /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 00000000000..2407a2b73eb
--- /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 00000000000..6009979dd41
--- /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 00000000000..b94c29f8f23
--- /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 00000000000..d70c00f0065
--- /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 00000000000..f73e767741e
--- /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 00000000000..69815929532
--- /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 00000000000..1d38e6e2232
--- /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 00000000000..d0fe31838f0
--- /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 00000000000..e6fa4c583e4
--- /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 00000000000..f0ff6a8711e
--- /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 00000000000..99f29996304
--- /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 00000000000..b6247bd8716
--- /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 00000000000..46724a33d00
--- /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 00000000000..00da9101fc7
--- /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 00000000000..df563ec1d27
--- /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 00000000000..8229a9a64c2
--- /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 00000000000..a8175c316a4
--- /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 00000000000..0a8c6bde992
--- /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 00000000000..c81618bb776
--- /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 00000000000..8f52f0dde57
--- /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 00000000000..e3888411a0c
--- /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 00000000000..452332095ec
--- /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 00000000000..a55bf8a88bb
--- /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 00000000000..fd4c56f0e09
--- /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 00000000000..a0c153ad014
--- /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 00000000000..5a54580c102
--- /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 00000000000..61185272887
--- /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 00000000000..66a89110e85
--- /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 00000000000..2de1fd80842
--- /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 00000000000..fee59df1325
--- /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 00000000000..c6cb469ef60
--- /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 00000000000..9c6132eccbd
--- /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 00000000000..ea870fd8733
--- /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 00000000000..706af95a48e
--- /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 00000000000..403674223b3
--- /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 00000000000..df872c96c6c
--- /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 00000000000..593e49e143d
--- /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 00000000000..5a1b9ac74b5
--- /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 00000000000..f9fffbc435d
--- /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 00000000000..2668f2c43d3
--- /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 00000000000..8a1db78288a
--- /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 00000000000..48785e7d74d
--- /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 00000000000..01583e46720
--- /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 00000000000..102f40d76ee
--- /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/dialog_feed_group_create.xml b/app/src/main/res/layout/dialog_feed_group_create.xml
new file mode 100644
index 00000000000..364a6c8917a
--- /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 00000000000..82a9b1591d6
--- /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 00000000000..3424762e265
--- /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 00000000000..b6bf8656bea
--- /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 00000000000..d3bbf8005a9
--- /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 00000000000..2049db65e4d
--- /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 00000000000..db3d9cb11ff
--- /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 71217eea358..7d166a3f57f 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 979993a56b2..d1281f46294 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 00000000000..4d4e1b884f5
--- /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 00000000000..580e8db4d82
--- /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 00000000000..871893ad64b
--- /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 3fe6429745f..423bfeb9efe 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 e1833f243b2..0943533240d 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 02e8f153138..d2000381d90 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 2747038f6be..00b431cc620 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 00000000000..f156772b6b7
--- /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 00000000000..474f068df2d
--- /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 821e1b2f405..9deabada062 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 5fdb8e1f5fa..1e48f180032 100644
--- a/app/src/main/res/values-ar/strings.xml
+++ b/app/src/main/res/values-ar/strings.xml
@@ -62,7 +62,7 @@
مشترك
الرئيسية
الاشتراكات
- ما الجديد
+ ما الجديد
في الخلفية
تشغيل تلقائي
اسود
diff --git a/app/src/main/res/values-az/strings.xml b/app/src/main/res/values-az/strings.xml
index d39e46d5ac2..bf3b0f3f26b 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 721d0a7ebb7..347afa2cf88 100644
--- a/app/src/main/res/values-b+ast/strings.xml
+++ b/app/src/main/res/values-b+ast/strings.xml
@@ -74,7 +74,7 @@
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)
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 9c4739e41d0..fe866fd04b3 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
@@ -233,7 +233,7 @@
无法更新订阅
主页
订阅
- 最新
+ 最新
恢复前台焦点
中断后继续播放(例如突然来电后)
搜索历史记录
diff --git a/app/src/main/res/values-be/strings.xml b/app/src/main/res/values-be/strings.xml
index b3a09cb8f7d..58ea028dfa3 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 7c813d0a936..26d394254bb 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 60b87caa918..6ba96425ab0 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 20deb31601f..8bc94599218 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 f8ac19ddd8c..9fd68d180b6 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
diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml
index ff2fea8c9c8..25abe3d1afa 100644
--- a/app/src/main/res/values-da/strings.xml
+++ b/app/src/main/res/values-da/strings.xml
@@ -34,7 +34,7 @@
Gemte spillelister
Ny fane
Vælg fane
- Nyheder
+ Nyheder
Baggrund
Pop op
Føj til
diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml
index d8ad7169b7b..1d69e7a82de 100644
--- a/app/src/main/res/values-de/strings.xml
+++ b/app/src/main/res/values-de/strings.xml
@@ -168,7 +168,7 @@
Abonniert
Kanal abbestellt
Abos
- Neuigkeiten
+ Neuigkeiten
Suchverlauf
Suchanfragen lokal speichern
Verlauf ansehen
diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml
index 915e1902da4..e67bea60d80 100644
--- a/app/src/main/res/values-el/strings.xml
+++ b/app/src/main/res/values-el/strings.xml
@@ -100,7 +100,7 @@
Κύριο
Συνδρομές
Αγαπημένες λίστες αναπαραγωγής
- Νέα
+ Νέα
Στο παρασκήνιο
Αναδυόμενο παράθυρο
Προσθήκη σε
diff --git a/app/src/main/res/values-eo/strings.xml b/app/src/main/res/values-eo/strings.xml
index fc57b106c7b..6af9723f57b 100644
--- a/app/src/main/res/values-eo/strings.xml
+++ b/app/src/main/res/values-eo/strings.xml
@@ -81,7 +81,7 @@
Ĉefa
Abonoj
Konservitaj ludlistoj
- Kio novas
+ Kio novas
Fono
Ŝprucfenestro
Aldonu al
@@ -120,7 +120,7 @@
Jes
Poste
- Tiu permeso estas necesa por
+ Tiu permeso estas necesa por
\nmalfermi en ŝprucfenestra modo
Ludante en ŝprucfenestra modo
Malŝatitaj
@@ -211,15 +211,15 @@
Tia dosiero/enhavo ne ekzistas
Dosiernomo ne povas esti malplena
Eraro okazis: %1$s
- Importu Jutubajn abonaĵojn per elŝuti la dosieron de eksporto :
-\n
-\n1. Iru ĉe tiu retpaĝo: %1$s
-\n2. Ensalutu kiam oni petas vin
+ Importu Jutubajn abonaĵojn per elŝuti la dosieron de eksporto :
+\n
+\n1. Iru ĉe tiu retpaĝo: %1$s
+\n2. Ensalutu kiam oni petas vin
\n3. Elŝuto devus komenci (ĝi estas la dosiero de eksporto)
- Importu Soundcloud-n profilon tajpante ĉu la ligilon, ĉu vian ID :
+ Importu Soundcloud-n profilon tajpante ĉu la ligilon, ĉu vian ID :
\n
-\n1. Ebligu komputilon modon en retumilon (la retejo malhaveblas por poŝtelefonoj)
-\n2. Iru tien: %1$s
+\n1. Ebligu komputilon modon en retumilon (la retejo malhaveblas por poŝtelefonoj)
+\n2. Iru tien: %1$s
\n3. Ensalutu kiam oni petas vin
\n4. Kopiu la ligilon de profilo ke oni kondikis vin.
Malŝaltu por malebligi ŝarĝajn bildetojn, konservi datumojn kaj uzadon de memoro. Ŝanĝoj forviŝas ambaŭ en-memoro kaj sur-disko bildo kaŝmemoro.
@@ -291,7 +291,7 @@
Oni petos vin kie konservi ĉion elŝutaĵon.
\nElektu AFM se vi volas elŝuti al ekstera SD-karto
Uzu AFM
- La \"Atinga Framo al la Memoro\" ebligas elŝuti al ekstera SD-karto.
+ La \"Atinga Framo al la Memoro\" ebligas elŝuti al ekstera SD-karto.
\nKomento: kelkaj aparatoj malkongruas
Forviŝi ludajn poziciojn
Forviŝi la totalon de ludaj pozicioj
@@ -440,10 +440,10 @@
NewPipe estas programaro sub rajtoceda permesilo: Vi povas uzi, studi, komuniki kaj plibonigi ĝin kiel vi volas. Precize, vi povas redistribui kaj/aŭ modifi ĝin sub la kondiĉoj de la Ĝenerala Publika Permesilo de GNU, kiel publikigita per la Free Software Foundation, ĉu en la versio 3, ĉu (se vi volas) ajna posta versio.
Ĉu vi volas ankaŭ importi agordojn\?
Privateca politiko de NewPipe
- La NewPipe projekto serioze respektas vian privatecon. Konsekvence, la apo ne kolektas ajnan datumon sen via konsento.
+ La NewPipe projekto serioze respektas vian privatecon. Konsekvence, la apo ne kolektas ajnan datumon sen via konsento.
\nLa privateco politiko de NewPipe detale eksplikas kion datumon estas sendita kaj stokita kiam vi sendas falegosignalon.
Legi la privatecan politikon
- Por konformiĝi al la Ĝenerala Datum-Protekta Regularon (GDPR), ni allogas vian atenton al la privateca politiko de NewPipe. Bonvolu atentive legi ĝin.
+ Por konformiĝi al la Ĝenerala Datum-Protekta Regularon (GDPR), ni allogas vian atenton al la privateca politiko de NewPipe. Bonvolu atentive legi ĝin.
\nVi devas akcepti ĝin por sendi la cimsignalon al ni.
Akcepti
Rifuzi
diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml
index 3cc903e101a..b6e0386a3ac 100644
--- a/app/src/main/res/values-es/strings.xml
+++ b/app/src/main/res/values-es/strings.xml
@@ -166,7 +166,7 @@
No se pudo actualizar la suscripción
Principal
Suscripciones
- Qué hay de nuevo
+ Qué hay de nuevo
Reanudar reproducción
Continuar reproduciendo después de las interrupciones (ej. llamadas telefónicas)
Descargar
diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml
index 79442f42196..6521c1d45f2 100644
--- a/app/src/main/res/values-et/strings.xml
+++ b/app/src/main/res/values-et/strings.xml
@@ -30,7 +30,7 @@
Kuva info
Tellimused
Esitusloendid järjehoidjates
- Mis on uut
+ Mis on uut
Taust
Hüpikaken
"Lisa "
diff --git a/app/src/main/res/values-eu/strings.xml b/app/src/main/res/values-eu/strings.xml
index f0fab60ec46..e8bd8a9a7f7 100644
--- a/app/src/main/res/values-eu/strings.xml
+++ b/app/src/main/res/values-eu/strings.xml
@@ -165,7 +165,7 @@
Ezin izan da harpidetza eguneratu
Nagusia
Harpidetzak
- Zer dago berri
+ Zer dago berri
Jarraitu erreprodukzioa
Jarraitu etenaldiak eta gero (adib. telefono deiak)
Deskargak
diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml
index 790c476690f..36e3524c74a 100644
--- a/app/src/main/res/values-fa/strings.xml
+++ b/app/src/main/res/values-fa/strings.xml
@@ -115,7 +115,7 @@
اصلی
اشتراکها
فهرستهای پخش دارای نشانک
- موارد جدید
+ موارد جدید
پس زمینه
افزودن به
نمایش کیفیت بالاتر
diff --git a/app/src/main/res/values-fi/strings.xml b/app/src/main/res/values-fi/strings.xml
index d34a6408ac6..46a47c6b502 100644
--- a/app/src/main/res/values-fi/strings.xml
+++ b/app/src/main/res/values-fi/strings.xml
@@ -27,7 +27,7 @@
Ei pystytty päivittämään tilausta
Päävalikko
Tilaukset
- Uudet
+ Uudet
Taustatoisto
Ikkuna
Videolatausten sijainti
diff --git a/app/src/main/res/values-fil/strings.xml b/app/src/main/res/values-fil/strings.xml
index 9813b692205..d52b1f4b9b4 100644
--- a/app/src/main/res/values-fil/strings.xml
+++ b/app/src/main/res/values-fil/strings.xml
@@ -34,7 +34,7 @@
Naka-bookmark mga Playlist
Bagong Tab
Pumili nang Tab
- Anong Bago
+ Anong Bago
Likuran
Popup
Idagdag sa
diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml
index 10e31e72fbd..c41d5c54fd7 100644
--- a/app/src/main/res/values-fr/strings.xml
+++ b/app/src/main/res/values-fr/strings.xml
@@ -147,7 +147,7 @@
Désabonné de la chaîne
Principal
Abonnements
- Nouveautés
+ Nouveautés
Téléchargement
Paramètres
À propos
diff --git a/app/src/main/res/values-gl/strings.xml b/app/src/main/res/values-gl/strings.xml
index 6427e1378ef..af1066152cf 100644
--- a/app/src/main/res/values-gl/strings.xml
+++ b/app/src/main/res/values-gl/strings.xml
@@ -32,7 +32,7 @@
Subscricións
Favoritos
- Novidades
+ Novidades
Segundo plano
Modo popup
diff --git a/app/src/main/res/values-he/strings.xml b/app/src/main/res/values-he/strings.xml
index 5de0e7816f5..a3f4583cb38 100644
--- a/app/src/main/res/values-he/strings.xml
+++ b/app/src/main/res/values-he/strings.xml
@@ -105,7 +105,7 @@
לא ניתן לעדכן את המינוי
ראשי
מינויים
- מה חדש
+ מה חדש
היסטוריית חיפוש
שמירת שאילתות החיפוש מקומית
היסטוריית צפייה
diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml
index a7746330ae0..f082742632a 100644
--- a/app/src/main/res/values-hi/strings.xml
+++ b/app/src/main/res/values-hi/strings.xml
@@ -62,7 +62,7 @@
मुख्य
सदस्यता नहीं बदला जा सका
सदस्यता का अद्यतन नहीं हो सका
- देखे की क्या नया है
+ देखे की क्या नया है
वीडियो डाउनलोड फ़ोल्डर
डाउनलोड की गई वीडियो फ़ाइलें यहां संग्रहीत हैं
वीडियो फ़ाइलों के लिए डाउनलोड फ़ोल्डर चुनें
diff --git a/app/src/main/res/values-hr/strings.xml b/app/src/main/res/values-hr/strings.xml
index 204d8895c51..e3da3e711fa 100644
--- a/app/src/main/res/values-hr/strings.xml
+++ b/app/src/main/res/values-hr/strings.xml
@@ -27,7 +27,7 @@
Nije moguće osvježiti pretplatu
Početna
Pretplate
- Što je novo
+ Što je novo
Pozadina
Skočni prozor
Mapa za preuzimanje videozapisa
diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml
index 1fda2a515d5..1f74f79e611 100644
--- a/app/src/main/res/values-hu/strings.xml
+++ b/app/src/main/res/values-hu/strings.xml
@@ -109,7 +109,7 @@
Főoldal
Feliratkozások
Könyvjelzőzött lejátszási listák
- Újdonságok
+ Újdonságok
Háttér
Felugró ablak
Nem található stream lejátszó alkalmazás (feltelepítheted a VLC-t a lejátszáshoz).
diff --git a/app/src/main/res/values-ia/strings.xml b/app/src/main/res/values-ia/strings.xml
index ff90b50ab53..822d714f600 100644
--- a/app/src/main/res/values-ia/strings.xml
+++ b/app/src/main/res/values-ia/strings.xml
@@ -17,7 +17,7 @@
Subscriptiones
Nove scheda
Seliger le scheda
- Novitates
+ Novitates
Fundo
Emergente
Adder a
diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml
index 54581e9ac13..b4db9141d73 100644
--- a/app/src/main/res/values-in/strings.xml
+++ b/app/src/main/res/values-in/strings.xml
@@ -165,7 +165,7 @@
Kontribusi
Subscribe
Disubscribe
- Apa Yang Baru
+ Apa Yang Baru
Lanjutkan pemutaran
Melanjutkan pemutaran setelah interupsi (mis. panggilan telepon)
Utama
diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml
index 37e328799b9..1ad3bc21841 100644
--- a/app/src/main/res/values-it/strings.xml
+++ b/app/src/main/res/values-it/strings.xml
@@ -164,7 +164,7 @@
Impossibile cambiare l\'iscrizione
Impossibile aggiornare l\'iscrizione
Iscrizioni
- Novità
+ Novità
Cronologia Ricerche
Salva le ricerche localmente
Cronologia Visualizzazioni
@@ -381,7 +381,7 @@
NewPipe è un software libero con licenza copyleft: si può utilizzare, studiare, condividere e migliorare a proprio piacimento. In particolare, è possibile ridistribuirlo e/o modificarlo secondo i termini della GNU General Public License (Free Software Foundation), nella versione 3 o successiva, a propria discrezione.
Vuoi anche importare le impostazioni?
Informativa sulla Privacy
- Il progetto NewPipe tiene molto alla tua privacy. Perciò, l\'app non raccoglie alcun dato senza il tuo consenso.
+ Il progetto NewPipe tiene molto alla tua privacy. Perciò, l\'app non raccoglie alcun dato senza il tuo consenso.
\nL\'informativa sulla privacy spiega nel dettaglio quali dati vengono trattati e memorizzati durante l\'invio di segnalazioni per arresti anomali.
Leggi l\'informativa sulla privacy
Per rispettare il regolamento europeo sulla protezione dei dati (GDPR), attiriamo la vostra attenzione riguardo l\'informativa sulla privacy di NewPipe. Si prega di leggerla attentamente.
@@ -496,9 +496,9 @@
Ogni volta verrà chiesta la destinazione dei file
Utilizza SAF
Limita Coda Download
- Ogni volta verrà chiesta la destinazione dei file.
+ Ogni volta verrà chiesta la destinazione dei file.
\nScegli SAF se vuoi scaricare su una scheda SD esterna
- \"Storage Access Framework\" consente di salvare file su una memoria esterna.
+ \"Storage Access Framework\" consente di salvare file su una memoria esterna.
\nAlcuni dispositivi non sono compatibili
Elimina posizioni di riproduzione
Elimina tutte le posizioni di riproduzione
diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml
index 3fcef78997d..16ee3ed0393 100644
--- a/app/src/main/res/values-ja/strings.xml
+++ b/app/src/main/res/values-ja/strings.xml
@@ -165,7 +165,7 @@
チャンネル登録を更新できません
メイン
登録リスト
- 新着
+ 新着
検索履歴
検索した履歴を記憶します
視聴履歴
diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml
index 54437a37545..4935c4bdbc2 100644
--- a/app/src/main/res/values-ko/strings.xml
+++ b/app/src/main/res/values-ko/strings.xml
@@ -98,7 +98,7 @@
구독을 업데이트할 수 없음
메인 화면
구독
- 새로운 영상
+ 새로운 영상
백그라운드
팝업
기본 팝업 해상도
diff --git a/app/src/main/res/values-ku/strings.xml b/app/src/main/res/values-ku/strings.xml
index 3ac62a30ab4..43b32a29d0e 100644
--- a/app/src/main/res/values-ku/strings.xml
+++ b/app/src/main/res/values-ku/strings.xml
@@ -32,7 +32,7 @@
سهرهكی
بهشدارییهكان
خشتەی کارپێکردنەکان نیشانەکران
- چی نوێ ههیه
+ چی نوێ ههیه
لە پاشبنەما
پهنجهرهی بچووک
زیادکردن بۆ
diff --git a/app/src/main/res/values-lt/strings.xml b/app/src/main/res/values-lt/strings.xml
index f84a3c23cf5..fef2e3549d3 100644
--- a/app/src/main/res/values-lt/strings.xml
+++ b/app/src/main/res/values-lt/strings.xml
@@ -166,7 +166,7 @@
Pagrindinis
Prenumeratos
- Kas Naujo
+ Kas Naujo
Ieškoti istorijoje
Saugoti paieškos užklausas vietinėje atmintyje
diff --git a/app/src/main/res/values-mk/strings.xml b/app/src/main/res/values-mk/strings.xml
index 96b4c8819c2..8bf86c102b9 100644
--- a/app/src/main/res/values-mk/strings.xml
+++ b/app/src/main/res/values-mk/strings.xml
@@ -31,7 +31,7 @@
Почетна
Членства
Обележани плејлисти
- Новости
+ Новости
Позадина
Подпрозорче
Додај на
diff --git a/app/src/main/res/values-ms/strings.xml b/app/src/main/res/values-ms/strings.xml
index d8d291759bb..eb8826d45fe 100644
--- a/app/src/main/res/values-ms/strings.xml
+++ b/app/src/main/res/values-ms/strings.xml
@@ -34,7 +34,7 @@
Henti langganan
Tab Baru
Pilih Tab
- Apa yang Baru
+ Apa yang Baru
Latar Belakang
Popup
Tambahkan Ke
diff --git a/app/src/main/res/values-nb-rNO/strings.xml b/app/src/main/res/values-nb-rNO/strings.xml
index e0392a72de6..95a19c90346 100644
--- a/app/src/main/res/values-nb-rNO/strings.xml
+++ b/app/src/main/res/values-nb-rNO/strings.xml
@@ -134,7 +134,7 @@
Klarte ikke å endre abonnement
Klarte ikke å oppdatere abonnement
Abonnementer
- Hva er nytt
+ Hva er nytt
Bakgrunn
Oppsprett
Husk oppsprettsstørrelse og posisjon
diff --git a/app/src/main/res/values-nl-rBE/strings.xml b/app/src/main/res/values-nl-rBE/strings.xml
index de96152ec3b..4c3b9628d9d 100644
--- a/app/src/main/res/values-nl-rBE/strings.xml
+++ b/app/src/main/res/values-nl-rBE/strings.xml
@@ -31,7 +31,7 @@
Start
Abonnementen
Bijgehouden afspeellijsten
- Nieuw
+ Nieuw
Achtergrond
Pop-up
Toevoegen aan
diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml
index 9bde969cb53..35faa9ddec9 100644
--- a/app/src/main/res/values-nl/strings.xml
+++ b/app/src/main/res/values-nl/strings.xml
@@ -171,7 +171,7 @@
Kan abonnement niet bijwerken
Startpagina
Abonnementen
- Nieuw
+ Nieuw
Zoekgeschiedenis
Sla zoekopdrachten lokaal op
Geschiedenis en cache
diff --git a/app/src/main/res/values-oc/strings.xml b/app/src/main/res/values-oc/strings.xml
index e69e43b447d..937f76df47b 100644
--- a/app/src/main/res/values-oc/strings.xml
+++ b/app/src/main/res/values-oc/strings.xml
@@ -34,7 +34,7 @@
Listas de lectura enregistradas
Onglet novèl
Causir un onglet
- Çò novèl
+ Çò novèl
Rèireplan
Fenestron
Apondre a
diff --git a/app/src/main/res/values-pa/strings.xml b/app/src/main/res/values-pa/strings.xml
index 7e39321e05c..29d030e1eb5 100644
--- a/app/src/main/res/values-pa/strings.xml
+++ b/app/src/main/res/values-pa/strings.xml
@@ -31,7 +31,7 @@
ਸਬਸਕ੍ਰਿਪਸ਼ਨ ਅੱਪਡੇਟ ਕਰਨ ਵਿਚ ਅਸਮਰੱਥ
ਸਬਸਕ੍ਰਿਪਸ਼ਨ
ਬੁੱਕਮਾਰਕ ਪਲੇਲਿਸਟਾਂ
- ਨਵਾਂ ਕੀ ਹੈ
+ ਨਵਾਂ ਕੀ ਹੈ
ਬੈਕਗਰਾਊਂਡ
ਪੋਪ-ਅਪ
ਸ਼ਾਮਿਲ ਕਰੋ
diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml
index ded3ff417e3..694c295543c 100644
--- a/app/src/main/res/values-pl/strings.xml
+++ b/app/src/main/res/values-pl/strings.xml
@@ -169,7 +169,7 @@
Nie można zaktualizować subskrypcji
Główna
Subskrypcje
- Co nowego
+ Co nowego
Historia wyszukiwania
Zapisuj lokalnie historię wyszukiwania
Historia oglądanych
diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml
index fad9fea0646..c96435799fb 100644
--- a/app/src/main/res/values-pt-rBR/strings.xml
+++ b/app/src/main/res/values-pt-rBR/strings.xml
@@ -172,7 +172,7 @@ abrir em modo popup
Não foi possível atualizar inscrição
Principal
Inscrições
- Novidades
+ Novidades
Retomar reprodução
Continuar reproduzindo depois de interrupções (por exemplo: ligações)
Histórico de pesquisas
diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml
index c06a1909d4a..504b98642e1 100644
--- a/app/src/main/res/values-pt/strings.xml
+++ b/app/src/main/res/values-pt/strings.xml
@@ -165,7 +165,7 @@
Não foi possível atualizar a subscrição
Principal
Subscrições
- Novidades
+ Novidades
Histórico de pesquisa
Guardar termos de pesquisa localmente
Ver histórico
diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml
index d69ad016ef6..61d6a2d9af6 100644
--- a/app/src/main/res/values-ro/strings.xml
+++ b/app/src/main/res/values-ro/strings.xml
@@ -149,7 +149,7 @@ pentru a deschide în mod pop-up
Nu s-a putut actualiza abonamentul
Principal
Abonamente
- Ce este nou
+ Ce este nou
Istoric de căutări
Stochează local căutările
Istoric vizualizări.
diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml
index 0fb62f0738d..a0d292c8058 100644
--- a/app/src/main/res/values-ru/strings.xml
+++ b/app/src/main/res/values-ru/strings.xml
@@ -163,7 +163,7 @@
Не удалось обновить подписку
Главная
Подписки
- Что нового
+ Что нового
История поиска
Хранить запросы поиска локально
История просмотров
diff --git a/app/src/main/res/values-sk/strings.xml b/app/src/main/res/values-sk/strings.xml
index a6a20a48f5a..bdda945bc63 100644
--- a/app/src/main/res/values-sk/strings.xml
+++ b/app/src/main/res/values-sk/strings.xml
@@ -149,7 +149,7 @@
Nemožno aktualizovať odber
Hlavné
Odbery
- Čo je nové
+ Čo je nové
Hľadať v histórií
Hľadané výrazy ukladať lokálne
História pozretí
diff --git a/app/src/main/res/values-sl/strings.xml b/app/src/main/res/values-sl/strings.xml
index f318ca77e0e..d3adc9eca2e 100644
--- a/app/src/main/res/values-sl/strings.xml
+++ b/app/src/main/res/values-sl/strings.xml
@@ -201,7 +201,7 @@ odpiranje v pojavnem načinu
Glavno
Naročnine
- Kaj je novega
+ Kaj je novega
Prejmi
Dovoljeni znaki v imenih datotek
diff --git a/app/src/main/res/values-sq/strings.xml b/app/src/main/res/values-sq/strings.xml
index 74bf10804ae..1ef4bb6f5f8 100644
--- a/app/src/main/res/values-sq/strings.xml
+++ b/app/src/main/res/values-sq/strings.xml
@@ -14,7 +14,7 @@
Shfaq informatat
Menyja kryesore
- Të rejat
+ Të rejat
Shto në
diff --git a/app/src/main/res/values-sr/strings.xml b/app/src/main/res/values-sr/strings.xml
index ee999d779c2..79dccafc5fd 100644
--- a/app/src/main/res/values-sr/strings.xml
+++ b/app/src/main/res/values-sr/strings.xml
@@ -145,7 +145,7 @@
Претплати
Главно
Претплате
- Шта је ново
+ Шта је ново
Историјат претраге
Уписуј појмове претраге локално
Историјат
diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml
index 5290517abeb..f95944aeecb 100644
--- a/app/src/main/res/values-sv/strings.xml
+++ b/app/src/main/res/values-sv/strings.xml
@@ -106,7 +106,7 @@
Kunde inte uppdatera prenumeration
Hem
Prenumerationer
- Vad är nytt
+ Vad är nytt
Spela upp automatiskt
Sökhistorik
Spara sökfrågor lokalt
diff --git a/app/src/main/res/values-ta/strings.xml b/app/src/main/res/values-ta/strings.xml
index 41e68354536..409cf5d1262 100644
--- a/app/src/main/res/values-ta/strings.xml
+++ b/app/src/main/res/values-ta/strings.xml
@@ -30,7 +30,7 @@
முதன்மை
குழுசேர்ப்புகள்
குறிக்கப்பட்ட காணொலி பட்டியல்கள்
- புதிதாக
+ புதிதாக
பின்னால்
திரைமேல்
சேர்
diff --git a/app/src/main/res/values-te/strings.xml b/app/src/main/res/values-te/strings.xml
index 17519e08434..e7e87e93539 100644
--- a/app/src/main/res/values-te/strings.xml
+++ b/app/src/main/res/values-te/strings.xml
@@ -24,7 +24,7 @@
సబ్ స్క్రైబ్ నవీకరించలేరు
ప్రధానంగా
సభ్యత్వం
- కొత్తది ఏమిటి
+ కొత్తది ఏమిటి
వెనకవైపు
పాపప్
వీడియో డౌన్లోడ్ మార్గం
diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml
index 0fc066986f0..b4f506158f3 100644
--- a/app/src/main/res/values-th/strings.xml
+++ b/app/src/main/res/values-th/strings.xml
@@ -33,7 +33,7 @@
เพลย์ลิสต์ที่เก็บไว้
แท็บใหม่
เลือกแท็บ
- มีอะไรใหม่
+ มีอะไรใหม่
พื้นหลัง
ป๊อปอัพ
เพิ่มไปยัง
diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml
index 78d352e827e..05bc3cbe7f6 100644
--- a/app/src/main/res/values-tr/strings.xml
+++ b/app/src/main/res/values-tr/strings.xml
@@ -171,7 +171,7 @@
Abonelik güncellenemedi
Ana
Abonelikler
- Yenilikler
+ Yenilikler
Arama geçmişi
Arama sorgularını yerel olarak saklayın
İzleme geçmişi
diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml
index ad4e4731242..0d0c6082c50 100644
--- a/app/src/main/res/values-uk/strings.xml
+++ b/app/src/main/res/values-uk/strings.xml
@@ -119,7 +119,7 @@
Не вдалося оновити підписку
Головна
Підписки
- Новинки
+ Новинки
У тлі
У вікні
Типова роздільна здатність вікна
diff --git a/app/src/main/res/values-ur/strings.xml b/app/src/main/res/values-ur/strings.xml
index 627def4d3ba..2c712d8786a 100644
--- a/app/src/main/res/values-ur/strings.xml
+++ b/app/src/main/res/values-ur/strings.xml
@@ -31,7 +31,7 @@
مرکزی
رکنیتیں
نشان زدہ پلے لسٹس
- نیا کیا ہے
+ نیا کیا ہے
پس منظر
پوپ اپ
شامل کریں
diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml
index bfde056b08b..0f6cfe96ff0 100644
--- a/app/src/main/res/values-vi/strings.xml
+++ b/app/src/main/res/values-vi/strings.xml
@@ -173,7 +173,7 @@
Trang chủ
Đăng ký
Danh sách phát được đánh dấu
- Có gì mới
+ Có gì mới
Thêm vào
Sử dụng tìm kiếm nhanh không chính xác
Tìm kiếm không chính xác cho phép trình phát tìm đến vị trí nhanh hơn với độ chính xác bị hạn chế
diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml
index 8be5dd96723..8d210c067f7 100644
--- a/app/src/main/res/values-zh-rCN/strings.xml
+++ b/app/src/main/res/values-zh-rCN/strings.xml
@@ -237,7 +237,7 @@
无法更新订阅
主页
订阅
- 最新
+ 最新
恢复前台焦点
中断后继续播放(例如突然来电后)
搜索历史记录
@@ -411,7 +411,7 @@
NewPipe 项目非常重视您的隐私。因此,未经您的同意,应用程序不会收集任何数据。
\nNewPipe 的隐私政策详细解释了在发送崩溃报告时发送和存储的数据。
阅读隐私政策
- 为了遵守欧盟的《通用数据保护条例》(GDPR),我们特此提醒您注意 NewPipe 的隐私政策。请您仔细阅读。
+ 为了遵守欧盟的《通用数据保护条例》(GDPR),我们特此提醒您注意 NewPipe 的隐私政策。请您仔细阅读。
\n您必须在同意以后才能向我们发送错误报告。
接受
拒绝
diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml
index c141b2c9494..2837990ac45 100644
--- a/app/src/main/res/values-zh-rTW/strings.xml
+++ b/app/src/main/res/values-zh-rTW/strings.xml
@@ -148,7 +148,7 @@
無法更新訂閱
主頁
訂閱清單
- 新鮮事
+ 新鮮事
搜尋紀錄
在本機儲存搜尋紀錄
檢視歷史
diff --git a/app/src/main/res/values/attrs.xml b/app/src/main/res/values/attrs.xml
index 88925a59855..4cda52a99e0 100644
--- a/app/src/main/res/values/attrs.xml
+++ b/app/src/main/res/values/attrs.xml
@@ -41,21 +41,62 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
index 5741d1b4f77..425886cdc86 100644
--- a/app/src/main/res/values/colors.xml
+++ b/app/src/main/res/values/colors.xml
@@ -14,6 +14,9 @@
#ffffff
#212121
#c8ffffff
+ #F8F8F8
+ #E9E9E9
+ #33000000
#222222
@@ -27,12 +30,18 @@
#424242
#ffffff
#af000000
+ #313131
+ #474747
+ #33FFFFFF
#000000
@color/dark_settings_accent_color
#1effffff
#23454545
+ #0F0F0F
+ #202020
+ #25FFFFFF
#ddffffff
diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml
index 582c4bade2d..538179b7367 100644
--- a/app/src/main/res/values/dimens.xml
+++ b/app/src/main/res/values/dimens.xml
@@ -14,6 +14,9 @@
70dp
164dp
92dp
+
+ 42dp
+ 128dp
96dp
@@ -24,10 +27,12 @@
2dp
4dp
8dp
+ 12dp
180dp
150dp
+ 32dp
42dp
24dp
@@ -89,4 +94,10 @@
12sp
12sp
+
+
+ 12dp
+ 2dp
+ 4dp
+
diff --git a/app/src/main/res/values/settings_keys.xml b/app/src/main/res/values/settings_keys.xml
index fe096c9fddd..e97bf11cadb 100644
--- a/app/src/main/res/values/settings_keys.xml
+++ b/app/src/main/res/values/settings_keys.xml
@@ -9,6 +9,7 @@
@string/youtube
saved_tabs_key
+ feed_last_updated
download_path
@@ -180,6 +181,31 @@
app_language_key
enable_lock_screen_video_thumbnail
+ feed_update_threshold_key
+ 300
+
+
+
+ - @string/feed_update_threshold_option_always_update
+ - 5 minutes
+ - 15 minutes
+ - 1 hour
+ - 6 hours
+ - 12 hours
+ - 1 day
+
+
+
+ - 0
+ - 300
+ - 900
+ - 3600
+ - 21600
+ - 43200
+ - 86400
+
+ feed_use_dedicated_fetch_method
+
import_data
export_data
@@ -1082,12 +1108,17 @@
list_view_mode
- auto
+ @string/list_view_mode_auto_key
+
+
+ auto
+ list
+ grid
- - auto
- - list
- - grid
+ - @string/list_view_mode_auto_key
+ - @string/list_view_mode_list_key
+ - @string/list_view_mode_grid_key
- @string/auto
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 2934769d027..edc4c5a2726 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -36,7 +36,6 @@
Bookmarked Playlists
New Tab
Choose Tab
- What\'s New
Background
Popup
Add To
@@ -192,6 +191,7 @@
Deletes history of search keywords
Delete entire search history?
Search history deleted.
+ Help
Error
External storage unavailable
@@ -598,4 +598,50 @@
- %s second
- %s seconds
-
+
+
+
+ - %d second
+ - %d seconds
+
+
+
+ - %d minute
+ - %d minutes
+
+
+
+ - %d hour
+ - %d hours
+
+
+
+ - %d day
+ - %d days
+
+
+
+ What\'s New
+ Feed groups
+ Oldest subscription update: %s
+ Not loaded: %d
+ Loading feed…
+ Processing feed…
+ Select subscriptions
+ Selected: %d
+ No subscription selected
+ Empty group name
+ Name
+ Do you want to delete this group?
+ New
+
+ Feed
+ Feed update threshold
+ Time after last update before a subscription is considered outdated — %s
+ Always update
+ Fetch from dedicated feed when available
+ Available in some services, it is usually much faster but may return a limited amount of items and often incomplete information (e.g. no duration, item type, no live status).
+ Enable fast mode
+ Disable fast mode
+ Do you think feed loading is too slow? If so, try enabling fast loading (you can change it in settings or by pressing the button below).\n\nNewPipe offers two feed loading strategies:\n• Fetching the whole subscription channel, which is slow but complete.\n• Using a dedicated service endpoint, which is fast but usually not complete.\n\nThe difference between the two is that the fast one usually lacks some information, like the item\'s duration or type (can\'t distinguish between live videos and normal ones) and it may return less items.\n\nYouTube is an example of a service that offers this fast method with its RSS feed.\n\nSo the choice boils down to what you prefer: speed or precise information.
+
\ No newline at end of file
diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml
index d72a1b0926c..f7d09ef9c96 100644
--- a/app/src/main/res/values/styles.xml
+++ b/app/src/main/res/values/styles.xml
@@ -61,15 +61,55 @@
- @drawable/ic_delete_black_24dp
- @drawable/ic_settings_update_black
- @drawable/ic_done_black_24dp
+ - @drawable/ic_refresh_black_24dp
+ - @drawable/ic_computer_black_24dp
+ - @drawable/ic_videogame_black_24dp
+ - @drawable/ic_music_note_black_24dp
+ - @drawable/ic_stars_black_24dp
+ - @drawable/ic_sports_black_24dp
+ - @drawable/ic_money_black_24dp
+ - @drawable/ic_person_black_24dp
+ - @drawable/ic_people_black_24dp
+ - @drawable/ic_heart_black_24dp
+ - @drawable/ic_kids_black_24dp
+ - @drawable/ic_fastfood_black_24dp
+ - @drawable/ic_car_black_24dp
+ - @drawable/ic_motorcycle_black_24dp
+ - @drawable/ic_trending_up_black_24dp
+ - @drawable/ic_school_black_24dp
+ - @drawable/ic_asterisk_black_24dp
+ - @drawable/ic_emoticon_black_24dp
+ - @drawable/ic_edit_black_24dp
+ - @drawable/ic_explore_black_24dp
+ - @drawable/ic_fitness_black_24dp
+ - @drawable/ic_restaurant_black_24dp
+ - @drawable/ic_mic_black_24dp
+ - @drawable/ic_radio_black_24dp
+ - @drawable/ic_shopping_cart_black_24dp
+ - @drawable/ic_watch_later_black_24dp
+ - @drawable/ic_work_black_24dp
+ - @drawable/ic_movie_black_24dp
+ - @drawable/ic_pets_black_24dp
+ - @drawable/ic_world_black_24dp
+ - @drawable/ic_sunny_black_24dp
+ - @drawable/ic_telescope_black_24dp
+ - @drawable/ic_megaphone_black_24dp
+ - @drawable/ic_sort_black_24dp
+ - @drawable/ic_help_black_24dp
- @color/light_separator_color
- @color/light_contrast_background_color
- @drawable/light_checked_selector
+ - @drawable/light_focused_selector
- @color/light_queue_background_color
- @drawable/toolbar_shadow_light
- @drawable/light_selector
- @color/light_ripple_color
- @drawable/progress_youtube_horizontal_light
+ - @color/light_card_item_background_color
+ - @color/light_card_item_contrast_color
+ - @color/light_border_color
+ - @drawable/dashed_border_light
- @style/PreferenceThemeOverlay.v14.Material
@@ -127,15 +167,55 @@
- @drawable/ic_pause_white_24dp
- @drawable/ic_settings_update_white
- @drawable/ic_done_white_24dp
+ - @drawable/ic_refresh_white_24dp
+ - @drawable/ic_computer_white_24dp
+ - @drawable/ic_videogame_white_24dp
+ - @drawable/ic_music_note_white_24dp
+ - @drawable/ic_stars_white_24dp
+ - @drawable/ic_sports_white_24dp
+ - @drawable/ic_money_white_24dp
+ - @drawable/ic_person_white_24dp
+ - @drawable/ic_people_white_24dp
+ - @drawable/ic_heart_white_24dp
+ - @drawable/ic_kids_white_24dp
+ - @drawable/ic_fastfood_white_24dp
+ - @drawable/ic_car_white_24dp
+ - @drawable/ic_motorcycle_white_24dp
+ - @drawable/ic_trending_up_white_24dp
+ - @drawable/ic_school_white_24dp
+ - @drawable/ic_asterisk_white_24dp
+ - @drawable/ic_emoticon_white_24dp
+ - @drawable/ic_edit_white_24dp
+ - @drawable/ic_explore_white_24dp
+ - @drawable/ic_fitness_white_24dp
+ - @drawable/ic_restaurant_white_24dp
+ - @drawable/ic_mic_white_24dp
+ - @drawable/ic_radio_white_24dp
+ - @drawable/ic_shopping_cart_white_24dp
+ - @drawable/ic_watch_later_white_24dp
+ - @drawable/ic_work_white_24dp
+ - @drawable/ic_movie_white_24dp
+ - @drawable/ic_pets_white_24dp
+ - @drawable/ic_world_white_24dp
+ - @drawable/ic_sunny_white_24dp
+ - @drawable/ic_telescope_white_24dp
+ - @drawable/ic_megaphone_white_24dp
+ - @drawable/ic_sort_white_24dp
+ - @drawable/ic_help_white_24dp
- @color/dark_separator_color
- @color/dark_contrast_background_color
- @drawable/dark_checked_selector
+ - @drawable/dark_focused_selector
- @color/dark_queue_background_color
- @drawable/toolbar_shadow_dark
- @drawable/dark_selector
- @color/dark_ripple_color
- @drawable/progress_youtube_horizontal_dark
+ - @color/dark_card_item_background_color
+ - @color/dark_card_item_contrast_color
+ - @color/dark_border_color
+ - @drawable/dashed_border_dark
- @style/PreferenceThemeOverlay.v14.Material
@@ -148,6 +228,11 @@
- @color/black_separator_color
- @color/black_contrast_background_color
+
+ - @color/black_card_item_background_color
+ - @color/black_card_item_contrast_color
+ - @color/black_border_color
+ - @drawable/dashed_border_black
@@ -167,6 +252,22 @@
- @color/dark_dialog_background_color
+
+
+
+