() {
- @Override
- public void onSubscribe(Subscription s) {
- if (feedSubscriber != null) feedSubscriber.cancel();
- feedSubscriber = s;
-
- int requestSize = FEED_LOAD_COUNT - infoListAdapter.getItemsList().size();
- if (wasLoading.getAndSet(false)) requestSize = FEED_LOAD_COUNT;
-
- boolean hasToLoad = requestSize > 0;
- if (hasToLoad) {
- requestLoadedAtomic.set(infoListAdapter.getItemsList().size());
- requestFeed(requestSize);
- }
- isLoading.set(hasToLoad);
- }
-
- @Override
- public void onNext(SubscriptionEntity subscriptionEntity) {
- if (!itemsLoaded.contains(subscriptionEntity.getServiceId() + subscriptionEntity.getUrl())) {
- subscriptionService.getChannelInfo(subscriptionEntity)
- .observeOn(AndroidSchedulers.mainThread())
- .onErrorComplete(
- (@io.reactivex.annotations.NonNull Throwable throwable) ->
- FeedFragment.super.onError(throwable))
- .subscribe(
- getChannelInfoObserver(subscriptionEntity.getServiceId(),
- subscriptionEntity.getUrl()));
- } else {
- requestFeed(1);
- }
- }
-
- @Override
- public void onError(Throwable exception) {
- FeedFragment.this.onError(exception);
- }
-
- @Override
- public void onComplete() {
- if (DEBUG) Log.d(TAG, "getSubscriptionObserver > onComplete() called");
- }
- };
- }
-
- /**
- * On each request, a subscription item from the updated table is transformed
- * into a ChannelInfo, containing the latest streams from the channel.
- *
- * Currently, the feed uses the first into from the list of streams.
- *
- * If chosen feed already displayed, then we request another feed from another
- * subscription, until the subscription table runs out of new items.
- *
- * This Observer is self-contained and will close itself when complete. However, this
- * does not obey the fragment lifecycle and may continue running in the background
- * until it is complete. This is done due to RxJava2 no longer propagate errors once
- * an observer is unsubscribed while the thread process is still running.
- *
- * To solve the above issue, we can either set a global RxJava Error Handler, or
- * manage exceptions case by case. This should be done if the current implementation is
- * too costly when dealing with larger subscription sets.
- *
- * @param url + serviceId to put in {@link #allItemsLoaded} to signal that this specific entity has been loaded.
- */
- private MaybeObserver getChannelInfoObserver(final int serviceId, final String url) {
- return new MaybeObserver() {
- private Disposable observer;
-
- @Override
- public void onSubscribe(Disposable d) {
- observer = d;
- compositeDisposable.add(d);
- isLoading.set(true);
- }
-
- // Called only when response is non-empty
- @Override
- public void onSuccess(final ChannelInfo channelInfo) {
- if (infoListAdapter == null || channelInfo.getRelatedItems().isEmpty()) {
- onDone();
- return;
- }
-
- final InfoItem item = channelInfo.getRelatedItems().get(0);
- // Keep requesting new items if the current one already exists
- boolean itemExists = doesItemExist(infoListAdapter.getItemsList(), item);
- if (!itemExists) {
- infoListAdapter.addInfoItem(item);
- //updateSubscription(channelInfo);
- } else {
- requestFeed(1);
- }
- onDone();
- }
-
- @Override
- public void onError(Throwable exception) {
- showSnackBarError(exception,
- UserAction.SUBSCRIPTION,
- NewPipe.getNameOfService(serviceId),
- url, 0);
- requestFeed(1);
- onDone();
- }
-
- // Called only when response is empty
- @Override
- public void onComplete() {
- onDone();
- }
-
- private void onDone() {
- if (observer.isDisposed()) {
- return;
- }
-
- itemsLoaded.add(serviceId + url);
- compositeDisposable.remove(observer);
-
- int loaded = requestLoadedAtomic.incrementAndGet();
- if (loaded >= Math.min(FEED_LOAD_COUNT, subscriptionPoolSize)) {
- requestLoadedAtomic.set(0);
- isLoading.set(false);
- }
-
- if (itemsLoaded.size() == subscriptionPoolSize) {
- if (DEBUG) Log.d(TAG, "getChannelInfoObserver > All Items Loaded");
- allItemsLoaded.set(true);
- showListFooter(false);
- isLoading.set(false);
- hideLoading();
- if (infoListAdapter.getItemsList().size() == 0) {
- showEmptyState();
- }
- }
- }
- };
- }
-
- @Override
- protected void loadMoreItems() {
- isLoading.set(true);
- delayHandler.removeCallbacksAndMessages(null);
- // Add a little of a delay when requesting more items because the cache is so fast,
- // that the view seems stuck to the user when he scroll to the bottom
- delayHandler.postDelayed(() -> requestFeed(FEED_LOAD_COUNT), 300);
- }
-
- @Override
- protected boolean hasMoreItems() {
- return !allItemsLoaded.get();
- }
-
- private final Handler delayHandler = new Handler();
-
- private void requestFeed(final int count) {
- if (DEBUG) Log.d(TAG, "requestFeed() called with: count = [" + count + "], feedSubscriber = [" + feedSubscriber + "]");
- if (feedSubscriber == null) return;
-
- isLoading.set(true);
- delayHandler.removeCallbacksAndMessages(null);
- feedSubscriber.request(count);
- }
-
- /*//////////////////////////////////////////////////////////////////////////
- // Utils
- //////////////////////////////////////////////////////////////////////////*/
-
- private void resetFragment() {
- if (DEBUG) Log.d(TAG, "resetFragment() called");
- if (subscriptionObserver != null) subscriptionObserver.dispose();
- if (compositeDisposable != null) compositeDisposable.clear();
- if (infoListAdapter != null) infoListAdapter.clearStreamItemList();
-
- delayHandler.removeCallbacksAndMessages(null);
- requestLoadedAtomic.set(0);
- allItemsLoaded.set(false);
- showListFooter(false);
- itemsLoaded.clear();
- }
-
- private void disposeEverything() {
- if (subscriptionObserver != null) subscriptionObserver.dispose();
- if (compositeDisposable != null) compositeDisposable.clear();
- if (feedSubscriber != null) feedSubscriber.cancel();
- delayHandler.removeCallbacksAndMessages(null);
- }
-
- private boolean doesItemExist(final List items, final InfoItem item) {
- for (final InfoItem existingItem : items) {
- if (existingItem.getInfoType() == item.getInfoType() &&
- existingItem.getServiceId() == item.getServiceId() &&
- existingItem.getName().equals(item.getName()) &&
- existingItem.getUrl().equals(item.getUrl())) return true;
- }
- return false;
- }
-
- private int howManyItemsToLoad() {
- int heightPixels = getResources().getDisplayMetrics().heightPixels;
- int itemHeightPixels = activity.getResources().getDimensionPixelSize(R.dimen.video_item_search_height);
-
- int items = itemHeightPixels > 0
- ? heightPixels / itemHeightPixels + OFF_SCREEN_ITEMS_COUNT
- : MIN_ITEMS_INITIAL_LOAD;
- return Math.max(MIN_ITEMS_INITIAL_LOAD, items);
- }
-
- /*//////////////////////////////////////////////////////////////////////////
- // Fragment Error Handling
- //////////////////////////////////////////////////////////////////////////*/
-
- @Override
- public void showError(String message, boolean showRetryButton) {
- resetFragment();
- super.showError(message, showRetryButton);
- }
-
- @Override
- protected boolean onError(Throwable exception) {
- if (super.onError(exception)) return true;
-
- int errorId = exception instanceof ExtractionException
- ? R.string.parsing_error
- : R.string.general_error;
- onUnrecoverableError(exception,
- UserAction.SOMETHING_ELSE,
- "none",
- "Requesting feed",
- errorId);
- return true;
- }
-}
diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/FeedFragment.kt b/app/src/main/java/org/schabi/newpipe/local/feed/FeedFragment.kt
new file mode 100644
index 000000000..d41a2e37b
--- /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(resources.getString(R.string.finish), null)
+ .create()
+ .show()
+ return true
+ }
+
+ return super.onOptionsItemSelected(item)
+ }
+
+ override fun onDestroyOptionsMenu() {
+ super.onDestroyOptionsMenu()
+ activity?.supportActionBar?.subtitle = null
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+ activity?.supportActionBar?.subtitle = null
+ }
+
+ ///////////////////////////////////////////////////////////////////////////
+ // Handling
+ ///////////////////////////////////////////////////////////////////////////
+
+ override fun showLoading() {
+ animateView(refresh_root_view, false, 0)
+ animateView(items_list, false, 0)
+
+ animateView(loading_progress_bar, true, 200)
+ animateView(loading_progress_text, true, 200)
+
+ empty_state_view?.let { animateView(it, false, 0) }
+ animateView(error_panel, false, 0)
+ }
+
+ override fun hideLoading() {
+ animateView(refresh_root_view, true, 200)
+ animateView(items_list, true, 300)
+
+ animateView(loading_progress_bar, false, 0)
+ animateView(loading_progress_text, false, 0)
+
+ empty_state_view?.let { animateView(it, false, 0) }
+ animateView(error_panel, false, 0)
+ }
+
+ override fun showEmptyState() {
+ animateView(refresh_root_view, true, 200)
+ animateView(items_list, false, 0)
+
+ animateView(loading_progress_bar, false, 0)
+ animateView(loading_progress_text, false, 0)
+
+ empty_state_view?.let { animateView(it, true, 800) }
+ animateView(error_panel, false, 0)
+ }
+
+ override fun showError(message: String, showRetryButton: Boolean) {
+ infoListAdapter.clearStreamItemList()
+ animateView(refresh_root_view, false, 120)
+ animateView(items_list, false, 120)
+
+ animateView(loading_progress_bar, false, 120)
+ animateView(loading_progress_text, false, 120)
+
+ error_message_view.text = message
+ animateView(error_button_retry, showRetryButton, if (showRetryButton) 600 else 0)
+ animateView(error_panel, true, 300)
+ }
+
+ override fun handleResult(result: FeedState) {
+ when (result) {
+ is FeedState.ProgressState -> handleProgressState(result)
+ is FeedState.LoadedState -> handleLoadedState(result)
+ is FeedState.ErrorState -> if (handleErrorState(result)) return
+ }
+
+ updateRefreshViewState()
+ }
+
+ private fun handleProgressState(progressState: FeedState.ProgressState) {
+ showLoading()
+
+ val isIndeterminate = progressState.currentProgress == -1 &&
+ progressState.maxProgress == -1
+
+ if (!isIndeterminate) {
+ loading_progress_text.text = "${progressState.currentProgress}/${progressState.maxProgress}"
+ } else if (progressState.progressMessage > 0) {
+ loading_progress_text?.setText(progressState.progressMessage)
+ } else {
+ loading_progress_text?.text = "∞/∞"
+ }
+
+ loading_progress_bar.isIndeterminate = isIndeterminate ||
+ (progressState.maxProgress > 0 && progressState.currentProgress == 0)
+ loading_progress_bar.progress = progressState.currentProgress
+
+ loading_progress_bar.max = progressState.maxProgress
+ }
+
+ private fun handleLoadedState(loadedState: FeedState.LoadedState) {
+ infoListAdapter.setInfoItemList(loadedState.items)
+ listState?.run {
+ items_list.layoutManager?.onRestoreInstanceState(listState)
+ listState = null
+ }
+
+ oldestSubscriptionUpdate = loadedState.oldestUpdate
+
+ if (loadedState.notLoadedCount > 0) {
+ refresh_subtitle_text.visibility = View.VISIBLE
+ refresh_subtitle_text.text = getString(R.string.feed_subscription_not_loaded_count, loadedState.notLoadedCount)
+ } else {
+ refresh_subtitle_text.visibility = View.GONE
+ }
+
+ if (loadedState.itemsErrors.isNotEmpty()) {
+ showSnackBarError(loadedState.itemsErrors, UserAction.REQUESTED_FEED,
+ "none", "Loading feed", R.string.general_error)
+ }
+
+ if (loadedState.items.isEmpty()) {
+ showEmptyState()
+ } else {
+ hideLoading()
+ }
+ }
+
+
+ private fun handleErrorState(errorState: FeedState.ErrorState): Boolean {
+ hideLoading()
+ errorState.error?.let {
+ onError(errorState.error)
+ return true
+ }
+ return false
+ }
+
+ private fun updateRelativeTimeViews() {
+ updateRefreshViewState()
+ infoListAdapter.notifyDataSetChanged()
+ }
+
+ private fun updateRefreshViewState() {
+ val oldestSubscriptionUpdateText = when {
+ oldestSubscriptionUpdate != null -> Localization.relativeTime(oldestSubscriptionUpdate!!)
+ else -> "—"
+ }
+
+ refresh_text?.text = getString(R.string.feed_oldest_subscription_update, oldestSubscriptionUpdateText)
+ }
+
+ ///////////////////////////////////////////////////////////////////////////
+ // Load Service Handling
+ ///////////////////////////////////////////////////////////////////////////
+
+ override fun doInitialLoadLogic() {}
+ override fun reloadContent() = triggerUpdate()
+ override fun loadMoreItems() {}
+ override fun hasMoreItems() = false
+
+ private fun triggerUpdate() {
+ getActivity()?.startService(Intent(requireContext(), FeedLoadService::class.java).apply {
+ putExtra(FeedLoadService.EXTRA_GROUP_ID, groupId)
+ })
+ listState = null
+ }
+
+ override fun onError(exception: Throwable): Boolean {
+ if (super.onError(exception)) return true
+
+ if (useAsFrontPage) {
+ showSnackBarError(exception, UserAction.REQUESTED_FEED, "none", "Loading Feed", 0)
+ return true
+ }
+
+ onUnrecoverableError(exception, UserAction.REQUESTED_FEED, "none", "Loading Feed", 0)
+ return true
+ }
+
+ companion object {
+ const val KEY_GROUP_ID = "ARG_GROUP_ID"
+ const val KEY_GROUP_NAME = "ARG_GROUP_NAME"
+
+ @JvmStatic
+ fun newInstance(groupId: Long = FeedGroupEntity.GROUP_ALL_ID, groupName: String? = null): FeedFragment {
+ val feedFragment = FeedFragment()
+
+ feedFragment.arguments = Bundle().apply {
+ putLong(KEY_GROUP_ID, groupId)
+ putString(KEY_GROUP_NAME, groupName)
+ }
+
+ return feedFragment
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/FeedState.kt b/app/src/main/java/org/schabi/newpipe/local/feed/FeedState.kt
new file mode 100644
index 000000000..c37d6a0b3
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/local/feed/FeedState.kt
@@ -0,0 +1,24 @@
+package org.schabi.newpipe.local.feed
+
+import androidx.annotation.StringRes
+import org.schabi.newpipe.extractor.stream.StreamInfoItem
+import java.util.*
+
+sealed class FeedState {
+ data class ProgressState(
+ val currentProgress: Int = -1,
+ val maxProgress: Int = -1,
+ @StringRes val progressMessage: Int = 0
+ ) : FeedState()
+
+ data class LoadedState(
+ val items: List,
+ val oldestUpdate: Calendar? = null,
+ val notLoadedCount: Long,
+ val itemsErrors: List = emptyList()
+ ) : FeedState()
+
+ data class ErrorState(
+ val error: Throwable? = null
+ ) : FeedState()
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/FeedViewModel.kt b/app/src/main/java/org/schabi/newpipe/local/feed/FeedViewModel.kt
new file mode 100644
index 000000000..adc262ecb
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/local/feed/FeedViewModel.kt
@@ -0,0 +1,71 @@
+package org.schabi.newpipe.local.feed
+
+import android.content.Context
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.ViewModelProvider
+import io.reactivex.Flowable
+import io.reactivex.android.schedulers.AndroidSchedulers
+import io.reactivex.functions.Function4
+import io.reactivex.schedulers.Schedulers
+import org.schabi.newpipe.database.feed.model.FeedGroupEntity
+import org.schabi.newpipe.extractor.stream.StreamInfoItem
+import org.schabi.newpipe.local.feed.service.FeedEventManager
+import org.schabi.newpipe.local.feed.service.FeedEventManager.Event.*
+import org.schabi.newpipe.util.DEFAULT_THROTTLE_TIMEOUT
+import java.util.*
+import java.util.concurrent.TimeUnit
+
+class FeedViewModel(applicationContext: Context, val groupId: Long = FeedGroupEntity.GROUP_ALL_ID) : ViewModel() {
+ class Factory(val context: Context, val groupId: Long = FeedGroupEntity.GROUP_ALL_ID) : ViewModelProvider.Factory {
+ @Suppress("UNCHECKED_CAST")
+ override fun create(modelClass: Class): T {
+ return FeedViewModel(context.applicationContext, groupId) as T
+ }
+ }
+
+ private var feedDatabaseManager: FeedDatabaseManager = FeedDatabaseManager(applicationContext)
+
+ private val mutableStateLiveData = MutableLiveData()
+ val stateLiveData: LiveData = mutableStateLiveData
+
+ private var combineDisposable = Flowable
+ .combineLatest(
+ FeedEventManager.events(),
+ feedDatabaseManager.asStreamItems(groupId),
+ feedDatabaseManager.notLoadedCount(groupId),
+ feedDatabaseManager.oldestSubscriptionUpdate(groupId),
+
+ Function4 { t1: FeedEventManager.Event, t2: List, t3: Long, t4: List ->
+ return@Function4 CombineResultHolder(t1, t2, t3, t4.firstOrNull())
+ }
+ )
+ .throttleLatest(DEFAULT_THROTTLE_TIMEOUT, TimeUnit.MILLISECONDS)
+ .subscribeOn(Schedulers.io())
+ .observeOn(AndroidSchedulers.mainThread())
+ .subscribe {
+ val (event, listFromDB, notLoadedCount, oldestUpdate) = it
+
+ val oldestUpdateCalendar =
+ oldestUpdate?.let { Calendar.getInstance().apply { time = it } }
+
+ mutableStateLiveData.postValue(when (event) {
+ is IdleEvent -> FeedState.LoadedState(listFromDB, oldestUpdateCalendar, notLoadedCount)
+ is ProgressEvent -> FeedState.ProgressState(event.currentProgress, event.maxProgress, event.progressMessage)
+ is SuccessResultEvent -> FeedState.LoadedState(listFromDB, oldestUpdateCalendar, notLoadedCount, event.itemsErrors)
+ is ErrorResultEvent -> FeedState.ErrorState(event.error)
+ })
+
+ if (event is ErrorResultEvent || event is SuccessResultEvent) {
+ FeedEventManager.reset()
+ }
+ }
+
+ override fun onCleared() {
+ super.onCleared()
+ combineDisposable.dispose()
+ }
+
+ private data class CombineResultHolder(val t1: FeedEventManager.Event, val t2: List, val t3: Long, val t4: Date?)
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedEventManager.kt b/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedEventManager.kt
new file mode 100644
index 000000000..e9012ff37
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedEventManager.kt
@@ -0,0 +1,38 @@
+package org.schabi.newpipe.local.feed.service
+
+import androidx.annotation.StringRes
+import io.reactivex.Flowable
+import io.reactivex.processors.BehaviorProcessor
+import org.schabi.newpipe.local.feed.service.FeedEventManager.Event.IdleEvent
+import java.util.concurrent.atomic.AtomicBoolean
+
+object FeedEventManager {
+ private var processor: BehaviorProcessor = BehaviorProcessor.create()
+ private var ignoreUpstream = AtomicBoolean()
+ private var eventsFlowable = processor.startWith(IdleEvent)
+
+ fun postEvent(event: Event) {
+ processor.onNext(event)
+ }
+
+ fun events(): Flowable {
+ return eventsFlowable.filter { !ignoreUpstream.get() }
+ }
+
+ fun reset() {
+ ignoreUpstream.set(true)
+ postEvent(IdleEvent)
+ ignoreUpstream.set(false)
+ }
+
+ sealed class Event {
+ object IdleEvent : Event()
+ data class ProgressEvent(val currentProgress: Int = -1, val maxProgress: Int = -1, @StringRes val progressMessage: Int = 0) : Event() {
+ constructor(@StringRes progressMessage: Int) : this(-1, -1, progressMessage)
+ }
+
+ data class SuccessResultEvent(val itemsErrors: List = emptyList()) : Event()
+ data class ErrorResultEvent(val error: Throwable) : Event()
+ }
+
+}
diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedLoadService.kt b/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedLoadService.kt
new file mode 100644
index 000000000..294a7fcd5
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedLoadService.kt
@@ -0,0 +1,464 @@
+/*
+ * Copyright 2019 Mauricio Colli
+ * FeedLoadService.kt is part of NewPipe
+ *
+ * License: GPL-3.0+
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package org.schabi.newpipe.local.feed.service
+
+import android.app.PendingIntent
+import android.app.Service
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.os.Build
+import android.os.IBinder
+import android.preference.PreferenceManager
+import android.util.Log
+import androidx.core.app.NotificationCompat
+import androidx.core.app.NotificationManagerCompat
+import io.reactivex.Flowable
+import io.reactivex.Notification
+import io.reactivex.Single
+import io.reactivex.android.schedulers.AndroidSchedulers
+import io.reactivex.disposables.CompositeDisposable
+import io.reactivex.functions.Consumer
+import io.reactivex.functions.Function
+import io.reactivex.processors.PublishProcessor
+import io.reactivex.schedulers.Schedulers
+import org.reactivestreams.Subscriber
+import org.reactivestreams.Subscription
+import org.schabi.newpipe.MainActivity.DEBUG
+import org.schabi.newpipe.R
+import org.schabi.newpipe.database.feed.model.FeedGroupEntity
+import org.schabi.newpipe.extractor.ListInfo
+import org.schabi.newpipe.extractor.exceptions.ReCaptchaException
+import org.schabi.newpipe.extractor.stream.StreamInfoItem
+import org.schabi.newpipe.local.feed.FeedDatabaseManager
+import org.schabi.newpipe.local.feed.service.FeedEventManager.Event.*
+import org.schabi.newpipe.local.feed.service.FeedEventManager.postEvent
+import org.schabi.newpipe.local.subscription.SubscriptionManager
+import org.schabi.newpipe.util.ExtractorHelper
+import java.io.IOException
+import java.util.*
+import java.util.concurrent.TimeUnit
+import java.util.concurrent.atomic.AtomicBoolean
+import java.util.concurrent.atomic.AtomicInteger
+import kotlin.collections.ArrayList
+
+class FeedLoadService : Service() {
+ companion object {
+ private val TAG = FeedLoadService::class.java.simpleName
+ private const val NOTIFICATION_ID = 7293450
+ private const val ACTION_CANCEL = "org.schabi.newpipe.local.feed.service.FeedLoadService.CANCEL"
+
+ /**
+ * How often the notification will be updated.
+ */
+ private const val NOTIFICATION_SAMPLING_PERIOD = 1500
+
+ /**
+ * How many extractions will be running in parallel.
+ */
+ private const val PARALLEL_EXTRACTIONS = 6
+
+ /**
+ * Number of items to buffer to mass-insert in the database.
+ */
+ private const val BUFFER_COUNT_BEFORE_INSERT = 20
+
+ const val EXTRA_GROUP_ID: String = "FeedLoadService.EXTRA_GROUP_ID"
+ }
+
+ private var loadingSubscription: Subscription? = null
+ private lateinit var subscriptionManager: SubscriptionManager
+
+ private lateinit var feedDatabaseManager: FeedDatabaseManager
+ private lateinit var feedResultsHolder: ResultsHolder
+
+ private var disposables = CompositeDisposable()
+ private var notificationUpdater = PublishProcessor.create()
+
+ ///////////////////////////////////////////////////////////////////////////
+ // Lifecycle
+ ///////////////////////////////////////////////////////////////////////////
+
+ override fun onCreate() {
+ super.onCreate()
+ subscriptionManager = SubscriptionManager(this)
+ feedDatabaseManager = FeedDatabaseManager(this)
+ }
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ if (DEBUG) {
+ Log.d(TAG, "onStartCommand() called with: intent = [" + intent + "]," +
+ " flags = [" + flags + "], startId = [" + startId + "]")
+ }
+
+ if (intent == null || loadingSubscription != null) {
+ return START_NOT_STICKY
+ }
+
+ setupNotification()
+ setupBroadcastReceiver()
+ val defaultSharedPreferences = PreferenceManager.getDefaultSharedPreferences(this)
+
+ val groupId = intent.getLongExtra(EXTRA_GROUP_ID, FeedGroupEntity.GROUP_ALL_ID)
+ val useFeedExtractor = defaultSharedPreferences
+ .getBoolean(getString(R.string.feed_use_dedicated_fetch_method_key), false)
+
+ val thresholdOutdatedSecondsString = defaultSharedPreferences
+ .getString(getString(R.string.feed_update_threshold_key), getString(R.string.feed_update_threshold_default_value))
+ val thresholdOutdatedSeconds = thresholdOutdatedSecondsString!!.toInt()
+
+ startLoading(groupId, useFeedExtractor, thresholdOutdatedSeconds)
+
+ return START_NOT_STICKY
+ }
+
+ private fun disposeAll() {
+ unregisterReceiver(broadcastReceiver)
+
+ loadingSubscription?.cancel()
+ loadingSubscription = null
+
+ disposables.dispose()
+ }
+
+ private fun stopService() {
+ disposeAll()
+ stopForeground(true)
+ notificationManager.cancel(NOTIFICATION_ID)
+ stopSelf()
+ }
+
+ override fun onBind(intent: Intent): IBinder? {
+ return null
+ }
+
+ ///////////////////////////////////////////////////////////////////////////
+ // Loading & Handling
+ ///////////////////////////////////////////////////////////////////////////
+
+ private class RequestException(val subscriptionId: Long, message: String, cause: Throwable) : Exception(message, cause) {
+ companion object {
+ fun wrapList(subscriptionId: Long, info: ListInfo): List {
+ val toReturn = ArrayList(info.errors.size)
+ for (error in info.errors) {
+ toReturn.add(RequestException(subscriptionId, info.serviceId.toString() + ":" + info.url, error))
+ }
+ return toReturn
+ }
+ }
+ }
+
+ private fun startLoading(groupId: Long = FeedGroupEntity.GROUP_ALL_ID, useFeedExtractor: Boolean, thresholdOutdatedSeconds: Int) {
+ feedResultsHolder = ResultsHolder()
+
+ val outdatedThreshold = Calendar.getInstance().apply {
+ add(Calendar.SECOND, -thresholdOutdatedSeconds)
+ }.time
+
+ val subscriptions = when (groupId) {
+ FeedGroupEntity.GROUP_ALL_ID -> feedDatabaseManager.outdatedSubscriptions(outdatedThreshold)
+ else -> feedDatabaseManager.outdatedSubscriptionsForGroup(groupId, outdatedThreshold)
+ }
+
+ subscriptions
+ .limit(1)
+
+ .doOnNext {
+ currentProgress.set(0)
+ maxProgress.set(it.size)
+ }
+ .filter { it.isNotEmpty() }
+
+ .observeOn(AndroidSchedulers.mainThread())
+ .doOnNext {
+ startForeground(NOTIFICATION_ID, notificationBuilder.build())
+ updateNotificationProgress(null)
+ broadcastProgress()
+ }
+
+ .observeOn(Schedulers.io())
+ .flatMap { Flowable.fromIterable(it) }
+ .takeWhile { !cancelSignal.get() }
+
+ .parallel(PARALLEL_EXTRACTIONS, PARALLEL_EXTRACTIONS * 2)
+ .runOn(Schedulers.io(), PARALLEL_EXTRACTIONS * 2)
+ .filter { !cancelSignal.get() }
+
+ .map { subscriptionEntity ->
+ try {
+ val listInfo = if (useFeedExtractor) {
+ ExtractorHelper
+ .getFeedInfoFallbackToChannelInfo(subscriptionEntity.serviceId, subscriptionEntity.url)
+ .blockingGet()
+ } else {
+ ExtractorHelper
+ .getChannelInfo(subscriptionEntity.serviceId, subscriptionEntity.url, true)
+ .blockingGet()
+ } as ListInfo
+
+ return@map Notification.createOnNext(Pair(subscriptionEntity.uid, listInfo))
+ } catch (e: Throwable) {
+ val request = "${subscriptionEntity.serviceId}:${subscriptionEntity.url}"
+ val wrapper = RequestException(subscriptionEntity.uid, request, e)
+ return@map Notification.createOnError>>(wrapper)
+ }
+ }
+ .sequential()
+
+ .observeOn(AndroidSchedulers.mainThread())
+ .doOnNext(errorHandlingConsumer)
+
+ .observeOn(AndroidSchedulers.mainThread())
+ .doOnNext(notificationsConsumer)
+
+ .observeOn(Schedulers.io())
+ .buffer(BUFFER_COUNT_BEFORE_INSERT)
+ .doOnNext(databaseConsumer)
+
+ .subscribeOn(Schedulers.io())
+ .observeOn(AndroidSchedulers.mainThread())
+ .subscribe(resultSubscriber)
+ }
+
+ private fun broadcastProgress() {
+ postEvent(ProgressEvent(currentProgress.get(), maxProgress.get()))
+ }
+
+ private val resultSubscriber
+ get() = object : Subscriber>>>> {
+
+ override fun onSubscribe(s: Subscription) {
+ loadingSubscription = s
+ s.request(java.lang.Long.MAX_VALUE)
+ }
+
+ override fun onNext(notification: List>>>) {
+ if (DEBUG) Log.v(TAG, "onNext() → $notification")
+ }
+
+ override fun onError(error: Throwable) {
+ handleError(error)
+ }
+
+ override fun onComplete() {
+ if (maxProgress.get() == 0) {
+ postEvent(IdleEvent)
+ stopService()
+
+ return
+ }
+
+ currentProgress.set(-1)
+ maxProgress.set(-1)
+
+ notificationUpdater.onNext(getString(R.string.feed_processing_message))
+ postEvent(ProgressEvent(R.string.feed_processing_message))
+
+ disposables.add(Single
+ .fromCallable {
+ feedResultsHolder.ready()
+
+ postEvent(ProgressEvent(R.string.feed_processing_message))
+ feedDatabaseManager.removeOrphansOrOlderStreams()
+
+ postEvent(SuccessResultEvent(feedResultsHolder.itemsErrors))
+ true
+ }
+ .subscribeOn(Schedulers.io())
+ .observeOn(AndroidSchedulers.mainThread())
+ .subscribe { _, throwable ->
+ if (throwable != null) {
+ Log.e(TAG, "Error while storing result", throwable)
+ handleError(throwable)
+ return@subscribe
+ }
+ stopService()
+ })
+ }
+ }
+
+ private val databaseConsumer: Consumer>>>>
+ get() = Consumer {
+ feedDatabaseManager.database().runInTransaction {
+ for (notification in it) {
+
+ if (notification.isOnNext) {
+ val subscriptionId = notification.value!!.first
+ val info = notification.value!!.second
+
+ feedDatabaseManager.upsertAll(subscriptionId, info.relatedItems)
+ subscriptionManager.updateFromInfo(subscriptionId, info)
+
+ if (info.errors.isNotEmpty()) {
+ feedResultsHolder.addErrors(RequestException.wrapList(subscriptionId, info))
+ feedDatabaseManager.markAsOutdated(subscriptionId)
+ }
+
+ } else if (notification.isOnError) {
+ val error = notification.error!!
+ feedResultsHolder.addError(error)
+
+ if (error is RequestException) {
+ feedDatabaseManager.markAsOutdated(error.subscriptionId)
+ }
+ }
+ }
+ }
+ }
+
+
+ private val errorHandlingConsumer: Consumer>>>
+ get() = Consumer {
+ if (it.isOnError) {
+ var error = it.error!!
+ if (error is RequestException) error = error.cause!!
+ val cause = error.cause
+
+ when {
+ error is IOException -> throw error
+ cause is IOException -> throw cause
+
+ error is ReCaptchaException -> throw error
+ cause is ReCaptchaException -> throw cause
+ }
+ }
+ }
+
+ private val notificationsConsumer: Consumer>>>
+ get() = Consumer { onItemCompleted(it.value?.second?.name) }
+
+ private fun onItemCompleted(updateDescription: String?) {
+ currentProgress.incrementAndGet()
+ notificationUpdater.onNext(updateDescription ?: "")
+
+ broadcastProgress()
+ }
+
+ ///////////////////////////////////////////////////////////////////////////
+ // Notification
+ ///////////////////////////////////////////////////////////////////////////
+
+ private lateinit var notificationManager: NotificationManagerCompat
+ private lateinit var notificationBuilder: NotificationCompat.Builder
+
+ private var currentProgress = AtomicInteger(-1)
+ private var maxProgress = AtomicInteger(-1)
+
+ private fun createNotification(): NotificationCompat.Builder {
+ val cancelActionIntent = PendingIntent.getBroadcast(this,
+ NOTIFICATION_ID, Intent(ACTION_CANCEL), 0)
+
+ return NotificationCompat.Builder(this, getString(R.string.notification_channel_id))
+ .setOngoing(true)
+ .setProgress(-1, -1, true)
+ .setSmallIcon(R.drawable.ic_newpipe_triangle_white)
+ .setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
+ .addAction(0, getString(R.string.cancel), cancelActionIntent)
+ .setContentTitle(getString(R.string.feed_notification_loading))
+ }
+
+ private fun setupNotification() {
+ notificationManager = NotificationManagerCompat.from(this)
+ notificationBuilder = createNotification()
+
+ val throttleAfterFirstEmission = Function { flow: Flowable ->
+ flow.limit(1).concatWith(flow.skip(1).throttleLatest(NOTIFICATION_SAMPLING_PERIOD.toLong(), TimeUnit.MILLISECONDS))
+ }
+
+ disposables.add(notificationUpdater
+ .publish(throttleAfterFirstEmission)
+ .observeOn(AndroidSchedulers.mainThread())
+ .subscribe(this::updateNotificationProgress))
+ }
+
+ private fun updateNotificationProgress(updateDescription: String?) {
+ notificationBuilder.setProgress(maxProgress.get(), currentProgress.get(), maxProgress.get() == -1)
+
+ if (maxProgress.get() == -1) {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) notificationBuilder.setContentInfo(null)
+ if (!updateDescription.isNullOrEmpty()) notificationBuilder.setContentText(updateDescription)
+ notificationBuilder.setContentText(updateDescription)
+ } else {
+ val progressText = this.currentProgress.toString() + "/" + maxProgress
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
+ if (!updateDescription.isNullOrEmpty()) notificationBuilder.setContentText("$updateDescription ($progressText)")
+ } else {
+ notificationBuilder.setContentInfo(progressText)
+ if (!updateDescription.isNullOrEmpty()) notificationBuilder.setContentText(updateDescription)
+ }
+ }
+
+ notificationManager.notify(NOTIFICATION_ID, notificationBuilder.build())
+ }
+
+ ///////////////////////////////////////////////////////////////////////////
+ // Notification Actions
+ ///////////////////////////////////////////////////////////////////////////
+
+ private lateinit var broadcastReceiver: BroadcastReceiver
+ private val cancelSignal = AtomicBoolean()
+
+ private fun setupBroadcastReceiver() {
+ broadcastReceiver = object : BroadcastReceiver() {
+ override fun onReceive(context: Context?, intent: Intent?) {
+ if (intent?.action == ACTION_CANCEL) {
+ cancelSignal.set(true)
+ }
+ }
+ }
+ registerReceiver(broadcastReceiver, IntentFilter(ACTION_CANCEL))
+ }
+
+ ///////////////////////////////////////////////////////////////////////////
+ // Error handling
+ ///////////////////////////////////////////////////////////////////////////
+
+ private fun handleError(error: Throwable) {
+ postEvent(ErrorResultEvent(error))
+ stopService()
+ }
+
+ ///////////////////////////////////////////////////////////////////////////
+ // Results Holder
+ ///////////////////////////////////////////////////////////////////////////
+
+ class ResultsHolder {
+ /**
+ * List of errors that may have happen during loading.
+ */
+ internal lateinit var itemsErrors: List
+
+ private val itemsErrorsHolder: MutableList = ArrayList()
+
+ fun addError(error: Throwable) {
+ itemsErrorsHolder.add(error)
+ }
+
+ fun addErrors(errors: List) {
+ itemsErrorsHolder.addAll(errors)
+ }
+
+ fun ready() {
+ itemsErrors = itemsErrorsHolder.toList()
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/schabi/newpipe/local/history/HistoryRecordManager.java b/app/src/main/java/org/schabi/newpipe/local/history/HistoryRecordManager.java
index d84fe0195..d208f92b3 100644
--- a/app/src/main/java/org/schabi/newpipe/local/history/HistoryRecordManager.java
+++ b/app/src/main/java/org/schabi/newpipe/local/history/HistoryRecordManager.java
@@ -269,11 +269,11 @@ public class HistoryRecordManager {
for (LocalItem item : items) {
long streamId;
if (item instanceof StreamStatisticsEntry) {
- streamId = ((StreamStatisticsEntry) item).streamId;
+ streamId = ((StreamStatisticsEntry) item).getStreamId();
} else if (item instanceof PlaylistStreamEntity) {
streamId = ((PlaylistStreamEntity) item).getStreamUid();
} else if (item instanceof PlaylistStreamEntry) {
- streamId = ((PlaylistStreamEntry) item).streamId;
+ streamId = ((PlaylistStreamEntry) item).getStreamId();
} else {
result.add(null);
continue;
diff --git a/app/src/main/java/org/schabi/newpipe/local/history/StatisticsPlaylistFragment.java b/app/src/main/java/org/schabi/newpipe/local/history/StatisticsPlaylistFragment.java
index 31ae70954..a54c2a9a4 100644
--- a/app/src/main/java/org/schabi/newpipe/local/history/StatisticsPlaylistFragment.java
+++ b/app/src/main/java/org/schabi/newpipe/local/history/StatisticsPlaylistFragment.java
@@ -76,11 +76,11 @@ public class StatisticsPlaylistFragment
switch (sortMode) {
case LAST_PLAYED:
Collections.sort(results, (left, right) ->
- right.latestAccessDate.compareTo(left.latestAccessDate));
+ right.getLatestAccessDate().compareTo(left.getLatestAccessDate()));
return results;
case MOST_PLAYED:
Collections.sort(results, (left, right) ->
- Long.compare(right.watchCount, left.watchCount));
+ Long.compare(right.getWatchCount(), left.getWatchCount()));
return results;
default: return null;
}
@@ -153,9 +153,9 @@ public class StatisticsPlaylistFragment
if (selectedItem instanceof StreamStatisticsEntry) {
final StreamStatisticsEntry item = (StreamStatisticsEntry) selectedItem;
NavigationHelper.openVideoDetailFragment(getFM(),
- item.serviceId,
- item.url,
- item.title);
+ item.getStreamEntity().getServiceId(),
+ item.getStreamEntity().getUrl(),
+ item.getStreamEntity().getTitle());
}
}
@@ -402,7 +402,7 @@ public class StatisticsPlaylistFragment
.get(index);
if(infoItem instanceof StreamStatisticsEntry) {
final StreamStatisticsEntry entry = (StreamStatisticsEntry) infoItem;
- final Disposable onDelete = recordManager.deleteStreamHistory(entry.streamId)
+ final Disposable onDelete = recordManager.deleteStreamHistory(entry.getStreamId())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
howManyDeleted -> {
diff --git a/app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistStreamItemHolder.java b/app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistStreamItemHolder.java
index 30cc6de32..7eef3e67e 100644
--- a/app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistStreamItemHolder.java
+++ b/app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistStreamItemHolder.java
@@ -52,12 +52,12 @@ public class LocalPlaylistStreamItemHolder extends LocalItemHolder {
if (!(localItem instanceof PlaylistStreamEntry)) return;
final PlaylistStreamEntry item = (PlaylistStreamEntry) localItem;
- itemVideoTitleView.setText(item.title);
- itemAdditionalDetailsView.setText(Localization.concatenateStrings(item.uploader,
- NewPipe.getNameOfService(item.serviceId)));
+ itemVideoTitleView.setText(item.getStreamEntity().getTitle());
+ itemAdditionalDetailsView.setText(Localization.concatenateStrings(item.getStreamEntity().getUploader(),
+ NewPipe.getNameOfService(item.getStreamEntity().getServiceId())));
- if (item.duration > 0) {
- itemDurationView.setText(Localization.getDurationString(item.duration));
+ if (item.getStreamEntity().getDuration() > 0) {
+ itemDurationView.setText(Localization.getDurationString(item.getStreamEntity().getDuration()));
itemDurationView.setBackgroundColor(ContextCompat.getColor(itemBuilder.getContext(),
R.color.duration_background_color));
itemDurationView.setVisibility(View.VISIBLE);
@@ -65,7 +65,7 @@ public class LocalPlaylistStreamItemHolder extends LocalItemHolder {
StreamStateEntity state = historyRecordManager.loadLocalStreamStateBatch(new ArrayList() {{ add(localItem); }}).blockingGet().get(0);
if (state != null) {
itemProgressView.setVisibility(View.VISIBLE);
- itemProgressView.setMax((int) item.duration);
+ itemProgressView.setMax((int) item.getStreamEntity().getDuration());
itemProgressView.setProgress((int) TimeUnit.MILLISECONDS.toSeconds(state.getProgressTime()));
} else {
itemProgressView.setVisibility(View.GONE);
@@ -75,7 +75,7 @@ public class LocalPlaylistStreamItemHolder extends LocalItemHolder {
}
// Default thumbnail is shown on error, while loading and if the url is empty
- itemBuilder.displayImage(item.thumbnailUrl, itemThumbnailView,
+ itemBuilder.displayImage(item.getStreamEntity().getThumbnailUrl(), itemThumbnailView,
ImageDisplayConstants.DISPLAY_THUMBNAIL_OPTIONS);
itemView.setOnClickListener(view -> {
@@ -102,8 +102,8 @@ public class LocalPlaylistStreamItemHolder extends LocalItemHolder {
final PlaylistStreamEntry item = (PlaylistStreamEntry) localItem;
StreamStateEntity state = historyRecordManager.loadLocalStreamStateBatch(new ArrayList() {{ add(localItem); }}).blockingGet().get(0);
- if (state != null && item.duration > 0) {
- itemProgressView.setMax((int) item.duration);
+ if (state != null && item.getStreamEntity().getDuration() > 0) {
+ itemProgressView.setMax((int) item.getStreamEntity().getDuration());
if (itemProgressView.getVisibility() == View.VISIBLE) {
itemProgressView.setProgressAnimated((int) TimeUnit.MILLISECONDS.toSeconds(state.getProgressTime()));
} else {
diff --git a/app/src/main/java/org/schabi/newpipe/local/holder/LocalStatisticStreamItemHolder.java b/app/src/main/java/org/schabi/newpipe/local/holder/LocalStatisticStreamItemHolder.java
index 75fbf13ea..77f947031 100644
--- a/app/src/main/java/org/schabi/newpipe/local/holder/LocalStatisticStreamItemHolder.java
+++ b/app/src/main/java/org/schabi/newpipe/local/holder/LocalStatisticStreamItemHolder.java
@@ -71,9 +71,9 @@ public class LocalStatisticStreamItemHolder extends LocalItemHolder {
private String getStreamInfoDetailLine(final StreamStatisticsEntry entry,
final DateFormat dateFormat) {
final String watchCount = Localization.shortViewCount(itemBuilder.getContext(),
- entry.watchCount);
- final String uploadDate = dateFormat.format(entry.latestAccessDate);
- final String serviceName = NewPipe.getNameOfService(entry.serviceId);
+ entry.getWatchCount());
+ final String uploadDate = dateFormat.format(entry.getLatestAccessDate());
+ final String serviceName = NewPipe.getNameOfService(entry.getStreamEntity().getServiceId());
return Localization.concatenateStrings(watchCount, uploadDate, serviceName);
}
@@ -82,11 +82,11 @@ public class LocalStatisticStreamItemHolder extends LocalItemHolder {
if (!(localItem instanceof StreamStatisticsEntry)) return;
final StreamStatisticsEntry item = (StreamStatisticsEntry) localItem;
- itemVideoTitleView.setText(item.title);
- itemUploaderView.setText(item.uploader);
+ itemVideoTitleView.setText(item.getStreamEntity().getTitle());
+ itemUploaderView.setText(item.getStreamEntity().getUploader());
- if (item.duration > 0) {
- itemDurationView.setText(Localization.getDurationString(item.duration));
+ if (item.getStreamEntity().getDuration() > 0) {
+ itemDurationView.setText(Localization.getDurationString(item.getStreamEntity().getDuration()));
itemDurationView.setBackgroundColor(ContextCompat.getColor(itemBuilder.getContext(),
R.color.duration_background_color));
itemDurationView.setVisibility(View.VISIBLE);
@@ -94,7 +94,7 @@ public class LocalStatisticStreamItemHolder extends LocalItemHolder {
StreamStateEntity state = historyRecordManager.loadLocalStreamStateBatch(new ArrayList() {{ add(localItem); }}).blockingGet().get(0);
if (state != null) {
itemProgressView.setVisibility(View.VISIBLE);
- itemProgressView.setMax((int) item.duration);
+ itemProgressView.setMax((int) item.getStreamEntity().getDuration());
itemProgressView.setProgress((int) TimeUnit.MILLISECONDS.toSeconds(state.getProgressTime()));
} else {
itemProgressView.setVisibility(View.GONE);
@@ -109,7 +109,7 @@ public class LocalStatisticStreamItemHolder extends LocalItemHolder {
}
// Default thumbnail is shown on error, while loading and if the url is empty
- itemBuilder.displayImage(item.thumbnailUrl, itemThumbnailView,
+ itemBuilder.displayImage(item.getStreamEntity().getThumbnailUrl(), itemThumbnailView,
ImageDisplayConstants.DISPLAY_THUMBNAIL_OPTIONS);
itemView.setOnClickListener(view -> {
@@ -133,8 +133,8 @@ public class LocalStatisticStreamItemHolder extends LocalItemHolder {
final StreamStatisticsEntry item = (StreamStatisticsEntry) localItem;
StreamStateEntity state = historyRecordManager.loadLocalStreamStateBatch(new ArrayList() {{ add(localItem); }}).blockingGet().get(0);
- if (state != null && item.duration > 0) {
- itemProgressView.setMax((int) item.duration);
+ if (state != null && item.getStreamEntity().getDuration() > 0) {
+ itemProgressView.setMax((int) item.getStreamEntity().getDuration());
if (itemProgressView.getVisibility() == View.VISIBLE) {
itemProgressView.setProgressAnimated((int) TimeUnit.MILLISECONDS.toSeconds(state.getProgressTime()));
} else {
diff --git a/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistFragment.java b/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistFragment.java
index 17599a1ca..dd9958486 100644
--- a/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistFragment.java
+++ b/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistFragment.java
@@ -168,7 +168,7 @@ public class LocalPlaylistFragment extends BaseLocalListFragment streamIds = new ArrayList<>(items.size());
for (final LocalItem item : items) {
if (item instanceof PlaylistStreamEntry) {
- streamIds.add(((PlaylistStreamEntry) item).streamId);
+ streamIds.add(((PlaylistStreamEntry) item).getStreamId());
}
}
@@ -579,7 +579,7 @@ public class LocalPlaylistFragment extends BaseLocalListFragment NavigationHelper.playOnBackgroundPlayer(context, getPlayQueueStartingAt(item), true));
StreamDialogEntry.set_as_playlist_thumbnail.setCustomAction(
- (fragment, infoItemDuplicate) -> changeThumbnailUrl(item.thumbnailUrl));
+ (fragment, infoItemDuplicate) -> changeThumbnailUrl(item.getStreamEntity().getThumbnailUrl()));
StreamDialogEntry.delete.setCustomAction(
(fragment, infoItemDuplicate) -> deleteItem(item));
diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/FeedGroupIcon.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/FeedGroupIcon.kt
new file mode 100644
index 000000000..9ff08c32c
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/local/subscription/FeedGroupIcon.kt
@@ -0,0 +1,63 @@
+package org.schabi.newpipe.local.subscription
+
+import android.content.Context
+import androidx.annotation.AttrRes
+import androidx.annotation.DrawableRes
+import org.schabi.newpipe.R
+import org.schabi.newpipe.util.ThemeHelper
+
+enum class FeedGroupIcon(
+ /**
+ * The id that will be used to store and retrieve icons from some persistent storage (e.g. DB).
+ */
+ val id: Int,
+
+ /**
+ * The attribute that points to a drawable resource. "R.attr" is used here to support multiple themes.
+ */
+ @AttrRes val drawableResourceAttr: Int
+) {
+ ALL(0, R.attr.ic_asterisk),
+ MUSIC(1, R.attr.ic_music_note),
+ EDUCATION(2, R.attr.ic_school),
+ FITNESS(3, R.attr.ic_fitness),
+ SPACE(4, R.attr.ic_telescope),
+ COMPUTER(5, R.attr.ic_computer),
+ GAMING(6, R.attr.ic_videogame),
+ SPORTS(7, R.attr.ic_sports),
+ NEWS(8, R.attr.ic_megaphone),
+ FAVORITES(9, R.attr.ic_heart),
+ CAR(10, R.attr.ic_car),
+ MOTORCYCLE(11, R.attr.ic_motorcycle),
+ TREND(12, R.attr.ic_trending_up),
+ MOVIE(13, R.attr.ic_movie),
+ BACKUP(14, R.attr.ic_backup),
+ ART(15, R.attr.palette),
+ PERSON(16, R.attr.ic_person),
+ PEOPLE(17, R.attr.ic_people),
+ MONEY(18, R.attr.ic_money),
+ KIDS(19, R.attr.ic_kids),
+ FOOD(20, R.attr.ic_fastfood),
+ SMILE(21, R.attr.ic_smile),
+ EXPLORE(22, R.attr.ic_explore),
+ RESTAURANT(23, R.attr.ic_restaurant),
+ MIC(24, R.attr.ic_mic),
+ HEADSET(25, R.attr.audio),
+ RADIO(26, R.attr.ic_radio),
+ SHOPPING_CART(27, R.attr.ic_shopping_cart),
+ WATCH_LATER(28, R.attr.ic_watch_later),
+ WORK(29, R.attr.ic_work),
+ HOT(30, R.attr.ic_hot),
+ CHANNEL(31, R.attr.ic_channel),
+ BOOKMARK(32, R.attr.ic_bookmark),
+ PETS(33, R.attr.ic_pets),
+ WORLD(34, R.attr.ic_world),
+ STAR(35, R.attr.ic_stars),
+ SUN(36, R.attr.ic_sunny),
+ RSS(37, R.attr.rss);
+
+ @DrawableRes
+ fun getDrawableRes(context: Context): Int {
+ return ThemeHelper.resolveResourceIdFromAttr(context, drawableResourceAttr)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionFragment.java b/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionFragment.java
deleted file mode 100644
index bff6c1b3a..000000000
--- a/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionFragment.java
+++ /dev/null
@@ -1,595 +0,0 @@
-package org.schabi.newpipe.local.subscription;
-
-import android.annotation.SuppressLint;
-import android.app.Activity;
-import android.app.AlertDialog;
-import android.content.BroadcastReceiver;
-import android.content.Context;
-import android.content.DialogInterface;
-import android.content.Intent;
-import android.content.IntentFilter;
-import android.content.SharedPreferences;
-import android.content.res.Configuration;
-import android.content.res.Resources;
-import android.graphics.Color;
-import android.graphics.PorterDuff;
-import android.os.Bundle;
-import android.os.Environment;
-import android.os.Parcelable;
-import android.preference.PreferenceManager;
-import androidx.annotation.DrawableRes;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-import androidx.fragment.app.FragmentManager;
-import androidx.localbroadcastmanager.content.LocalBroadcastManager;
-import androidx.appcompat.app.ActionBar;
-import androidx.recyclerview.widget.GridLayoutManager;
-import androidx.recyclerview.widget.LinearLayoutManager;
-import androidx.recyclerview.widget.RecyclerView;
-import android.view.LayoutInflater;
-import android.view.Menu;
-import android.view.MenuInflater;
-import android.view.View;
-import android.view.ViewGroup;
-import android.widget.ImageView;
-import android.widget.TextView;
-import android.widget.Toast;
-
-import com.nononsenseapps.filepicker.Utils;
-
-import org.schabi.newpipe.R;
-import org.schabi.newpipe.database.subscription.SubscriptionEntity;
-import org.schabi.newpipe.extractor.InfoItem;
-import org.schabi.newpipe.extractor.NewPipe;
-import org.schabi.newpipe.extractor.StreamingService;
-import org.schabi.newpipe.extractor.channel.ChannelInfoItem;
-import org.schabi.newpipe.extractor.exceptions.ExtractionException;
-import org.schabi.newpipe.extractor.subscription.SubscriptionExtractor;
-import org.schabi.newpipe.fragments.BaseStateFragment;
-import org.schabi.newpipe.info_list.InfoListAdapter;
-import org.schabi.newpipe.local.subscription.services.SubscriptionsExportService;
-import org.schabi.newpipe.local.subscription.services.SubscriptionsImportService;
-import org.schabi.newpipe.report.UserAction;
-import org.schabi.newpipe.util.FilePickerActivityHelper;
-import org.schabi.newpipe.util.NavigationHelper;
-import org.schabi.newpipe.util.OnClickGesture;
-import org.schabi.newpipe.util.ServiceHelper;
-import org.schabi.newpipe.util.ShareUtils;
-import org.schabi.newpipe.util.ThemeHelper;
-import org.schabi.newpipe.views.CollapsibleView;
-
-import java.io.File;
-import java.text.SimpleDateFormat;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.Date;
-import java.util.List;
-import java.util.Locale;
-
-import icepick.State;
-import io.reactivex.Observer;
-import io.reactivex.android.schedulers.AndroidSchedulers;
-import io.reactivex.disposables.CompositeDisposable;
-import io.reactivex.disposables.Disposable;
-import io.reactivex.schedulers.Schedulers;
-
-import static org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.KEY_MODE;
-import static org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.KEY_VALUE;
-import static org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.PREVIOUS_EXPORT_MODE;
-import static org.schabi.newpipe.util.AnimationUtils.animateRotation;
-import static org.schabi.newpipe.util.AnimationUtils.animateView;
-
-public class SubscriptionFragment extends BaseStateFragment> implements SharedPreferences.OnSharedPreferenceChangeListener {
- private static final int REQUEST_EXPORT_CODE = 666;
- private static final int REQUEST_IMPORT_CODE = 667;
-
- private RecyclerView itemsList;
- @State
- protected Parcelable itemsListState;
- private InfoListAdapter infoListAdapter;
- private int updateFlags = 0;
-
- private static final int LIST_MODE_UPDATE_FLAG = 0x32;
-
- private View whatsNewItemListHeader;
- private View importExportListHeader;
-
- @State
- protected Parcelable importExportOptionsState;
- private CollapsibleView importExportOptions;
-
- private CompositeDisposable disposables = new CompositeDisposable();
- private SubscriptionService subscriptionService;
-
- ///////////////////////////////////////////////////////////////////////////
- // Fragment LifeCycle
- ///////////////////////////////////////////////////////////////////////////
-
- @Override
- public void onCreate(Bundle savedInstanceState) {
- super.onCreate(savedInstanceState);
- setHasOptionsMenu(true);
- PreferenceManager.getDefaultSharedPreferences(activity)
- .registerOnSharedPreferenceChangeListener(this);
- }
-
- @Override
- public void setUserVisibleHint(boolean isVisibleToUser) {
- super.setUserVisibleHint(isVisibleToUser);
- if (activity != null && isVisibleToUser) {
- setTitle(activity.getString(R.string.tab_subscriptions));
- }
- }
-
- @Override
- public void onAttach(Context context) {
- super.onAttach(context);
- infoListAdapter = new InfoListAdapter(activity);
- subscriptionService = SubscriptionService.getInstance(activity);
- }
-
- @Override
- public void onDetach() {
- super.onDetach();
- }
-
- @Nullable
- @Override
- public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, Bundle savedInstanceState) {
- return inflater.inflate(R.layout.fragment_subscription, container, false);
- }
-
- @Override
- public void onResume() {
- super.onResume();
- setupBroadcastReceiver();
- if (updateFlags != 0) {
- if ((updateFlags & LIST_MODE_UPDATE_FLAG) != 0) {
- final boolean useGrid = isGridLayout();
- itemsList.setLayoutManager(useGrid ? getGridLayoutManager() : getListLayoutManager());
- infoListAdapter.setGridItemVariants(useGrid);
- infoListAdapter.notifyDataSetChanged();
- }
- updateFlags = 0;
- }
- }
-
- @Override
- public void onPause() {
- super.onPause();
- itemsListState = itemsList.getLayoutManager().onSaveInstanceState();
- importExportOptionsState = importExportOptions.onSaveInstanceState();
-
- if (subscriptionBroadcastReceiver != null && activity != null) {
- LocalBroadcastManager.getInstance(activity).unregisterReceiver(subscriptionBroadcastReceiver);
- }
- }
-
- @Override
- public void onDestroyView() {
- if (disposables != null) disposables.clear();
-
- super.onDestroyView();
- }
-
- @Override
- public void onDestroy() {
- if (disposables != null) disposables.dispose();
- disposables = null;
- subscriptionService = null;
-
- PreferenceManager.getDefaultSharedPreferences(activity)
- .unregisterOnSharedPreferenceChangeListener(this);
- super.onDestroy();
- }
-
- protected RecyclerView.LayoutManager getListLayoutManager() {
- return new LinearLayoutManager(activity);
- }
-
- protected RecyclerView.LayoutManager getGridLayoutManager() {
- final Resources resources = activity.getResources();
- int width = resources.getDimensionPixelSize(R.dimen.video_item_grid_thumbnail_image_width);
- width += (24 * resources.getDisplayMetrics().density);
- final int spanCount = (int) Math.floor(resources.getDisplayMetrics().widthPixels / (double)width);
- final GridLayoutManager lm = new GridLayoutManager(activity, spanCount);
- lm.setSpanSizeLookup(infoListAdapter.getSpanSizeLookup(spanCount));
- return lm;
- }
-
- /*/////////////////////////////////////////////////////////////////////////
- // Menu
- /////////////////////////////////////////////////////////////////////////*/
-
- @Override
- public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) {
- super.onCreateOptionsMenu(menu, inflater);
-
- ActionBar supportActionBar = activity.getSupportActionBar();
- if (supportActionBar != null) {
- supportActionBar.setDisplayShowTitleEnabled(true);
- setTitle(getString(R.string.tab_subscriptions));
- }
- }
-
- /*//////////////////////////////////////////////////////////////////////////
- // Subscriptions import/export
- //////////////////////////////////////////////////////////////////////////*/
-
- private BroadcastReceiver subscriptionBroadcastReceiver;
-
- private void setupBroadcastReceiver() {
- if (activity == null) return;
-
- if (subscriptionBroadcastReceiver != null) {
- LocalBroadcastManager.getInstance(activity).unregisterReceiver(subscriptionBroadcastReceiver);
- }
-
- final IntentFilter filters = new IntentFilter();
- filters.addAction(SubscriptionsExportService.EXPORT_COMPLETE_ACTION);
- filters.addAction(SubscriptionsImportService.IMPORT_COMPLETE_ACTION);
- subscriptionBroadcastReceiver = new BroadcastReceiver() {
- @Override
- public void onReceive(Context context, Intent intent) {
- if (importExportOptions != null) importExportOptions.collapse();
- }
- };
-
- LocalBroadcastManager.getInstance(activity).registerReceiver(subscriptionBroadcastReceiver, filters);
- }
-
- private View addItemView(final String title, @DrawableRes final int icon, ViewGroup container) {
- final View itemRoot = View.inflate(getContext(), R.layout.subscription_import_export_item, null);
- final TextView titleView = itemRoot.findViewById(android.R.id.text1);
- final ImageView iconView = itemRoot.findViewById(android.R.id.icon1);
-
- titleView.setText(title);
- iconView.setImageResource(icon);
-
- container.addView(itemRoot);
- return itemRoot;
- }
-
- private void setupImportFromItems(final ViewGroup listHolder) {
- final View previousBackupItem = addItemView(getString(R.string.previous_export),
- ThemeHelper.resolveResourceIdFromAttr(getContext(), R.attr.ic_backup), listHolder);
- previousBackupItem.setOnClickListener(item -> onImportPreviousSelected());
-
- final int iconColor = ThemeHelper.isLightThemeSelected(getContext()) ? Color.BLACK : Color.WHITE;
- final String[] services = getResources().getStringArray(R.array.service_list);
- for (String serviceName : services) {
- try {
- final StreamingService service = NewPipe.getService(serviceName);
-
- final SubscriptionExtractor subscriptionExtractor = service.getSubscriptionExtractor();
- if (subscriptionExtractor == null) continue;
-
- final List supportedSources = subscriptionExtractor.getSupportedSources();
- if (supportedSources.isEmpty()) continue;
-
- final View itemView = addItemView(serviceName, ServiceHelper.getIcon(service.getServiceId()), listHolder);
- final ImageView iconView = itemView.findViewById(android.R.id.icon1);
- iconView.setColorFilter(iconColor, PorterDuff.Mode.SRC_IN);
-
- itemView.setOnClickListener(selectedItem -> onImportFromServiceSelected(service.getServiceId()));
- } catch (ExtractionException e) {
- throw new RuntimeException("Services array contains an entry that it's not a valid service name (" + serviceName + ")", e);
- }
- }
- }
-
- private void setupExportToItems(final ViewGroup listHolder) {
- final View previousBackupItem = addItemView(getString(R.string.file), ThemeHelper.resolveResourceIdFromAttr(getContext(), R.attr.ic_save), listHolder);
- previousBackupItem.setOnClickListener(item -> onExportSelected());
- }
-
- private void onImportFromServiceSelected(int serviceId) {
- FragmentManager fragmentManager = getFM();
- NavigationHelper.openSubscriptionsImportFragment(fragmentManager, serviceId);
- }
-
- private void onImportPreviousSelected() {
- startActivityForResult(FilePickerActivityHelper.chooseSingleFile(activity), REQUEST_IMPORT_CODE);
- }
-
- private void onExportSelected() {
- final String date = new SimpleDateFormat("yyyyMMddHHmm", Locale.ENGLISH).format(new Date());
- final String exportName = "newpipe_subscriptions_" + date + ".json";
- final File exportFile = new File(Environment.getExternalStorageDirectory(), exportName);
-
- startActivityForResult(FilePickerActivityHelper.chooseFileToSave(activity, exportFile.getAbsolutePath()), REQUEST_EXPORT_CODE);
- }
-
- @Override
- public void onActivityResult(int requestCode, int resultCode, Intent data) {
- super.onActivityResult(requestCode, resultCode, data);
- if (data != null && data.getData() != null && resultCode == Activity.RESULT_OK) {
- if (requestCode == REQUEST_EXPORT_CODE) {
- final File exportFile = Utils.getFileForUri(data.getData());
- if (!exportFile.getParentFile().canWrite() || !exportFile.getParentFile().canRead()) {
- Toast.makeText(activity, R.string.invalid_directory, Toast.LENGTH_SHORT).show();
- } else {
- activity.startService(new Intent(activity, SubscriptionsExportService.class)
- .putExtra(SubscriptionsExportService.KEY_FILE_PATH, exportFile.getAbsolutePath()));
- }
- } else if (requestCode == REQUEST_IMPORT_CODE) {
- final String path = Utils.getFileForUri(data.getData()).getAbsolutePath();
- ImportConfirmationDialog.show(this, new Intent(activity, SubscriptionsImportService.class)
- .putExtra(KEY_MODE, PREVIOUS_EXPORT_MODE)
- .putExtra(KEY_VALUE, path));
- }
- }
- }
- /*/////////////////////////////////////////////////////////////////////////
- // Fragment Views
- /////////////////////////////////////////////////////////////////////////*/
-
- @Override
- protected void initViews(View rootView, Bundle savedInstanceState) {
- super.initViews(rootView, savedInstanceState);
-
- final boolean useGrid = isGridLayout();
- infoListAdapter = new InfoListAdapter(getActivity());
- itemsList = rootView.findViewById(R.id.items_list);
- itemsList.setLayoutManager(useGrid ? getGridLayoutManager() : getListLayoutManager());
-
- View headerRootLayout;
- infoListAdapter.setHeader(headerRootLayout = activity.getLayoutInflater().inflate(R.layout.subscription_header, itemsList, false));
- whatsNewItemListHeader = headerRootLayout.findViewById(R.id.whats_new);
- importExportListHeader = headerRootLayout.findViewById(R.id.import_export);
- importExportOptions = headerRootLayout.findViewById(R.id.import_export_options);
-
- infoListAdapter.useMiniItemVariants(true);
- infoListAdapter.setGridItemVariants(useGrid);
- itemsList.setAdapter(infoListAdapter);
-
- setupImportFromItems(headerRootLayout.findViewById(R.id.import_from_options));
- setupExportToItems(headerRootLayout.findViewById(R.id.export_to_options));
-
- if (importExportOptionsState != null) {
- importExportOptions.onRestoreInstanceState(importExportOptionsState);
- importExportOptionsState = null;
- }
-
- importExportOptions.addListener(getExpandIconSyncListener(headerRootLayout.findViewById(R.id.import_export_expand_icon)));
- importExportOptions.ready();
- }
-
- private CollapsibleView.StateListener getExpandIconSyncListener(final ImageView iconView) {
- return newState -> animateRotation(iconView, 250, newState == CollapsibleView.COLLAPSED ? 0 : 180);
- }
-
- @Override
- protected void initListeners() {
- super.initListeners();
-
- infoListAdapter.setOnChannelSelectedListener(new OnClickGesture() {
-
- public void selected(ChannelInfoItem selectedItem) {
- final FragmentManager fragmentManager = getFM();
- NavigationHelper.openChannelFragment(fragmentManager,
- selectedItem.getServiceId(),
- selectedItem.getUrl(),
- selectedItem.getName());
- }
-
- public void held(ChannelInfoItem selectedItem) {
- showLongTapDialog(selectedItem);
- }
-
- });
-
- whatsNewItemListHeader.setOnClickListener(v -> {
- FragmentManager fragmentManager = getFM();
- NavigationHelper.openWhatsNewFragment(fragmentManager);
- });
- importExportListHeader.setOnClickListener(v -> importExportOptions.switchState());
- }
-
- private void showLongTapDialog(ChannelInfoItem selectedItem) {
- final Context context = getContext();
- final Activity activity = getActivity();
- if (context == null || context.getResources() == null || getActivity() == null) return;
-
- final String[] commands = new String[]{
- context.getResources().getString(R.string.unsubscribe),
- context.getResources().getString(R.string.share)
- };
-
- final DialogInterface.OnClickListener actions = (dialogInterface, i) -> {
- switch (i) {
- case 0:
- deleteChannel(selectedItem);
- break;
- case 1:
- shareChannel(selectedItem);
- break;
- default:
- break;
- }
- };
-
- final View bannerView = View.inflate(activity, R.layout.dialog_title, null);
- bannerView.setSelected(true);
-
- TextView titleView = bannerView.findViewById(R.id.itemTitleView);
- titleView.setText(selectedItem.getName());
-
- TextView detailsView = bannerView.findViewById(R.id.itemAdditionalDetails);
- detailsView.setVisibility(View.GONE);
-
- new AlertDialog.Builder(activity)
- .setCustomTitle(bannerView)
- .setItems(commands, actions)
- .create()
- .show();
-
- }
-
- private void shareChannel(ChannelInfoItem selectedItem) {
- ShareUtils.shareUrl(getContext(), selectedItem.getName(), selectedItem.getUrl());
- }
-
- @SuppressLint("CheckResult")
- private void deleteChannel(ChannelInfoItem selectedItem) {
- subscriptionService.subscriptionTable()
- .getSubscription(selectedItem.getServiceId(), selectedItem.getUrl())
- .toObservable()
- .observeOn(Schedulers.io())
- .subscribe(getDeleteObserver());
-
- Toast.makeText(activity, getString(R.string.channel_unsubscribed), Toast.LENGTH_SHORT).show();
- }
-
-
-
- private Observer> getDeleteObserver() {
- return new Observer>() {
- @Override
- public void onSubscribe(Disposable d) {
- disposables.add(d);
- }
-
- @Override
- public void onNext(List subscriptionEntities) {
- subscriptionService.subscriptionTable().delete(subscriptionEntities);
- }
-
- @Override
- public void onError(Throwable exception) {
- SubscriptionFragment.this.onError(exception);
- }
-
- @Override
- public void onComplete() { }
- };
- }
-
- private void resetFragment() {
- if (disposables != null) disposables.clear();
- if (infoListAdapter != null) infoListAdapter.clearStreamItemList();
- }
-
- ///////////////////////////////////////////////////////////////////////////
- // Subscriptions Loader
- ///////////////////////////////////////////////////////////////////////////
-
- @Override
- public void startLoading(boolean forceLoad) {
- super.startLoading(forceLoad);
- resetFragment();
-
- subscriptionService.getSubscription().toObservable()
- .subscribeOn(Schedulers.io())
- .observeOn(AndroidSchedulers.mainThread())
- .subscribe(getSubscriptionObserver());
- }
-
- private Observer> getSubscriptionObserver() {
- return new Observer>() {
- @Override
- public void onSubscribe(Disposable d) {
- showLoading();
- disposables.add(d);
- }
-
- @Override
- public void onNext(List subscriptions) {
- handleResult(subscriptions);
- }
-
- @Override
- public void onError(Throwable exception) {
- SubscriptionFragment.this.onError(exception);
- }
-
- @Override
- public void onComplete() {
- }
- };
- }
-
- @Override
- public void handleResult(@NonNull List result) {
- super.handleResult(result);
-
- infoListAdapter.clearStreamItemList();
-
- if (result.isEmpty()) {
- whatsNewItemListHeader.setVisibility(View.GONE);
- showEmptyState();
- } else {
- infoListAdapter.addInfoItemList(getSubscriptionItems(result));
- if (itemsListState != null) {
- itemsList.getLayoutManager().onRestoreInstanceState(itemsListState);
- itemsListState = null;
- }
- whatsNewItemListHeader.setVisibility(View.VISIBLE);
- hideLoading();
- }
- }
-
-
- private List getSubscriptionItems(List subscriptions) {
- List items = new ArrayList<>();
- for (final SubscriptionEntity subscription : subscriptions) {
- items.add(subscription.toChannelInfoItem());
- }
-
- Collections.sort(items,
- (InfoItem o1, InfoItem o2) ->
- o1.getName().compareToIgnoreCase(o2.getName()));
- return items;
- }
-
- /*//////////////////////////////////////////////////////////////////////////
- // Contract
- //////////////////////////////////////////////////////////////////////////*/
-
- @Override
- public void showLoading() {
- super.showLoading();
- animateView(itemsList, false, 100);
- }
-
- @Override
- public void hideLoading() {
- super.hideLoading();
- animateView(itemsList, true, 200);
- }
-
- ///////////////////////////////////////////////////////////////////////////
- // Fragment Error Handling
- ///////////////////////////////////////////////////////////////////////////
-
- @Override
- protected boolean onError(Throwable exception) {
- resetFragment();
- if (super.onError(exception)) return true;
-
- onUnrecoverableError(exception,
- UserAction.SOMETHING_ELSE,
- "none",
- "Subscriptions",
- R.string.general_error);
- return true;
- }
-
- @Override
- public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) {
- if (key.equals(getString(R.string.list_view_mode_key))) {
- updateFlags |= LIST_MODE_UPDATE_FLAG;
- }
- }
-
- protected boolean isGridLayout() {
- final String list_mode = PreferenceManager.getDefaultSharedPreferences(activity).getString(getString(R.string.list_view_mode_key), getString(R.string.list_view_mode_value));
- if ("auto".equals(list_mode)) {
- final Configuration configuration = getResources().getConfiguration();
- return configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
- && configuration.isLayoutSizeAtLeast(Configuration.SCREENLAYOUT_SIZE_LARGE);
- } else {
- return "grid".equals(list_mode);
- }
- }
-}
diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionFragment.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionFragment.kt
new file mode 100644
index 000000000..98e20a02f
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionFragment.kt
@@ -0,0 +1,421 @@
+package org.schabi.newpipe.local.subscription
+
+import android.app.Activity
+import android.app.AlertDialog
+import android.content.*
+import android.content.res.Configuration
+import android.os.Bundle
+import android.os.Environment
+import android.os.Parcelable
+import android.preference.PreferenceManager
+import android.view.*
+import android.widget.Toast
+import androidx.lifecycle.ViewModelProviders
+import androidx.localbroadcastmanager.content.LocalBroadcastManager
+import androidx.recyclerview.widget.GridLayoutManager
+import com.nononsenseapps.filepicker.Utils
+import com.xwray.groupie.Group
+import com.xwray.groupie.GroupAdapter
+import com.xwray.groupie.Item
+import com.xwray.groupie.Section
+import com.xwray.groupie.kotlinandroidextensions.GroupieViewHolder
+import icepick.State
+import io.reactivex.disposables.CompositeDisposable
+import kotlinx.android.synthetic.main.dialog_title.view.*
+import kotlinx.android.synthetic.main.fragment_subscription.*
+import org.schabi.newpipe.R
+import org.schabi.newpipe.database.feed.model.FeedGroupEntity
+import org.schabi.newpipe.extractor.channel.ChannelInfoItem
+import org.schabi.newpipe.fragments.BaseStateFragment
+import org.schabi.newpipe.local.subscription.SubscriptionViewModel.SubscriptionState
+import org.schabi.newpipe.local.subscription.dialog.FeedGroupDialog
+import org.schabi.newpipe.local.subscription.dialog.FeedGroupReorderDialog
+import org.schabi.newpipe.local.subscription.item.*
+import org.schabi.newpipe.local.subscription.services.SubscriptionsExportService
+import org.schabi.newpipe.local.subscription.services.SubscriptionsExportService.EXPORT_COMPLETE_ACTION
+import org.schabi.newpipe.local.subscription.services.SubscriptionsExportService.KEY_FILE_PATH
+import org.schabi.newpipe.local.subscription.services.SubscriptionsImportService
+import org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.*
+import org.schabi.newpipe.report.UserAction
+import org.schabi.newpipe.util.*
+import org.schabi.newpipe.util.AnimationUtils.animateView
+import java.io.File
+import java.text.SimpleDateFormat
+import java.util.*
+import kotlin.math.floor
+import kotlin.math.max
+
+class SubscriptionFragment : BaseStateFragment() {
+ private lateinit var viewModel: SubscriptionViewModel
+ private lateinit var subscriptionManager: SubscriptionManager
+ private val disposables: CompositeDisposable = CompositeDisposable()
+
+ private var subscriptionBroadcastReceiver: BroadcastReceiver? = null
+
+ private val groupAdapter = GroupAdapter()
+ private val feedGroupsSection = Section()
+ private var feedGroupsCarousel: FeedGroupCarouselItem? = null
+ private lateinit var importExportItem: FeedImportExportItem
+ private lateinit var feedGroupsSortMenuItem: HeaderWithMenuItem
+ private val subscriptionsSection = Section()
+
+ @State @JvmField var itemsListState: Parcelable? = null
+ @State @JvmField var feedGroupsListState: Parcelable? = null
+ @State @JvmField var importExportItemExpandedState: Boolean? = null
+
+ init {
+ setHasOptionsMenu(true)
+ }
+
+ ///////////////////////////////////////////////////////////////////////////
+ // Fragment LifeCycle
+ ///////////////////////////////////////////////////////////////////////////
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setupInitialLayout()
+ }
+
+ override fun setUserVisibleHint(isVisibleToUser: Boolean) {
+ super.setUserVisibleHint(isVisibleToUser)
+ if (activity != null && isVisibleToUser) {
+ setTitle(activity.getString(R.string.tab_subscriptions))
+ }
+ }
+
+ override fun onAttach(context: Context) {
+ super.onAttach(context)
+ subscriptionManager = SubscriptionManager(requireContext())
+ }
+
+ override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
+ return inflater.inflate(R.layout.fragment_subscription, container, false)
+ }
+
+ override fun onResume() {
+ super.onResume()
+ setupBroadcastReceiver()
+ }
+
+ override fun onPause() {
+ super.onPause()
+ itemsListState = items_list.layoutManager?.onSaveInstanceState()
+ feedGroupsListState = feedGroupsCarousel?.onSaveInstanceState()
+ importExportItemExpandedState = importExportItem.isExpanded
+
+ if (subscriptionBroadcastReceiver != null && activity != null) {
+ LocalBroadcastManager.getInstance(activity).unregisterReceiver(subscriptionBroadcastReceiver!!)
+ }
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+ disposables.dispose()
+ }
+
+ //////////////////////////////////////////////////////////////////////////
+ // Menu
+ //////////////////////////////////////////////////////////////////////////
+
+ override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
+ super.onCreateOptionsMenu(menu, inflater)
+
+ val supportActionBar = activity.supportActionBar
+ if (supportActionBar != null) {
+ supportActionBar.setDisplayShowTitleEnabled(true)
+ setTitle(getString(R.string.tab_subscriptions))
+ }
+ }
+
+ private fun setupBroadcastReceiver() {
+ if (activity == null) return
+
+ if (subscriptionBroadcastReceiver != null) {
+ LocalBroadcastManager.getInstance(activity).unregisterReceiver(subscriptionBroadcastReceiver!!)
+ }
+
+ val filters = IntentFilter()
+ filters.addAction(EXPORT_COMPLETE_ACTION)
+ filters.addAction(IMPORT_COMPLETE_ACTION)
+ subscriptionBroadcastReceiver = object : BroadcastReceiver() {
+ override fun onReceive(context: Context, intent: Intent) {
+ items_list?.post {
+ importExportItem.isExpanded = false
+ importExportItem.notifyChanged(FeedImportExportItem.REFRESH_EXPANDED_STATUS)
+ }
+
+ }
+ }
+
+ LocalBroadcastManager.getInstance(activity).registerReceiver(subscriptionBroadcastReceiver!!, filters)
+ }
+
+ private fun onImportFromServiceSelected(serviceId: Int) {
+ val fragmentManager = fm
+ NavigationHelper.openSubscriptionsImportFragment(fragmentManager, serviceId)
+ }
+
+ private fun onImportPreviousSelected() {
+ startActivityForResult(FilePickerActivityHelper.chooseSingleFile(activity), REQUEST_IMPORT_CODE)
+ }
+
+ private fun onExportSelected() {
+ val date = SimpleDateFormat("yyyyMMddHHmm", Locale.ENGLISH).format(Date())
+ val exportName = "newpipe_subscriptions_$date.json"
+ val exportFile = File(Environment.getExternalStorageDirectory(), exportName)
+
+ startActivityForResult(FilePickerActivityHelper.chooseFileToSave(activity, exportFile.absolutePath), REQUEST_EXPORT_CODE)
+ }
+
+ private fun openReorderDialog() {
+ FeedGroupReorderDialog().show(requireFragmentManager(), null)
+ }
+
+ override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
+ super.onActivityResult(requestCode, resultCode, data)
+ if (data != null && data.data != null && resultCode == Activity.RESULT_OK) {
+ if (requestCode == REQUEST_EXPORT_CODE) {
+ val exportFile = Utils.getFileForUri(data.data!!)
+ if (!exportFile.parentFile.canWrite() || !exportFile.parentFile.canRead()) {
+ Toast.makeText(activity, R.string.invalid_directory, Toast.LENGTH_SHORT).show()
+ } else {
+ activity.startService(Intent(activity, SubscriptionsExportService::class.java)
+ .putExtra(KEY_FILE_PATH, exportFile.absolutePath))
+ }
+ } else if (requestCode == REQUEST_IMPORT_CODE) {
+ val path = Utils.getFileForUri(data.data!!).absolutePath
+ ImportConfirmationDialog.show(this, Intent(activity, SubscriptionsImportService::class.java)
+ .putExtra(KEY_MODE, PREVIOUS_EXPORT_MODE)
+ .putExtra(KEY_VALUE, path))
+ }
+ }
+ }
+
+ //////////////////////////////////////////////////////////////////////////
+ // Fragment Views
+ //////////////////////////////////////////////////////////////////////////
+
+ private fun setupInitialLayout() {
+ Section().apply {
+ val carouselAdapter = GroupAdapter()
+
+ carouselAdapter.add(FeedGroupCardItem(-1, getString(R.string.all), FeedGroupIcon.RSS))
+ carouselAdapter.add(feedGroupsSection)
+ carouselAdapter.add(FeedGroupAddItem())
+
+ carouselAdapter.setOnItemClickListener { item, _ ->
+ listenerFeedGroups.selected(item)
+ }
+ carouselAdapter.setOnItemLongClickListener { item, _ ->
+ if (item is FeedGroupCardItem) {
+ if (item.groupId == FeedGroupEntity.GROUP_ALL_ID) {
+ return@setOnItemLongClickListener false
+ }
+ }
+ listenerFeedGroups.held(item)
+ return@setOnItemLongClickListener true
+ }
+
+ feedGroupsCarousel = FeedGroupCarouselItem(requireContext(), carouselAdapter)
+ feedGroupsSortMenuItem = HeaderWithMenuItem(
+ getString(R.string.feed_groups_header_title),
+ ThemeHelper.resolveResourceIdFromAttr(requireContext(), R.attr.ic_sort),
+ menuItemOnClickListener = ::openReorderDialog
+ )
+ add(Section(feedGroupsSortMenuItem, listOf(feedGroupsCarousel)))
+
+ groupAdapter.add(this)
+ }
+
+ subscriptionsSection.setPlaceholder(EmptyPlaceholderItem())
+ subscriptionsSection.setHideWhenEmpty(true)
+
+ importExportItem = FeedImportExportItem(
+ { onImportPreviousSelected() },
+ { onImportFromServiceSelected(it) },
+ { onExportSelected() },
+ importExportItemExpandedState ?: false)
+ groupAdapter.add(Section(importExportItem, listOf(subscriptionsSection)))
+
+ }
+
+ override fun initViews(rootView: View, savedInstanceState: Bundle?) {
+ super.initViews(rootView, savedInstanceState)
+
+ val shouldUseGridLayout = shouldUseGridLayout()
+ groupAdapter.spanCount = if (shouldUseGridLayout) getGridSpanCount() else 1
+ items_list.layoutManager = GridLayoutManager(requireContext(), groupAdapter.spanCount).apply {
+ spanSizeLookup = groupAdapter.spanSizeLookup
+ }
+ items_list.adapter = groupAdapter
+
+ viewModel = ViewModelProviders.of(this).get(SubscriptionViewModel::class.java)
+ viewModel.stateLiveData.observe(viewLifecycleOwner, androidx.lifecycle.Observer { it?.let(this::handleResult) })
+ viewModel.feedGroupsLiveData.observe(viewLifecycleOwner, androidx.lifecycle.Observer { it?.let(this::handleFeedGroups) })
+ }
+
+ private fun showLongTapDialog(selectedItem: ChannelInfoItem) {
+ val commands = arrayOf(
+ getString(R.string.share),
+ getString(R.string.unsubscribe)
+ )
+
+ val actions = DialogInterface.OnClickListener { _, i ->
+ when (i) {
+ 0 -> ShareUtils.shareUrl(requireContext(), selectedItem.name, selectedItem.url)
+ 1 -> deleteChannel(selectedItem)
+ }
+ }
+
+ val bannerView = View.inflate(requireContext(), R.layout.dialog_title, null)
+ bannerView.isSelected = true
+ bannerView.itemTitleView.text = selectedItem.name
+ bannerView.itemAdditionalDetails.visibility = View.GONE
+
+ AlertDialog.Builder(requireContext())
+ .setCustomTitle(bannerView)
+ .setItems(commands, actions)
+ .create()
+ .show()
+ }
+
+ private fun deleteChannel(selectedItem: ChannelInfoItem) {
+ disposables.add(subscriptionManager.deleteSubscription(selectedItem.serviceId, selectedItem.url).subscribe {
+ Toast.makeText(requireContext(), getString(R.string.channel_unsubscribed), Toast.LENGTH_SHORT).show()
+ })
+ }
+
+ override fun doInitialLoadLogic() = Unit
+ override fun startLoading(forceLoad: Boolean) = Unit
+
+ private val listenerFeedGroups = object : OnClickGesture- >() {
+ override fun selected(selectedItem: Item<*>?) {
+ when (selectedItem) {
+ is FeedGroupCardItem -> NavigationHelper.openFeedFragment(fm, selectedItem.groupId, selectedItem.name)
+ is FeedGroupAddItem -> FeedGroupDialog.newInstance().show(fm, null)
+ }
+ }
+
+ override fun held(selectedItem: Item<*>?) {
+ when (selectedItem) {
+ is FeedGroupCardItem -> FeedGroupDialog.newInstance(selectedItem.groupId).show(fm, null)
+ }
+ }
+ }
+
+ private val listenerChannelItem = object : OnClickGesture
() {
+ override fun selected(selectedItem: ChannelInfoItem) = NavigationHelper.openChannelFragment(fm,
+ selectedItem.serviceId, selectedItem.url, selectedItem.name)
+
+ override fun held(selectedItem: ChannelInfoItem) = showLongTapDialog(selectedItem)
+ }
+
+ override fun handleResult(result: SubscriptionState) {
+ super.handleResult(result)
+
+ val shouldUseGridLayout = shouldUseGridLayout()
+ when (result) {
+ is SubscriptionState.LoadedState -> {
+ result.subscriptions.forEach {
+ if (it is ChannelItem) {
+ it.gesturesListener = listenerChannelItem
+ it.itemVersion = when {
+ shouldUseGridLayout -> ChannelItem.ItemVersion.GRID
+ else -> ChannelItem.ItemVersion.MINI
+ }
+ }
+ }
+
+ subscriptionsSection.update(result.subscriptions)
+ subscriptionsSection.setHideWhenEmpty(false)
+
+ if (result.subscriptions.isEmpty() && importExportItemExpandedState == null) {
+ items_list.post {
+ importExportItem.isExpanded = true
+ importExportItem.notifyChanged(FeedImportExportItem.REFRESH_EXPANDED_STATUS)
+ }
+ }
+
+ if (itemsListState != null) {
+ items_list.layoutManager?.onRestoreInstanceState(itemsListState)
+ itemsListState = null
+ }
+ }
+ is SubscriptionState.ErrorState -> {
+ result.error?.let { onError(result.error) }
+ }
+ }
+ }
+
+ private fun handleFeedGroups(groups: List) {
+ feedGroupsSection.update(groups)
+
+ if (feedGroupsListState != null) {
+ feedGroupsCarousel?.onRestoreInstanceState(feedGroupsListState)
+ feedGroupsListState = null
+ }
+
+ if (groups.size < 2) {
+ items_list.post { feedGroupsSortMenuItem.notifyChanged(HeaderWithMenuItem.PAYLOAD_HIDE_MENU_ITEM) }
+ } else {
+ items_list.post { feedGroupsSortMenuItem.notifyChanged(HeaderWithMenuItem.PAYLOAD_SHOW_MENU_ITEM) }
+ }
+ }
+
+ ///////////////////////////////////////////////////////////////////////////
+ // Contract
+ ///////////////////////////////////////////////////////////////////////////
+
+ override fun showLoading() {
+ super.showLoading()
+ animateView(items_list, false, 100)
+ }
+
+ override fun hideLoading() {
+ super.hideLoading()
+ animateView(items_list, true, 200)
+ }
+
+ ///////////////////////////////////////////////////////////////////////////
+ // Fragment Error Handling
+ ///////////////////////////////////////////////////////////////////////////
+
+ override fun onError(exception: Throwable): Boolean {
+ if (super.onError(exception)) return true
+
+ onUnrecoverableError(exception, UserAction.SOMETHING_ELSE, "none", "Subscriptions", R.string.general_error)
+ return true
+ }
+
+ ///////////////////////////////////////////////////////////////////////////
+ // Grid Mode
+ ///////////////////////////////////////////////////////////////////////////
+
+ // TODO: Move these out of this class, as it can be reused
+
+ private fun shouldUseGridLayout(): Boolean {
+ val listMode = PreferenceManager.getDefaultSharedPreferences(requireContext())
+ .getString(getString(R.string.list_view_mode_key), getString(R.string.list_view_mode_value))
+
+ return when (listMode) {
+ getString(R.string.list_view_mode_auto_key) -> {
+ val configuration = resources.configuration
+
+ (configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
+ && configuration.isLayoutSizeAtLeast(Configuration.SCREENLAYOUT_SIZE_LARGE))
+ }
+ getString(R.string.list_view_mode_grid_key) -> true
+ else -> false
+ }
+ }
+
+ private fun getGridSpanCount(): Int {
+ val minWidth = resources.getDimensionPixelSize(R.dimen.channel_item_grid_min_width)
+ return max(1, floor(resources.displayMetrics.widthPixels / minWidth.toDouble()).toInt())
+ }
+
+ companion object {
+ private const val REQUEST_EXPORT_CODE = 666
+ private const val REQUEST_IMPORT_CODE = 667
+ }
+}
diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionManager.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionManager.kt
new file mode 100644
index 000000000..92ab8cb0c
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionManager.kt
@@ -0,0 +1,74 @@
+package org.schabi.newpipe.local.subscription
+
+import android.content.Context
+import io.reactivex.Completable
+import io.reactivex.android.schedulers.AndroidSchedulers
+import io.reactivex.schedulers.Schedulers
+import org.schabi.newpipe.NewPipeDatabase
+import org.schabi.newpipe.database.subscription.SubscriptionDAO
+import org.schabi.newpipe.database.subscription.SubscriptionEntity
+import org.schabi.newpipe.extractor.ListInfo
+import org.schabi.newpipe.extractor.channel.ChannelInfo
+import org.schabi.newpipe.extractor.feed.FeedInfo
+import org.schabi.newpipe.extractor.stream.StreamInfoItem
+import org.schabi.newpipe.local.feed.FeedDatabaseManager
+
+class SubscriptionManager(context: Context) {
+ private val database = NewPipeDatabase.getInstance(context)
+ private val subscriptionTable = database.subscriptionDAO()
+ private val feedDatabaseManager = FeedDatabaseManager(context)
+
+ fun subscriptionTable(): SubscriptionDAO = subscriptionTable
+ fun subscriptions() = subscriptionTable.all
+
+ fun upsertAll(infoList: List): List {
+ val listEntities = subscriptionTable.upsertAll(
+ infoList.map { SubscriptionEntity.from(it) })
+
+ database.runInTransaction {
+ infoList.forEachIndexed { index, info ->
+ feedDatabaseManager.upsertAll(listEntities[index].uid, info.relatedItems)
+ }
+ }
+
+ return listEntities
+ }
+
+ fun updateChannelInfo(info: ChannelInfo): Completable = subscriptionTable.getSubscription(info.serviceId, info.url)
+ .flatMapCompletable {
+ Completable.fromRunnable {
+ it.setData(info.name, info.avatarUrl, info.description, info.subscriberCount)
+ subscriptionTable.update(it)
+ feedDatabaseManager.upsertAll(it.uid, info.relatedItems)
+ }
+ }
+
+ fun updateFromInfo(subscriptionId: Long, info: ListInfo) {
+ val subscriptionEntity = subscriptionTable.getSubscription(subscriptionId)
+
+ if (info is FeedInfo) {
+ subscriptionEntity.name = info.name
+ } else if (info is ChannelInfo) {
+ subscriptionEntity.setData(info.name, info.avatarUrl, info.description, info.subscriberCount)
+ }
+
+ subscriptionTable.update(subscriptionEntity)
+ }
+
+ fun deleteSubscription(serviceId: Int, url: String): Completable {
+ return Completable.fromCallable { subscriptionTable.deleteSubscription(serviceId, url) }
+ .subscribeOn(Schedulers.io())
+ .observeOn(AndroidSchedulers.mainThread())
+ }
+
+ fun insertSubscription(subscriptionEntity: SubscriptionEntity, info: ChannelInfo) {
+ database.runInTransaction {
+ val subscriptionId = subscriptionTable.insert(subscriptionEntity)
+ feedDatabaseManager.upsertAll(subscriptionId, info.relatedItems)
+ }
+ }
+
+ fun deleteSubscription(subscriptionEntity: SubscriptionEntity) {
+ subscriptionTable.delete(subscriptionEntity)
+ }
+}
diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionService.java b/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionService.java
deleted file mode 100644
index 7d6fa5158..000000000
--- a/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionService.java
+++ /dev/null
@@ -1,162 +0,0 @@
-package org.schabi.newpipe.local.subscription;
-
-import android.content.Context;
-import androidx.annotation.NonNull;
-import android.util.Log;
-
-import org.schabi.newpipe.MainActivity;
-import org.schabi.newpipe.NewPipeDatabase;
-import org.schabi.newpipe.database.AppDatabase;
-import org.schabi.newpipe.database.subscription.SubscriptionDAO;
-import org.schabi.newpipe.database.subscription.SubscriptionEntity;
-import org.schabi.newpipe.extractor.channel.ChannelInfo;
-import org.schabi.newpipe.util.ExtractorHelper;
-
-import java.util.ArrayList;
-import java.util.List;
-import java.util.concurrent.Executor;
-import java.util.concurrent.Executors;
-import java.util.concurrent.TimeUnit;
-
-import io.reactivex.Completable;
-import io.reactivex.CompletableSource;
-import io.reactivex.Flowable;
-import io.reactivex.Maybe;
-import io.reactivex.Scheduler;
-import io.reactivex.functions.Function;
-import io.reactivex.schedulers.Schedulers;
-
-/**
- * Subscription Service singleton:
- * Provides a basis for channel Subscriptions.
- * Provides access to subscription table in database as well as
- * up-to-date observations on the subscribed channels
- */
-public class SubscriptionService {
-
- private static volatile SubscriptionService instance;
-
- public static SubscriptionService getInstance(@NonNull Context context) {
- SubscriptionService result = instance;
- if (result == null) {
- synchronized (SubscriptionService.class) {
- result = instance;
- if (result == null) {
- instance = (result = new SubscriptionService(context));
- }
- }
- }
-
- return result;
- }
-
- protected final String TAG = "SubscriptionService@" + Integer.toHexString(hashCode());
- protected static final boolean DEBUG = MainActivity.DEBUG;
- private static final int SUBSCRIPTION_DEBOUNCE_INTERVAL = 500;
- private static final int SUBSCRIPTION_THREAD_POOL_SIZE = 4;
-
- private final AppDatabase db;
- private final Flowable> subscription;
-
- private final Scheduler subscriptionScheduler;
-
- private SubscriptionService(Context context) {
- db = NewPipeDatabase.getInstance(context.getApplicationContext());
- subscription = getSubscriptionInfos();
-
- final Executor subscriptionExecutor = Executors.newFixedThreadPool(SUBSCRIPTION_THREAD_POOL_SIZE);
- subscriptionScheduler = Schedulers.from(subscriptionExecutor);
- }
-
- /**
- * Part of subscription observation pipeline
- *
- * @see SubscriptionService#getSubscription()
- */
- private Flowable> getSubscriptionInfos() {
- return subscriptionTable().getAll()
- // Wait for a period of infrequent updates and return the latest update
- .debounce(SUBSCRIPTION_DEBOUNCE_INTERVAL, TimeUnit.MILLISECONDS)
- .share() // Share allows multiple subscribers on the same observable
- .replay(1) // Replay synchronizes subscribers to the last emitted result
- .autoConnect();
- }
-
- /**
- * Provides an observer to the latest update to the subscription table.
- *
- * This observer may be subscribed multiple times, where each subscriber obtains
- * the latest synchronized changes available, effectively share the same data
- * across all subscribers.
- *
- * This observer has a debounce cooldown, meaning if multiple updates are observed
- * in the cooldown interval, only the latest changes are emitted to the subscribers.
- * This reduces the amount of observations caused by frequent updates to the database.
- */
- @androidx.annotation.NonNull
- public Flowable> getSubscription() {
- return subscription;
- }
-
- public Maybe getChannelInfo(final SubscriptionEntity subscriptionEntity) {
- if (DEBUG) Log.d(TAG, "getChannelInfo() called with: subscriptionEntity = [" + subscriptionEntity + "]");
-
- return Maybe.fromSingle(ExtractorHelper
- .getChannelInfo(subscriptionEntity.getServiceId(), subscriptionEntity.getUrl(), false))
- .subscribeOn(subscriptionScheduler);
- }
-
- /**
- * Returns the database access interface for subscription table.
- */
- public SubscriptionDAO subscriptionTable() {
- return db.subscriptionDAO();
- }
-
- public Completable updateChannelInfo(final ChannelInfo info) {
- final Function, CompletableSource> update = new Function, CompletableSource>() {
- @Override
- public CompletableSource apply(@NonNull List subscriptionEntities) {
- if (DEBUG) Log.d(TAG, "updateChannelInfo() called with: subscriptionEntities = [" + subscriptionEntities + "]");
- if (subscriptionEntities.size() == 1) {
- SubscriptionEntity subscription = subscriptionEntities.get(0);
-
- // Subscriber count changes very often, making this check almost unnecessary.
- // Consider removing it later.
- if (!isSubscriptionUpToDate(info, subscription)) {
- subscription.setData(info.getName(), info.getAvatarUrl(), info.getDescription(), info.getSubscriberCount());
-
- return Completable.fromRunnable(() -> subscriptionTable().update(subscription));
- }
- }
-
- return Completable.complete();
- }
- };
-
- return subscriptionTable().getSubscription(info.getServiceId(), info.getUrl())
- .firstOrError()
- .flatMapCompletable(update);
- }
-
- public List upsertAll(final List infoList) {
- final List entityList = new ArrayList<>();
- for (ChannelInfo info : infoList) entityList.add(SubscriptionEntity.from(info));
-
- return subscriptionTable().upsertAll(entityList);
- }
-
- private boolean isSubscriptionUpToDate(final ChannelInfo info, final SubscriptionEntity entity) {
- return equalsAndNotNull(info.getUrl(), entity.getUrl()) &&
- info.getServiceId() == entity.getServiceId() &&
- info.getName().equals(entity.getName()) &&
- equalsAndNotNull(info.getAvatarUrl(), entity.getAvatarUrl()) &&
- equalsAndNotNull(info.getDescription(), entity.getDescription()) &&
- info.getSubscriberCount() == entity.getSubscriberCount();
- }
-
- private boolean equalsAndNotNull(final Object o1, final Object o2) {
- return (o1 != null && o2 != null)
- && o1.equals(o2);
- }
-}
diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionViewModel.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionViewModel.kt
new file mode 100644
index 000000000..6454cc912
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionViewModel.kt
@@ -0,0 +1,52 @@
+package org.schabi.newpipe.local.subscription
+
+import android.app.Application
+import androidx.lifecycle.AndroidViewModel
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import com.xwray.groupie.Group
+import io.reactivex.schedulers.Schedulers
+import org.schabi.newpipe.local.feed.FeedDatabaseManager
+import org.schabi.newpipe.local.subscription.item.ChannelItem
+import org.schabi.newpipe.local.subscription.item.FeedGroupCardItem
+import org.schabi.newpipe.util.DEFAULT_THROTTLE_TIMEOUT
+import java.util.concurrent.TimeUnit
+
+class SubscriptionViewModel(application: Application) : AndroidViewModel(application) {
+ private var feedDatabaseManager: FeedDatabaseManager = FeedDatabaseManager(application)
+ private var subscriptionManager = SubscriptionManager(application)
+
+ private val mutableStateLiveData = MutableLiveData()
+ private val mutableFeedGroupsLiveData = MutableLiveData>()
+ val stateLiveData: LiveData = mutableStateLiveData
+ val feedGroupsLiveData: LiveData> = mutableFeedGroupsLiveData
+
+ private var feedGroupItemsDisposable = feedDatabaseManager.groups()
+ .throttleLatest(DEFAULT_THROTTLE_TIMEOUT, TimeUnit.MILLISECONDS)
+ .map { it.map(::FeedGroupCardItem) }
+ .subscribeOn(Schedulers.io())
+ .subscribe(
+ { mutableFeedGroupsLiveData.postValue(it) },
+ { mutableStateLiveData.postValue(SubscriptionState.ErrorState(it)) }
+ )
+
+ private var stateItemsDisposable = subscriptionManager.subscriptions()
+ .throttleLatest(DEFAULT_THROTTLE_TIMEOUT, TimeUnit.MILLISECONDS)
+ .map { it.map { entity -> ChannelItem(entity.toChannelInfoItem(), entity.uid, ChannelItem.ItemVersion.MINI) } }
+ .subscribeOn(Schedulers.io())
+ .subscribe(
+ { mutableStateLiveData.postValue(SubscriptionState.LoadedState(it)) },
+ { mutableStateLiveData.postValue(SubscriptionState.ErrorState(it)) }
+ )
+
+ override fun onCleared() {
+ super.onCleared()
+ stateItemsDisposable.dispose()
+ feedGroupItemsDisposable.dispose()
+ }
+
+ sealed class SubscriptionState {
+ data class LoadedState(val subscriptions: List) : SubscriptionState()
+ data class ErrorState(val error: Throwable? = null) : SubscriptionState()
+ }
+}
diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/decoration/FeedGroupCarouselDecoration.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/decoration/FeedGroupCarouselDecoration.kt
new file mode 100644
index 000000000..24c8d9cb8
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/local/subscription/decoration/FeedGroupCarouselDecoration.kt
@@ -0,0 +1,35 @@
+package org.schabi.newpipe.local.subscription.decoration
+
+import android.content.Context
+import android.graphics.Rect
+import android.view.View
+import androidx.recyclerview.widget.RecyclerView
+import org.schabi.newpipe.R
+
+class FeedGroupCarouselDecoration(context: Context) : RecyclerView.ItemDecoration() {
+
+ private val marginStartEnd: Int
+ private val marginTopBottom: Int
+ private val marginBetweenItems: Int
+
+ init {
+ with(context.resources) {
+ marginStartEnd = getDimensionPixelOffset(R.dimen.feed_group_carousel_start_end_margin)
+ marginTopBottom = getDimensionPixelOffset(R.dimen.feed_group_carousel_top_bottom_margin)
+ marginBetweenItems = getDimensionPixelOffset(R.dimen.feed_group_carousel_between_items_margin)
+ }
+ }
+
+ override fun getItemOffsets(outRect: Rect, child: View, parent: RecyclerView, state: RecyclerView.State) {
+ val childAdapterPosition = parent.getChildAdapterPosition(child)
+ val childAdapterCount = parent.adapter?.itemCount ?: 0
+
+ outRect.set(marginBetweenItems, marginTopBottom, 0, marginTopBottom)
+
+ if (childAdapterPosition == 0) {
+ outRect.left = marginStartEnd
+ } else if (childAdapterPosition == childAdapterCount - 1) {
+ outRect.right = marginStartEnd
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/dialog/FeedGroupDialog.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/dialog/FeedGroupDialog.kt
new file mode 100644
index 000000000..b1fef5671
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/local/subscription/dialog/FeedGroupDialog.kt
@@ -0,0 +1,356 @@
+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 subscriptionsCount = this.selectedSubscriptions.size
+ val selectedCountText = resources.getQuantityString(R.plurals.feed_group_dialog_selection_count, subscriptionsCount, subscriptionsCount)
+ 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 subscriptionsCount = this.selectedSubscriptions.size
+ val updateSelectedCountText = resources.getQuantityString(R.plurals.feed_group_dialog_selection_count, subscriptionsCount, subscriptionsCount)
+ selected_subscription_count_view.text = updateSelectedCountText
+ subscriptions_selector_header_info.text = updateSelectedCountText
+ }
+ }
+ }
+
+ select_channel_button.setOnClickListener {
+ subscriptions_selector_list.scrollToPosition(0)
+ showScreen(SubscriptionsPickerScreen)
+ }
+ }
+
+ private fun setupIconPicker() {
+ val groupAdapter = GroupAdapter()
+ groupAdapter.addAll(FeedGroupIcon.values().map { PickerIconItem(requireContext(), it) })
+
+ icon_selector.apply {
+ layoutManager = GridLayoutManager(requireContext(), 7, RecyclerView.VERTICAL, false)
+ adapter = groupAdapter
+
+ if (iconsListState != null) {
+ layoutManager?.onRestoreInstanceState(iconsListState)
+ iconsListState = null
+ }
+ }
+
+ groupAdapter.setOnItemClickListener { item, _ ->
+ when (item) {
+ is PickerIconItem -> {
+ selectedIcon = item.icon
+ icon_preview.setImageResource(item.iconRes)
+
+ showScreen(InitialScreen)
+ }
+ }
+ }
+ icon_preview.setOnClickListener {
+ icon_selector.scrollToPosition(0)
+ showScreen(IconPickerScreen)
+ }
+
+ if (groupId == NO_GROUP_SELECTED) {
+ val icon = selectedIcon ?: FeedGroupIcon.ALL
+ icon_preview.setImageResource(icon.getDrawableRes(requireContext()))
+ }
+ }
+
+ ///////////////////////////////////////////////////////////////////////////
+ // Screen Selector
+ ///////////////////////////////////////////////////////////////////////////
+
+ private fun showScreen(screen: ScreenState) {
+ currentScreen = screen
+
+ options_root.onlyVisibleIn(InitialScreen)
+ icon_selector.onlyVisibleIn(IconPickerScreen)
+ subscriptions_selector.onlyVisibleIn(SubscriptionsPickerScreen)
+ delete_screen_message.onlyVisibleIn(DeleteScreen)
+
+ separator.onlyVisibleIn(SubscriptionsPickerScreen, IconPickerScreen)
+ cancel_button.onlyVisibleIn(InitialScreen, DeleteScreen)
+
+ confirm_button.setText(when {
+ currentScreen == InitialScreen && groupId == NO_GROUP_SELECTED -> R.string.create
+ else -> android.R.string.ok
+ })
+
+ delete_button.visibility = when {
+ currentScreen != InitialScreen -> View.GONE
+ groupId == NO_GROUP_SELECTED -> View.GONE
+ else -> View.VISIBLE
+ }
+
+ if (currentScreen != InitialScreen) hideKeyboard()
+ }
+
+ private fun View.onlyVisibleIn(vararg screens: ScreenState) {
+ visibility = when (currentScreen) {
+ in screens -> View.VISIBLE
+ else -> View.GONE
+ }
+ }
+
+ ///////////////////////////////////////////////////////////////////////////
+ // Utils
+ ///////////////////////////////////////////////////////////////////////////
+
+ private fun hideKeyboard() {
+ val inputMethodManager = requireActivity().getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
+ inputMethodManager.hideSoftInputFromWindow(group_name_input.windowToken, InputMethodManager.RESULT_UNCHANGED_SHOWN)
+ group_name_input.clearFocus()
+ }
+
+ private fun disableInput() {
+ delete_button?.isEnabled = false
+ confirm_button?.isEnabled = false
+ cancel_button?.isEnabled = false
+ isCancelable = false
+
+ hideKeyboard()
+ }
+
+ companion object {
+ private const val KEY_GROUP_ID = "KEY_GROUP_ID"
+ private const val NO_GROUP_SELECTED = -1L
+
+ fun newInstance(groupId: Long = NO_GROUP_SELECTED): FeedGroupDialog {
+ val dialog = FeedGroupDialog()
+
+ dialog.arguments = Bundle().apply {
+ putLong(KEY_GROUP_ID, groupId)
+ }
+
+ return dialog
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/dialog/FeedGroupDialogViewModel.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/dialog/FeedGroupDialogViewModel.kt
new file mode 100644
index 000000000..bd57a2639
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/local/subscription/dialog/FeedGroupDialogViewModel.kt
@@ -0,0 +1,87 @@
+package org.schabi.newpipe.local.subscription.dialog
+
+import android.content.Context
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.ViewModelProvider
+import io.reactivex.Completable
+import io.reactivex.Flowable
+import io.reactivex.disposables.Disposable
+import io.reactivex.functions.BiFunction
+import io.reactivex.schedulers.Schedulers
+import org.schabi.newpipe.database.feed.model.FeedGroupEntity
+import org.schabi.newpipe.database.subscription.SubscriptionEntity
+import org.schabi.newpipe.local.feed.FeedDatabaseManager
+import org.schabi.newpipe.local.subscription.FeedGroupIcon
+import org.schabi.newpipe.local.subscription.SubscriptionManager
+
+
+class FeedGroupDialogViewModel(applicationContext: Context, val groupId: Long = FeedGroupEntity.GROUP_ALL_ID) : ViewModel() {
+ class Factory(val context: Context, val groupId: Long = FeedGroupEntity.GROUP_ALL_ID) : ViewModelProvider.Factory {
+ @Suppress("UNCHECKED_CAST")
+ override fun create(modelClass: Class): T {
+ return FeedGroupDialogViewModel(context.applicationContext, groupId) as T
+ }
+ }
+
+ private var feedDatabaseManager: FeedDatabaseManager = FeedDatabaseManager(applicationContext)
+ private var subscriptionManager = SubscriptionManager(applicationContext)
+
+ private val mutableGroupLiveData = MutableLiveData()
+ private val mutableSubscriptionsLiveData = MutableLiveData, Set>>()
+ private val mutableDialogEventLiveData = MutableLiveData()
+ val groupLiveData: LiveData = mutableGroupLiveData
+ val subscriptionsLiveData: LiveData, Set>> = mutableSubscriptionsLiveData
+ val dialogEventLiveData: LiveData = mutableDialogEventLiveData
+
+ private var actionProcessingDisposable: Disposable? = null
+
+ private var feedGroupDisposable = feedDatabaseManager.getGroup(groupId)
+ .subscribeOn(Schedulers.io())
+ .subscribe(mutableGroupLiveData::postValue)
+
+ private var subscriptionsDisposable = Flowable
+ .combineLatest(subscriptionManager.subscriptions(), feedDatabaseManager.subscriptionIdsForGroup(groupId),
+ BiFunction { t1: List, t2: List -> t1 to t2.toSet() })
+ .subscribeOn(Schedulers.io())
+ .subscribe(mutableSubscriptionsLiveData::postValue)
+
+ override fun onCleared() {
+ super.onCleared()
+ actionProcessingDisposable?.dispose()
+ subscriptionsDisposable.dispose()
+ feedGroupDisposable.dispose()
+ }
+
+ fun createGroup(name: String, selectedIcon: FeedGroupIcon, selectedSubscriptions: Set) {
+ doAction(feedDatabaseManager.createGroup(name, selectedIcon)
+ .flatMapCompletable {
+ feedDatabaseManager.updateSubscriptionsForGroup(it, selectedSubscriptions.toList())
+ })
+ }
+
+ fun updateGroup(name: String, selectedIcon: FeedGroupIcon, selectedSubscriptions: Set, sortOrder: Long) {
+ doAction(feedDatabaseManager.updateSubscriptionsForGroup(groupId, selectedSubscriptions.toList())
+ .andThen(feedDatabaseManager.updateGroup(FeedGroupEntity(groupId, name, selectedIcon, sortOrder))))
+ }
+
+ fun deleteGroup() {
+ doAction(feedDatabaseManager.deleteGroup(groupId))
+ }
+
+ private fun doAction(completable: Completable) {
+ if (actionProcessingDisposable == null) {
+ mutableDialogEventLiveData.value = DialogEvent.ProcessingEvent
+
+ actionProcessingDisposable = completable
+ .subscribeOn(Schedulers.io())
+ .subscribe { mutableDialogEventLiveData.postValue(DialogEvent.SuccessEvent) }
+ }
+ }
+
+ sealed class DialogEvent {
+ object ProcessingEvent : DialogEvent()
+ object SuccessEvent : DialogEvent()
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/dialog/FeedGroupReorderDialog.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/dialog/FeedGroupReorderDialog.kt
new file mode 100644
index 000000000..17ee89c87
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/local/subscription/dialog/FeedGroupReorderDialog.kt
@@ -0,0 +1,109 @@
+package org.schabi.newpipe.local.subscription.dialog
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.fragment.app.DialogFragment
+import androidx.lifecycle.Observer
+import androidx.lifecycle.ViewModelProviders
+import androidx.recyclerview.widget.ItemTouchHelper
+import androidx.recyclerview.widget.ItemTouchHelper.SimpleCallback
+import androidx.recyclerview.widget.LinearLayoutManager
+import androidx.recyclerview.widget.RecyclerView
+import com.xwray.groupie.GroupAdapter
+import com.xwray.groupie.TouchCallback
+import com.xwray.groupie.kotlinandroidextensions.GroupieViewHolder
+import icepick.Icepick
+import icepick.State
+import kotlinx.android.synthetic.main.dialog_feed_group_reorder.*
+import org.schabi.newpipe.R
+import org.schabi.newpipe.database.feed.model.FeedGroupEntity
+import org.schabi.newpipe.local.subscription.dialog.FeedGroupReorderDialogViewModel.DialogEvent.*
+import org.schabi.newpipe.local.subscription.item.FeedGroupReorderItem
+import org.schabi.newpipe.util.ThemeHelper
+import java.util.*
+import kotlin.collections.ArrayList
+
+class FeedGroupReorderDialog : DialogFragment() {
+ private lateinit var viewModel: FeedGroupReorderDialogViewModel
+
+ @State @JvmField var groupOrderedIdList = ArrayList()
+ private val groupAdapter = GroupAdapter()
+ private val itemTouchHelper = ItemTouchHelper(getItemTouchCallback())
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ Icepick.restoreInstanceState(this, savedInstanceState)
+
+ setStyle(STYLE_NO_TITLE, ThemeHelper.getMinWidthDialogTheme(requireContext()))
+ }
+
+ override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
+ return inflater.inflate(R.layout.dialog_feed_group_reorder, container)
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ viewModel = ViewModelProviders.of(this).get(FeedGroupReorderDialogViewModel::class.java)
+ viewModel.groupsLiveData.observe(viewLifecycleOwner, Observer(::handleGroups))
+ viewModel.dialogEventLiveData.observe(viewLifecycleOwner, Observer {
+ when (it) {
+ ProcessingEvent -> disableInput()
+ SuccessEvent -> dismiss()
+ }
+ })
+
+ feed_groups_list.layoutManager = LinearLayoutManager(requireContext())
+ feed_groups_list.adapter = groupAdapter
+ itemTouchHelper.attachToRecyclerView(feed_groups_list)
+
+ confirm_button.setOnClickListener {
+ viewModel.updateOrder(groupOrderedIdList)
+ }
+ }
+
+ override fun onSaveInstanceState(outState: Bundle) {
+ super.onSaveInstanceState(outState)
+ Icepick.saveInstanceState(this, outState)
+ }
+
+ private fun handleGroups(list: List) {
+ val groupList: List
+
+ if (groupOrderedIdList.isEmpty()) {
+ groupList = list
+ groupOrderedIdList.addAll(groupList.map { it.uid })
+ } else {
+ groupList = list.sortedBy { groupOrderedIdList.indexOf(it.uid) }
+ }
+
+ groupAdapter.update(groupList.map { FeedGroupReorderItem(it, itemTouchHelper) })
+ }
+
+ private fun disableInput() {
+ confirm_button?.isEnabled = false
+ isCancelable = false
+ }
+
+ private fun getItemTouchCallback(): SimpleCallback {
+ return object : TouchCallback() {
+
+ override fun onMove(recyclerView: RecyclerView, source: RecyclerView.ViewHolder,
+ target: RecyclerView.ViewHolder): Boolean {
+ val sourceIndex = source.adapterPosition
+ val targetIndex = target.adapterPosition
+
+ groupAdapter.notifyItemMoved(sourceIndex, targetIndex)
+ Collections.swap(groupOrderedIdList, sourceIndex, targetIndex)
+
+ return true
+ }
+
+ override fun isLongPressDragEnabled(): Boolean = false
+ override fun isItemViewSwipeEnabled(): Boolean = false
+ override fun onSwiped(viewHolder: RecyclerView.ViewHolder, swipeDir: Int) {}
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/dialog/FeedGroupReorderDialogViewModel.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/dialog/FeedGroupReorderDialogViewModel.kt
new file mode 100644
index 000000000..8ef5bb55c
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/local/subscription/dialog/FeedGroupReorderDialogViewModel.kt
@@ -0,0 +1,52 @@
+package org.schabi.newpipe.local.subscription.dialog
+
+import android.app.Application
+import androidx.lifecycle.AndroidViewModel
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import io.reactivex.Completable
+import io.reactivex.disposables.Disposable
+import io.reactivex.schedulers.Schedulers
+import org.schabi.newpipe.database.feed.model.FeedGroupEntity
+import org.schabi.newpipe.local.feed.FeedDatabaseManager
+
+class FeedGroupReorderDialogViewModel(application: Application) : AndroidViewModel(application) {
+ private var feedDatabaseManager: FeedDatabaseManager = FeedDatabaseManager(application)
+
+ private val mutableGroupsLiveData = MutableLiveData>()
+ private val mutableDialogEventLiveData = MutableLiveData()
+ val groupsLiveData: LiveData> = mutableGroupsLiveData
+ val dialogEventLiveData: LiveData = mutableDialogEventLiveData
+
+ private var actionProcessingDisposable: Disposable? = null
+
+ private var groupsDisposable = feedDatabaseManager.groups()
+ .limit(1)
+ .subscribeOn(Schedulers.io())
+ .subscribe(mutableGroupsLiveData::postValue)
+
+ override fun onCleared() {
+ super.onCleared()
+ actionProcessingDisposable?.dispose()
+ groupsDisposable.dispose()
+ }
+
+ fun updateOrder(groupIdList: List) {
+ doAction(feedDatabaseManager.updateGroupsOrder(groupIdList))
+ }
+
+ private fun doAction(completable: Completable) {
+ if (actionProcessingDisposable == null) {
+ mutableDialogEventLiveData.value = DialogEvent.ProcessingEvent
+
+ actionProcessingDisposable = completable
+ .subscribeOn(Schedulers.io())
+ .subscribe { mutableDialogEventLiveData.postValue(DialogEvent.SuccessEvent) }
+ }
+ }
+
+ sealed class DialogEvent {
+ object ProcessingEvent : DialogEvent()
+ object SuccessEvent : DialogEvent()
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/item/ChannelItem.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/item/ChannelItem.kt
new file mode 100644
index 000000000..928f93a47
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/local/subscription/item/ChannelItem.kt
@@ -0,0 +1,65 @@
+package org.schabi.newpipe.local.subscription.item
+
+import android.content.Context
+import com.nostra13.universalimageloader.core.ImageLoader
+import com.xwray.groupie.kotlinandroidextensions.Item
+import com.xwray.groupie.kotlinandroidextensions.GroupieViewHolder
+import kotlinx.android.synthetic.main.list_channel_item.*
+import org.schabi.newpipe.R
+import org.schabi.newpipe.extractor.channel.ChannelInfoItem
+import org.schabi.newpipe.util.ImageDisplayConstants
+import org.schabi.newpipe.util.Localization
+import org.schabi.newpipe.util.OnClickGesture
+
+
+class ChannelItem(
+ private val infoItem: ChannelInfoItem,
+ private val subscriptionId: Long = -1L,
+ var itemVersion: ItemVersion = ItemVersion.NORMAL,
+ var gesturesListener: OnClickGesture? = null
+) : Item() {
+
+ override fun getId(): Long = if (subscriptionId == -1L) super.getId() else subscriptionId
+
+ enum class ItemVersion { NORMAL, MINI, GRID }
+
+ override fun getLayout(): Int = when (itemVersion) {
+ ItemVersion.NORMAL -> R.layout.list_channel_item
+ ItemVersion.MINI -> R.layout.list_channel_mini_item
+ ItemVersion.GRID -> R.layout.list_channel_grid_item
+ }
+
+ override fun bind(viewHolder: GroupieViewHolder, position: Int) {
+ viewHolder.itemTitleView.text = infoItem.name
+ viewHolder.itemAdditionalDetails.text = getDetailLine(viewHolder.root.context)
+ if (itemVersion == ItemVersion.NORMAL) viewHolder.itemChannelDescriptionView.text = infoItem.description
+
+ ImageLoader.getInstance().displayImage(infoItem.thumbnailUrl, viewHolder.itemThumbnailView,
+ ImageDisplayConstants.DISPLAY_THUMBNAIL_OPTIONS)
+
+ gesturesListener?.run {
+ viewHolder.containerView.setOnClickListener { selected(infoItem) }
+ viewHolder.containerView.setOnLongClickListener { held(infoItem); true }
+ }
+ }
+
+ private fun getDetailLine(context: Context): String {
+ var details = if (infoItem.subscriberCount >= 0) {
+ Localization.shortSubscriberCount(context, infoItem.subscriberCount)
+ } else {
+ context.getString(R.string.subscribers_count_not_available)
+ }
+
+ if (itemVersion == ItemVersion.NORMAL) {
+ if (infoItem.streamCount >= 0) {
+ val formattedVideoAmount = Localization.localizeStreamCount(context, infoItem.streamCount)
+ details = Localization.concatenateStrings(details, formattedVideoAmount)
+ }
+ }
+ return details
+ }
+
+ override fun getSpanSize(spanCount: Int, position: Int): Int {
+ return if (itemVersion == ItemVersion.GRID) 1 else spanCount
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/item/EmptyPlaceholderItem.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/item/EmptyPlaceholderItem.kt
new file mode 100644
index 000000000..0c651dc69
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/local/subscription/item/EmptyPlaceholderItem.kt
@@ -0,0 +1,10 @@
+package org.schabi.newpipe.local.subscription.item
+
+import com.xwray.groupie.kotlinandroidextensions.Item
+import com.xwray.groupie.kotlinandroidextensions.GroupieViewHolder
+import org.schabi.newpipe.R
+
+class EmptyPlaceholderItem : Item() {
+ override fun getLayout(): Int = R.layout.list_empty_view
+ override fun bind(viewHolder: GroupieViewHolder, position: Int) {}
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/item/FeedGroupAddItem.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/item/FeedGroupAddItem.kt
new file mode 100644
index 000000000..309f82bbc
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/local/subscription/item/FeedGroupAddItem.kt
@@ -0,0 +1,10 @@
+package org.schabi.newpipe.local.subscription.item
+
+import com.xwray.groupie.kotlinandroidextensions.Item
+import com.xwray.groupie.kotlinandroidextensions.GroupieViewHolder
+import org.schabi.newpipe.R
+
+class FeedGroupAddItem : Item() {
+ override fun getLayout(): Int = R.layout.feed_group_add_new_item
+ override fun bind(viewHolder: GroupieViewHolder, position: Int) {}
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/item/FeedGroupCardItem.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/item/FeedGroupCardItem.kt
new file mode 100644
index 000000000..a757dc5b3
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/local/subscription/item/FeedGroupCardItem.kt
@@ -0,0 +1,30 @@
+package org.schabi.newpipe.local.subscription.item
+
+import com.xwray.groupie.kotlinandroidextensions.Item
+import com.xwray.groupie.kotlinandroidextensions.GroupieViewHolder
+import kotlinx.android.synthetic.main.feed_group_card_item.*
+import org.schabi.newpipe.R
+import org.schabi.newpipe.database.feed.model.FeedGroupEntity
+import org.schabi.newpipe.local.subscription.FeedGroupIcon
+
+data class FeedGroupCardItem(
+ val groupId: Long = FeedGroupEntity.GROUP_ALL_ID,
+ val name: String,
+ val icon: FeedGroupIcon
+) : Item() {
+ constructor (feedGroupEntity: FeedGroupEntity) : this(feedGroupEntity.uid, feedGroupEntity.name, feedGroupEntity.icon)
+
+ override fun getId(): Long {
+ return when (groupId) {
+ FeedGroupEntity.GROUP_ALL_ID -> super.getId()
+ else -> groupId
+ }
+ }
+
+ override fun getLayout(): Int = R.layout.feed_group_card_item
+
+ override fun bind(viewHolder: GroupieViewHolder, position: Int) {
+ viewHolder.title.text = name
+ viewHolder.icon.setImageResource(icon.getDrawableRes(viewHolder.containerView.context))
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/item/FeedGroupCarouselItem.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/item/FeedGroupCarouselItem.kt
new file mode 100644
index 000000000..bde3c604a
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/local/subscription/item/FeedGroupCarouselItem.kt
@@ -0,0 +1,57 @@
+package org.schabi.newpipe.local.subscription.item
+
+import android.content.Context
+import android.os.Parcelable
+import android.view.View
+import androidx.recyclerview.widget.LinearLayoutManager
+import androidx.recyclerview.widget.RecyclerView
+import com.xwray.groupie.GroupAdapter
+import com.xwray.groupie.kotlinandroidextensions.Item
+import com.xwray.groupie.kotlinandroidextensions.GroupieViewHolder
+import kotlinx.android.synthetic.main.feed_item_carousel.*
+import org.schabi.newpipe.R
+import org.schabi.newpipe.local.subscription.decoration.FeedGroupCarouselDecoration
+
+class FeedGroupCarouselItem(context: Context, private val carouselAdapter: GroupAdapter) : Item() {
+ private val feedGroupCarouselDecoration = FeedGroupCarouselDecoration(context)
+
+ private var linearLayoutManager: LinearLayoutManager? = null
+ private var listState: Parcelable? = null
+
+ override fun getLayout() = R.layout.feed_item_carousel
+
+ fun onSaveInstanceState(): Parcelable? {
+ listState = linearLayoutManager?.onSaveInstanceState()
+ return listState
+ }
+
+ fun onRestoreInstanceState(state: Parcelable?) {
+ linearLayoutManager?.onRestoreInstanceState(state)
+ listState = state
+ }
+
+ override fun createViewHolder(itemView: View): GroupieViewHolder {
+ val viewHolder = super.createViewHolder(itemView)
+
+ linearLayoutManager = LinearLayoutManager(itemView.context, RecyclerView.HORIZONTAL, false)
+
+ viewHolder.recycler_view.apply {
+ layoutManager = linearLayoutManager
+ adapter = carouselAdapter
+ addItemDecoration(feedGroupCarouselDecoration)
+ }
+
+ return viewHolder
+ }
+
+ override fun bind(viewHolder: GroupieViewHolder, position: Int) {
+ viewHolder.recycler_view.apply { adapter = carouselAdapter }
+ linearLayoutManager?.onRestoreInstanceState(listState)
+ }
+
+ override fun unbind(viewHolder: GroupieViewHolder) {
+ super.unbind(viewHolder)
+
+ listState = linearLayoutManager?.onSaveInstanceState()
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/item/FeedGroupReorderItem.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/item/FeedGroupReorderItem.kt
new file mode 100644
index 000000000..cf010af7f
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/local/subscription/item/FeedGroupReorderItem.kt
@@ -0,0 +1,48 @@
+package org.schabi.newpipe.local.subscription.item
+
+import android.view.MotionEvent
+import androidx.recyclerview.widget.ItemTouchHelper
+import androidx.recyclerview.widget.ItemTouchHelper.DOWN
+import androidx.recyclerview.widget.ItemTouchHelper.UP
+import com.xwray.groupie.kotlinandroidextensions.GroupieViewHolder
+import com.xwray.groupie.kotlinandroidextensions.Item
+import kotlinx.android.synthetic.main.feed_group_reorder_item.*
+import org.schabi.newpipe.R
+import org.schabi.newpipe.database.feed.model.FeedGroupEntity
+import org.schabi.newpipe.local.subscription.FeedGroupIcon
+
+data class FeedGroupReorderItem(
+ val groupId: Long = FeedGroupEntity.GROUP_ALL_ID,
+ val name: String,
+ val icon: FeedGroupIcon,
+ val dragCallback: ItemTouchHelper
+) : Item() {
+ constructor (feedGroupEntity: FeedGroupEntity, dragCallback: ItemTouchHelper)
+ : this(feedGroupEntity.uid, feedGroupEntity.name, feedGroupEntity.icon, dragCallback)
+
+ override fun getId(): Long {
+ return when (groupId) {
+ FeedGroupEntity.GROUP_ALL_ID -> super.getId()
+ else -> groupId
+ }
+ }
+
+ override fun getLayout(): Int = R.layout.feed_group_reorder_item
+
+ override fun bind(viewHolder: GroupieViewHolder, position: Int) {
+ viewHolder.group_name.text = name
+ viewHolder.group_icon.setImageResource(icon.getDrawableRes(viewHolder.containerView.context))
+ viewHolder.handle.setOnTouchListener { _, event ->
+ if (event.actionMasked == MotionEvent.ACTION_DOWN) {
+ dragCallback.startDrag(viewHolder)
+ return@setOnTouchListener true
+ }
+
+ false
+ }
+ }
+
+ override fun getDragDirs(): Int {
+ return UP or DOWN
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/item/FeedImportExportItem.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/item/FeedImportExportItem.kt
new file mode 100644
index 000000000..ab47564ce
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/local/subscription/item/FeedImportExportItem.kt
@@ -0,0 +1,116 @@
+package org.schabi.newpipe.local.subscription.item
+
+import android.graphics.Color
+import android.graphics.PorterDuff
+import android.view.View
+import android.view.ViewGroup
+import android.widget.ImageView
+import android.widget.TextView
+import androidx.annotation.DrawableRes
+import com.xwray.groupie.kotlinandroidextensions.GroupieViewHolder
+import com.xwray.groupie.kotlinandroidextensions.Item
+import kotlinx.android.synthetic.main.feed_import_export_group.*
+import org.schabi.newpipe.R
+import org.schabi.newpipe.extractor.NewPipe
+import org.schabi.newpipe.extractor.exceptions.ExtractionException
+import org.schabi.newpipe.util.AnimationUtils
+import org.schabi.newpipe.util.ServiceHelper
+import org.schabi.newpipe.util.ThemeHelper
+import org.schabi.newpipe.views.CollapsibleView
+
+class FeedImportExportItem(
+ val onImportPreviousSelected: () -> Unit,
+ val onImportFromServiceSelected: (Int) -> Unit,
+ val onExportSelected: () -> Unit,
+ var isExpanded: Boolean = false
+) : Item() {
+ companion object {
+ const val REFRESH_EXPANDED_STATUS = 123
+ }
+
+ override fun bind(viewHolder: GroupieViewHolder, position: Int, payloads: MutableList) {
+ if (payloads.contains(REFRESH_EXPANDED_STATUS)) {
+ viewHolder.import_export_options.apply { if (isExpanded) expand() else collapse() }
+ return
+ }
+
+ super.bind(viewHolder, position, payloads)
+ }
+
+ override fun getLayout(): Int = R.layout.feed_import_export_group
+
+ override fun bind(viewHolder: GroupieViewHolder, position: Int) {
+ if (viewHolder.import_from_options.childCount == 0) setupImportFromItems(viewHolder.import_from_options)
+ if (viewHolder.export_to_options.childCount == 0) setupExportToItems(viewHolder.export_to_options)
+
+ expandIconListener?.let { viewHolder.import_export_options.removeListener(it) }
+ expandIconListener = CollapsibleView.StateListener { newState ->
+ AnimationUtils.animateRotation(viewHolder.import_export_expand_icon,
+ 250, if (newState == CollapsibleView.COLLAPSED) 0 else 180)
+ }
+
+ viewHolder.import_export_options.currentState = if (isExpanded) CollapsibleView.EXPANDED else CollapsibleView.COLLAPSED
+ viewHolder.import_export_expand_icon.rotation = if (isExpanded) 180F else 0F
+ viewHolder.import_export_options.ready()
+
+ viewHolder.import_export_options.addListener(expandIconListener)
+ viewHolder.import_export.setOnClickListener {
+ viewHolder.import_export_options.switchState()
+ isExpanded = viewHolder.import_export_options.currentState == CollapsibleView.EXPANDED
+ }
+ }
+
+ override fun unbind(viewHolder: GroupieViewHolder) {
+ super.unbind(viewHolder)
+ expandIconListener?.let { viewHolder.import_export_options.removeListener(it) }
+ expandIconListener = null
+ }
+
+ private var expandIconListener: CollapsibleView.StateListener? = null
+
+ private fun addItemView(title: String, @DrawableRes icon: Int, container: ViewGroup): View {
+ val itemRoot = View.inflate(container.context, R.layout.subscription_import_export_item, null)
+ val titleView = itemRoot.findViewById(android.R.id.text1)
+ val iconView = itemRoot.findViewById(android.R.id.icon1)
+
+ titleView.text = title
+ iconView.setImageResource(icon)
+
+ container.addView(itemRoot)
+ return itemRoot
+ }
+
+ private fun setupImportFromItems(listHolder: ViewGroup) {
+ val previousBackupItem = addItemView(listHolder.context.getString(R.string.previous_export),
+ ThemeHelper.resolveResourceIdFromAttr(listHolder.context, R.attr.ic_backup), listHolder)
+ previousBackupItem.setOnClickListener { onImportPreviousSelected() }
+
+ val iconColor = if (ThemeHelper.isLightThemeSelected(listHolder.context)) Color.BLACK else Color.WHITE
+ val services = listHolder.context.resources.getStringArray(R.array.service_list)
+ for (serviceName in services) {
+ try {
+ val service = NewPipe.getService(serviceName)
+
+ val subscriptionExtractor = service.subscriptionExtractor ?: continue
+
+ val supportedSources = subscriptionExtractor.supportedSources
+ if (supportedSources.isEmpty()) continue
+
+ val itemView = addItemView(serviceName, ServiceHelper.getIcon(service.serviceId), listHolder)
+ val iconView = itemView.findViewById(android.R.id.icon1)
+ iconView.setColorFilter(iconColor, PorterDuff.Mode.SRC_IN)
+
+ itemView.setOnClickListener { onImportFromServiceSelected(service.serviceId) }
+ } catch (e: ExtractionException) {
+ throw RuntimeException("Services array contains an entry that it's not a valid service name ($serviceName)", e)
+ }
+
+ }
+ }
+
+ private fun setupExportToItems(listHolder: ViewGroup) {
+ val previousBackupItem = addItemView(listHolder.context.getString(R.string.file),
+ ThemeHelper.resolveResourceIdFromAttr(listHolder.context, R.attr.ic_save), listHolder)
+ previousBackupItem.setOnClickListener { onExportSelected() }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/item/HeaderItem.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/item/HeaderItem.kt
new file mode 100644
index 000000000..367605f46
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/local/subscription/item/HeaderItem.kt
@@ -0,0 +1,19 @@
+package org.schabi.newpipe.local.subscription.item
+
+import android.view.View.OnClickListener
+import com.xwray.groupie.kotlinandroidextensions.Item
+import com.xwray.groupie.kotlinandroidextensions.GroupieViewHolder
+import kotlinx.android.synthetic.main.header_item.*
+import org.schabi.newpipe.R
+
+class HeaderItem(val title: String, private val onClickListener: (() -> Unit)? = null) : Item() {
+
+ override fun getLayout(): Int = R.layout.header_item
+
+ override fun bind(viewHolder: GroupieViewHolder, position: Int) {
+ viewHolder.header_title.text = title
+
+ val listener: OnClickListener? = if (onClickListener != null) OnClickListener { onClickListener.invoke() } else null
+ viewHolder.root.setOnClickListener(listener)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/item/HeaderWithMenuItem.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/item/HeaderWithMenuItem.kt
new file mode 100644
index 000000000..5ffdfe7c1
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/local/subscription/item/HeaderWithMenuItem.kt
@@ -0,0 +1,48 @@
+package org.schabi.newpipe.local.subscription.item
+
+import android.view.View.*
+import androidx.annotation.DrawableRes
+import com.xwray.groupie.kotlinandroidextensions.GroupieViewHolder
+import com.xwray.groupie.kotlinandroidextensions.Item
+import kotlinx.android.synthetic.main.header_with_menu_item.*
+import org.schabi.newpipe.R
+
+class HeaderWithMenuItem(
+ val title: String,
+ @DrawableRes val itemIcon: Int = 0,
+ private val onClickListener: (() -> Unit)? = null,
+ private val menuItemOnClickListener: (() -> Unit)? = null
+) : Item() {
+ companion object {
+ const val PAYLOAD_SHOW_MENU_ITEM = 1
+ const val PAYLOAD_HIDE_MENU_ITEM = 2
+ }
+
+ override fun getLayout(): Int = R.layout.header_with_menu_item
+
+
+ override fun bind(viewHolder: GroupieViewHolder, position: Int, payloads: MutableList) {
+ if (payloads.contains(PAYLOAD_SHOW_MENU_ITEM)) {
+ viewHolder.header_menu_item.visibility = VISIBLE
+ return
+ } else if (payloads.contains(PAYLOAD_HIDE_MENU_ITEM)) {
+ viewHolder.header_menu_item.visibility = GONE
+ return
+ }
+
+ super.bind(viewHolder, position, payloads)
+ }
+
+ override fun bind(viewHolder: GroupieViewHolder, position: Int) {
+ viewHolder.header_title.text = title
+ viewHolder.header_menu_item.setImageResource(itemIcon)
+
+ val listener: OnClickListener? =
+ onClickListener?.let { OnClickListener { onClickListener.invoke() } }
+ viewHolder.root.setOnClickListener(listener)
+
+ val menuItemListener: OnClickListener? =
+ menuItemOnClickListener?.let { OnClickListener { menuItemOnClickListener.invoke() } }
+ viewHolder.header_menu_item.setOnClickListener(menuItemListener)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/item/PickerIconItem.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/item/PickerIconItem.kt
new file mode 100644
index 000000000..fedec9880
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/local/subscription/item/PickerIconItem.kt
@@ -0,0 +1,19 @@
+package org.schabi.newpipe.local.subscription.item
+
+import android.content.Context
+import androidx.annotation.DrawableRes
+import com.xwray.groupie.kotlinandroidextensions.Item
+import com.xwray.groupie.kotlinandroidextensions.GroupieViewHolder
+import kotlinx.android.synthetic.main.picker_icon_item.*
+import org.schabi.newpipe.R
+import org.schabi.newpipe.local.subscription.FeedGroupIcon
+
+class PickerIconItem(context: Context, val icon: FeedGroupIcon) : Item() {
+ @DrawableRes val iconRes: Int = icon.getDrawableRes(context)
+
+ override fun getLayout(): Int = R.layout.picker_icon_item
+
+ override fun bind(viewHolder: GroupieViewHolder, position: Int) {
+ viewHolder.icon_view.setImageResource(iconRes)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/item/PickerSubscriptionItem.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/item/PickerSubscriptionItem.kt
new file mode 100644
index 000000000..21c74b09f
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/local/subscription/item/PickerSubscriptionItem.kt
@@ -0,0 +1,51 @@
+package org.schabi.newpipe.local.subscription.item
+
+import android.view.View
+import com.nostra13.universalimageloader.core.DisplayImageOptions
+import com.nostra13.universalimageloader.core.ImageLoader
+import com.xwray.groupie.kotlinandroidextensions.Item
+import com.xwray.groupie.kotlinandroidextensions.GroupieViewHolder
+import kotlinx.android.synthetic.main.picker_subscription_item.*
+import org.schabi.newpipe.R
+import org.schabi.newpipe.database.subscription.SubscriptionEntity
+import org.schabi.newpipe.util.AnimationUtils
+import org.schabi.newpipe.util.AnimationUtils.animateView
+import org.schabi.newpipe.util.ImageDisplayConstants
+
+data class PickerSubscriptionItem(val subscriptionEntity: SubscriptionEntity, var isSelected: Boolean = false) : Item() {
+ companion object {
+ const val UPDATE_SELECTED = 123
+
+ val IMAGE_LOADING_OPTIONS: DisplayImageOptions = ImageDisplayConstants.DISPLAY_AVATAR_OPTIONS
+ }
+
+ override fun getLayout(): Int = R.layout.picker_subscription_item
+
+ override fun bind(viewHolder: GroupieViewHolder, position: Int, payloads: MutableList) {
+ if (payloads.contains(UPDATE_SELECTED)) {
+ animateView(viewHolder.selected_highlight, AnimationUtils.Type.LIGHT_SCALE_AND_ALPHA, isSelected, 150)
+ return
+ }
+
+ super.bind(viewHolder, position, payloads)
+ }
+
+ override fun bind(viewHolder: GroupieViewHolder, position: Int) {
+ ImageLoader.getInstance().displayImage(subscriptionEntity.avatarUrl, viewHolder.thumbnail_view, IMAGE_LOADING_OPTIONS)
+
+ viewHolder.title_view.text = subscriptionEntity.name
+ viewHolder.selected_highlight.visibility = if (isSelected) View.VISIBLE else View.GONE
+ }
+
+ override fun unbind(viewHolder: GroupieViewHolder) {
+ super.unbind(viewHolder)
+
+ viewHolder.selected_highlight.animate().setListener(null).cancel()
+ viewHolder.selected_highlight.visibility = View.GONE
+ viewHolder.selected_highlight.alpha = 1F
+ }
+
+ override fun getId(): Long {
+ return subscriptionEntity.uid
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/services/BaseImportExportService.java b/app/src/main/java/org/schabi/newpipe/local/subscription/services/BaseImportExportService.java
index 6b607cdca..e970ebfa4 100644
--- a/app/src/main/java/org/schabi/newpipe/local/subscription/services/BaseImportExportService.java
+++ b/app/src/main/java/org/schabi/newpipe/local/subscription/services/BaseImportExportService.java
@@ -34,10 +34,9 @@ import android.widget.Toast;
import org.reactivestreams.Publisher;
import org.schabi.newpipe.R;
import org.schabi.newpipe.extractor.subscription.SubscriptionExtractor;
+import org.schabi.newpipe.local.subscription.SubscriptionManager;
import org.schabi.newpipe.report.ErrorActivity;
import org.schabi.newpipe.report.UserAction;
-import org.schabi.newpipe.local.subscription.ImportExportEventListener;
-import org.schabi.newpipe.local.subscription.SubscriptionService;
import java.io.FileNotFoundException;
import java.io.IOException;
@@ -57,7 +56,7 @@ public abstract class BaseImportExportService extends Service {
protected NotificationManagerCompat notificationManager;
protected NotificationCompat.Builder notificationBuilder;
- protected SubscriptionService subscriptionService;
+ protected SubscriptionManager subscriptionManager;
protected final CompositeDisposable disposables = new CompositeDisposable();
protected final PublishProcessor notificationUpdater = PublishProcessor.create();
@@ -70,7 +69,7 @@ public abstract class BaseImportExportService extends Service {
@Override
public void onCreate() {
super.onCreate();
- subscriptionService = SubscriptionService.getInstance(this);
+ subscriptionManager = new SubscriptionManager(this);
setupNotification();
}
diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/ImportExportEventListener.java b/app/src/main/java/org/schabi/newpipe/local/subscription/services/ImportExportEventListener.java
similarity index 87%
rename from app/src/main/java/org/schabi/newpipe/local/subscription/ImportExportEventListener.java
rename to app/src/main/java/org/schabi/newpipe/local/subscription/services/ImportExportEventListener.java
index 01c0427f3..788073ee5 100644
--- a/app/src/main/java/org/schabi/newpipe/local/subscription/ImportExportEventListener.java
+++ b/app/src/main/java/org/schabi/newpipe/local/subscription/services/ImportExportEventListener.java
@@ -1,4 +1,4 @@
-package org.schabi.newpipe.local.subscription;
+package org.schabi.newpipe.local.subscription.services;
public interface ImportExportEventListener {
/**
diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/ImportExportJsonHelper.java b/app/src/main/java/org/schabi/newpipe/local/subscription/services/ImportExportJsonHelper.java
similarity index 98%
rename from app/src/main/java/org/schabi/newpipe/local/subscription/ImportExportJsonHelper.java
rename to app/src/main/java/org/schabi/newpipe/local/subscription/services/ImportExportJsonHelper.java
index ebfff9fe2..5b5ebf702 100644
--- a/app/src/main/java/org/schabi/newpipe/local/subscription/ImportExportJsonHelper.java
+++ b/app/src/main/java/org/schabi/newpipe/local/subscription/services/ImportExportJsonHelper.java
@@ -17,7 +17,7 @@
* along with this program. If not, see .
*/
-package org.schabi.newpipe.local.subscription;
+package org.schabi.newpipe.local.subscription.services;
import androidx.annotation.Nullable;
diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/services/SubscriptionsExportService.java b/app/src/main/java/org/schabi/newpipe/local/subscription/services/SubscriptionsExportService.java
index 31cd4b603..358024574 100644
--- a/app/src/main/java/org/schabi/newpipe/local/subscription/services/SubscriptionsExportService.java
+++ b/app/src/main/java/org/schabi/newpipe/local/subscription/services/SubscriptionsExportService.java
@@ -29,7 +29,6 @@ import org.reactivestreams.Subscription;
import org.schabi.newpipe.R;
import org.schabi.newpipe.database.subscription.SubscriptionEntity;
import org.schabi.newpipe.extractor.subscription.SubscriptionItem;
-import org.schabi.newpipe.local.subscription.ImportExportJsonHelper;
import java.io.File;
import java.io.FileNotFoundException;
@@ -96,7 +95,7 @@ public class SubscriptionsExportService extends BaseImportExportService {
private void startExport() {
showToast(R.string.export_ongoing);
- subscriptionService.subscriptionTable()
+ subscriptionManager.subscriptionTable()
.getAll()
.take(1)
.map(subscriptionEntities -> {
diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/services/SubscriptionsImportService.java b/app/src/main/java/org/schabi/newpipe/local/subscription/services/SubscriptionsImportService.java
index 62c1dfeb9..0d2f3757f 100644
--- a/app/src/main/java/org/schabi/newpipe/local/subscription/services/SubscriptionsImportService.java
+++ b/app/src/main/java/org/schabi/newpipe/local/subscription/services/SubscriptionsImportService.java
@@ -33,7 +33,6 @@ import org.schabi.newpipe.database.subscription.SubscriptionEntity;
import org.schabi.newpipe.extractor.NewPipe;
import org.schabi.newpipe.extractor.channel.ChannelInfo;
import org.schabi.newpipe.extractor.subscription.SubscriptionItem;
-import org.schabi.newpipe.local.subscription.ImportExportJsonHelper;
import org.schabi.newpipe.util.Constants;
import org.schabi.newpipe.util.ExtractorHelper;
@@ -180,6 +179,7 @@ public class SubscriptionsImportService extends BaseImportExportService {
.observeOn(Schedulers.io())
.doOnNext(getNotificationsConsumer())
+
.buffer(BUFFER_COUNT_BEFORE_INSERT)
.map(upsertBatch())
@@ -204,6 +204,7 @@ public class SubscriptionsImportService extends BaseImportExportService {
@Override
public void onError(Throwable error) {
+ Log.e(TAG, "Got an error!", error);
handleError(error);
}
@@ -242,7 +243,7 @@ public class SubscriptionsImportService extends BaseImportExportService {
if (n.isOnNext()) infoList.add(n.getValue());
}
- return subscriptionService.upsertAll(infoList);
+ return subscriptionManager.upsertAll(infoList);
};
}
diff --git a/app/src/main/java/org/schabi/newpipe/player/BackgroundPlayer.java b/app/src/main/java/org/schabi/newpipe/player/BackgroundPlayer.java
index 9e23d9145..4eaa2a73b 100644
--- a/app/src/main/java/org/schabi/newpipe/player/BackgroundPlayer.java
+++ b/app/src/main/java/org/schabi/newpipe/player/BackgroundPlayer.java
@@ -30,16 +30,16 @@ import android.content.res.Resources;
import android.graphics.Bitmap;
import android.os.Build;
import android.os.IBinder;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-import androidx.annotation.RequiresApi;
-import androidx.core.app.NotificationCompat;
-
import android.preference.PreferenceManager;
import android.util.Log;
import android.view.View;
import android.widget.RemoteViews;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.RequiresApi;
+import androidx.core.app.NotificationCompat;
+
import com.google.android.exoplayer2.PlaybackParameters;
import com.google.android.exoplayer2.Player;
import com.google.android.exoplayer2.source.MediaSource;
@@ -341,7 +341,7 @@ public final class BackgroundPlayer extends Service {
@Override
public void handleIntent(final Intent intent) {
super.handleIntent(intent);
-
+
resetNotification();
if (bigNotRemoteView != null)
bigNotRemoteView.setProgressBar(R.id.notificationProgressBar, 100, 0, false);
@@ -389,7 +389,6 @@ public final class BackgroundPlayer extends Service {
@Override
public void onPrepared(boolean playWhenReady) {
super.onPrepared(playWhenReady);
- simpleExoPlayer.setVolume(1f);
}
@Override
@@ -398,6 +397,12 @@ public final class BackgroundPlayer extends Service {
updatePlayback();
}
+ @Override
+ public void onMuteUnmuteButtonClicked() {
+ super.onMuteUnmuteButtonClicked();
+ updatePlayback();
+ }
+
@Override
public void onUpdateProgress(int currentProgress, int duration, int bufferPercent) {
updateProgress(currentProgress, duration, bufferPercent);
diff --git a/app/src/main/java/org/schabi/newpipe/player/BasePlayer.java b/app/src/main/java/org/schabi/newpipe/player/BasePlayer.java
index 46ca3921d..08fdb9258 100644
--- a/app/src/main/java/org/schabi/newpipe/player/BasePlayer.java
+++ b/app/src/main/java/org/schabi/newpipe/player/BasePlayer.java
@@ -153,6 +153,8 @@ public abstract class BasePlayer implements
public static final String START_PAUSED = "start_paused";
@NonNull
public static final String SELECT_ON_APPEND = "select_on_append";
+ @NonNull
+ public static final String IS_MUTED = "is_muted";
/*//////////////////////////////////////////////////////////////////////////
// Playback
@@ -275,6 +277,7 @@ public abstract class BasePlayer implements
final float playbackPitch = intent.getFloatExtra(PLAYBACK_PITCH, getPlaybackPitch());
final boolean playbackSkipSilence = intent.getBooleanExtra(PLAYBACK_SKIP_SILENCE,
getPlaybackSkipSilence());
+ final boolean isMuted = intent.getBooleanExtra(IS_MUTED, simpleExoPlayer == null ? false : isMuted());
// seek to timestamp if stream is already playing
if (simpleExoPlayer != null
@@ -283,7 +286,7 @@ public abstract class BasePlayer implements
&& playQueue.getItem() != null
&& queue.getItem().getUrl().equals(playQueue.getItem().getUrl())
&& queue.getItem().getRecoveryPosition() != PlayQueueItem.RECOVERY_UNSET
- ) {
+ ) {
simpleExoPlayer.seekTo(playQueue.getIndex(), queue.getItem().getRecoveryPosition());
return;
@@ -293,7 +296,7 @@ public abstract class BasePlayer implements
stateLoader = recordManager.loadStreamState(item)
.observeOn(AndroidSchedulers.mainThread())
.doFinally(() -> initPlayback(queue, repeatMode, playbackSpeed, playbackPitch, playbackSkipSilence,
- /*playOnInit=*/true))
+ /*playOnInit=*/true, isMuted))
.subscribe(
state -> queue.setRecovery(queue.getIndex(), state.getProgressTime()),
error -> {
@@ -306,7 +309,7 @@ public abstract class BasePlayer implements
}
// Good to go...
initPlayback(queue, repeatMode, playbackSpeed, playbackPitch, playbackSkipSilence,
- /*playOnInit=*/!intent.getBooleanExtra(START_PAUSED, false));
+ /*playOnInit=*/!intent.getBooleanExtra(START_PAUSED, false), isMuted);
}
protected void initPlayback(@NonNull final PlayQueue queue,
@@ -314,7 +317,8 @@ public abstract class BasePlayer implements
final float playbackSpeed,
final float playbackPitch,
final boolean playbackSkipSilence,
- final boolean playOnReady) {
+ final boolean playOnReady,
+ final boolean isMuted) {
destroyPlayer();
initPlayer(playOnReady);
setRepeatMode(repeatMode);
@@ -327,6 +331,8 @@ public abstract class BasePlayer implements
if (playQueueAdapter != null) playQueueAdapter.dispose();
playQueueAdapter = new PlayQueueAdapter(context, playQueue);
+
+ simpleExoPlayer.setVolume(isMuted ? 0 : 1);
}
public void destroyPlayer() {
@@ -532,6 +538,18 @@ public abstract class BasePlayer implements
if (simpleExoPlayer == null) return;
simpleExoPlayer.setShuffleModeEnabled(!simpleExoPlayer.getShuffleModeEnabled());
}
+ /*//////////////////////////////////////////////////////////////////////////
+ // Mute / Unmute
+ //////////////////////////////////////////////////////////////////////////*/
+
+ public void onMuteUnmuteButtonClicked() {
+ if (DEBUG) Log.d(TAG, "onMuteUnmuteButtonClicled() called");
+ simpleExoPlayer.setVolume(isMuted() ? 1 : 0);
+ }
+
+ public boolean isMuted() {
+ return simpleExoPlayer.getVolume() == 0;
+ }
/*//////////////////////////////////////////////////////////////////////////
// Progress Updates
diff --git a/app/src/main/java/org/schabi/newpipe/player/MainVideoPlayer.java b/app/src/main/java/org/schabi/newpipe/player/MainVideoPlayer.java
index f779c7bf9..42759a5ed 100644
--- a/app/src/main/java/org/schabi/newpipe/player/MainVideoPlayer.java
+++ b/app/src/main/java/org/schabi/newpipe/player/MainVideoPlayer.java
@@ -34,14 +34,17 @@ import android.os.Bundle;
import android.os.Handler;
import android.preference.PreferenceManager;
import android.provider.Settings;
+
import androidx.annotation.ColorInt;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.app.ActivityCompat;
import androidx.appcompat.app.AppCompatActivity;
import androidx.appcompat.content.res.AppCompatResources;
+import androidx.core.content.ContextCompat;
import androidx.recyclerview.widget.RecyclerView;
import androidx.recyclerview.widget.ItemTouchHelper;
+
import android.util.DisplayMetrics;
import android.util.Log;
import android.util.TypedValue;
@@ -116,7 +119,8 @@ public final class MainVideoPlayer extends AppCompatActivity
private SharedPreferences defaultPreferences;
- @Nullable private PlayerState playerState;
+ @Nullable
+ private PlayerState playerState;
private boolean isInMultiWindow;
private boolean isBackPressed;
@@ -130,11 +134,13 @@ public final class MainVideoPlayer extends AppCompatActivity
protected void onCreate(@Nullable Bundle savedInstanceState) {
assureCorrectAppLanguage(this);
super.onCreate(savedInstanceState);
- if (DEBUG) Log.d(TAG, "onCreate() called with: savedInstanceState = [" + savedInstanceState + "]");
+ if (DEBUG)
+ Log.d(TAG, "onCreate() called with: savedInstanceState = [" + savedInstanceState + "]");
defaultPreferences = PreferenceManager.getDefaultSharedPreferences(this);
ThemeHelper.setTheme(this);
getWindow().setBackgroundDrawable(new ColorDrawable(Color.BLACK));
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) getWindow().setStatusBarColor(Color.BLACK);
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP)
+ getWindow().setStatusBarColor(Color.BLACK);
setVolumeControlStream(AudioManager.STREAM_MUSIC);
WindowManager.LayoutParams lp = getWindow().getAttributes();
@@ -143,7 +149,7 @@ public final class MainVideoPlayer extends AppCompatActivity
hideSystemUi();
setContentView(R.layout.activity_main_player);
- playerImpl = new VideoPlayerImpl(this);
+ playerImpl = new VideoPlayerImpl(this);
playerImpl.setup(findViewById(android.R.id.content));
if (savedInstanceState != null && savedInstanceState.get(KEY_SAVED_STATE) != null) {
@@ -220,7 +226,7 @@ public final class MainVideoPlayer extends AppCompatActivity
playerImpl.setPlaybackQuality(playerState.getPlaybackQuality());
playerImpl.initPlayback(playerState.getPlayQueue(), playerState.getRepeatMode(),
playerState.getPlaybackSpeed(), playerState.getPlaybackPitch(),
- playerState.isPlaybackSkipSilence(), playerState.wasPlaying());
+ playerState.isPlaybackSkipSilence(), playerState.wasPlaying(), playerImpl.isMuted());
}
}
@@ -248,7 +254,7 @@ public final class MainVideoPlayer extends AppCompatActivity
if (playerImpl == null) return;
playerImpl.setRecovery();
- if(!playerImpl.gotDestroyed()) {
+ if (!playerImpl.gotDestroyed()) {
playerState = createPlayerState();
}
StateSaver.tryToSave(isChangingConfigurations(), null, outState, this);
@@ -396,6 +402,12 @@ public final class MainVideoPlayer extends AppCompatActivity
shuffleButton.setImageAlpha(shuffleAlpha);
}
+ protected void setMuteButton(final ImageButton muteButton, final boolean isMuted) {
+ muteButton.setImageDrawable(AppCompatResources.getDrawable(getApplicationContext(),
+ isMuted ? R.drawable.ic_volume_off_white_72dp : R.drawable.ic_volume_up_white_72dp));
+ }
+
+
private boolean isInMultiWindow() {
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && isInMultiWindowMode();
}
@@ -448,6 +460,7 @@ public final class MainVideoPlayer extends AppCompatActivity
private ImageButton toggleOrientationButton;
private ImageButton switchPopupButton;
private ImageButton switchBackgroundButton;
+ private ImageButton muteButton;
private RelativeLayout windowRootLayout;
private View secondaryControls;
@@ -484,6 +497,7 @@ public final class MainVideoPlayer extends AppCompatActivity
this.shareButton = rootView.findViewById(R.id.share);
this.toggleOrientationButton = rootView.findViewById(R.id.toggleOrientation);
this.switchBackgroundButton = rootView.findViewById(R.id.switchBackground);
+ this.muteButton = rootView.findViewById(R.id.switchMute);
this.switchPopupButton = rootView.findViewById(R.id.switchPopup);
this.queueLayout = findViewById(R.id.playQueuePanel);
@@ -493,7 +507,7 @@ public final class MainVideoPlayer extends AppCompatActivity
titleTextView.setSelected(true);
channelTextView.setSelected(true);
boolean showKodiButton = PreferenceManager.getDefaultSharedPreferences(this.context).getBoolean(
- this.context.getString(R.string.show_play_with_kodi_key), false);
+ this.context.getString(R.string.show_play_with_kodi_key), false);
kodiButton.setVisibility(showKodiButton ? View.VISIBLE : View.GONE);
getRootView().setKeepScreenOn(true);
@@ -535,6 +549,7 @@ public final class MainVideoPlayer extends AppCompatActivity
shareButton.setOnClickListener(this);
toggleOrientationButton.setOnClickListener(this);
switchBackgroundButton.setOnClickListener(this);
+ muteButton.setOnClickListener(this);
switchPopupButton.setOnClickListener(this);
getRootView().addOnLayoutChangeListener((view, l, t, r, b, ol, ot, or, ob) -> {
@@ -653,7 +668,8 @@ public final class MainVideoPlayer extends AppCompatActivity
this.getPlaybackSkipSilence(),
this.getPlaybackQuality(),
false,
- !isPlaying()
+ !isPlaying(),
+ isMuted()
);
context.startService(intent);
@@ -677,7 +693,8 @@ public final class MainVideoPlayer extends AppCompatActivity
this.getPlaybackSkipSilence(),
this.getPlaybackQuality(),
false,
- !isPlaying()
+ !isPlaying(),
+ isMuted()
);
context.startService(intent);
@@ -686,6 +703,12 @@ public final class MainVideoPlayer extends AppCompatActivity
finish();
}
+ @Override
+ public void onMuteUnmuteButtonClicked() {
+ super.onMuteUnmuteButtonClicked();
+ setMuteButton(muteButton, playerImpl.isMuted());
+ }
+
@Override
public void onClick(View v) {
@@ -723,11 +746,14 @@ public final class MainVideoPlayer extends AppCompatActivity
} else if (v.getId() == switchBackgroundButton.getId()) {
onPlayBackgroundButtonClicked();
+ } else if (v.getId() == muteButton.getId()) {
+ onMuteUnmuteButtonClicked();
+
} else if (v.getId() == closeButton.getId()) {
onPlaybackShutdown();
return;
} else if (v.getId() == kodiButton.getId()) {
- onKodiShare();
+ onKodiShare();
}
if (getCurrentState() != STATE_COMPLETED) {
@@ -770,13 +796,14 @@ public final class MainVideoPlayer extends AppCompatActivity
animateView(secondaryControls, SLIDE_AND_ALPHA, !isMoreControlsVisible,
DEFAULT_CONTROLS_DURATION);
showControls(DEFAULT_CONTROLS_DURATION);
+ setMuteButton(muteButton, playerImpl.isMuted());
}
private void onShareClicked() {
// share video at the current time (youtube.com/watch?v=ID&t=SECONDS)
ShareUtils.shareUrl(MainVideoPlayer.this,
playerImpl.getVideoTitle(),
- playerImpl.getVideoUrl() + "&t=" + String.valueOf(playerImpl.getPlaybackSeekBar().getProgress()/1000));
+ playerImpl.getVideoUrl() + "&t=" + String.valueOf(playerImpl.getPlaybackSeekBar().getProgress() / 1000));
}
private void onScreenRotationClicked() {
@@ -1009,7 +1036,7 @@ public final class MainVideoPlayer extends AppCompatActivity
@Override
public void onSwiped(int index) {
- if(index != -1) playQueue.remove(index);
+ if (index != -1) playQueue.remove(index);
}
};
}
@@ -1074,6 +1101,10 @@ public final class MainVideoPlayer extends AppCompatActivity
return repeatButton;
}
+ public ImageButton getMuteButton() {
+ return muteButton;
+ }
+
public ImageButton getPlayPauseButton() {
return playPauseButton;
}
@@ -1088,7 +1119,8 @@ public final class MainVideoPlayer extends AppCompatActivity
@Override
public boolean onDoubleTap(MotionEvent e) {
- if (DEBUG) Log.d(TAG, "onDoubleTap() called with: e = [" + e + "]" + "rawXy = " + e.getRawX() + ", " + e.getRawY() + ", xy = " + e.getX() + ", " + e.getY());
+ if (DEBUG)
+ Log.d(TAG, "onDoubleTap() called with: e = [" + e + "]" + "rawXy = " + e.getRawX() + ", " + e.getRawY() + ", xy = " + e.getX() + ", " + e.getY());
if (e.getX() > playerImpl.getRootView().getWidth() * 2 / 3) {
playerImpl.onFastForward();
@@ -1184,7 +1216,8 @@ public final class MainVideoPlayer extends AppCompatActivity
layoutParams.screenBrightness = currentProgressPercent;
getWindow().setAttributes(layoutParams);
- if (DEBUG) Log.d(TAG, "onScroll().brightnessControl, currentBrightness = " + currentProgressPercent);
+ if (DEBUG)
+ Log.d(TAG, "onScroll().brightnessControl, currentBrightness = " + currentProgressPercent);
final int resId =
currentProgressPercent < 0.25 ? R.drawable.ic_brightness_low_white_72dp
@@ -1223,7 +1256,8 @@ public final class MainVideoPlayer extends AppCompatActivity
@Override
public boolean onTouch(View v, MotionEvent event) {
//noinspection PointlessBooleanExpression
- if (DEBUG && false) Log.d(TAG, "onTouch() called with: v = [" + v + "], event = [" + event + "]");
+ if (DEBUG && false)
+ Log.d(TAG, "onTouch() called with: v = [" + v + "], event = [" + event + "]");
gestureDetector.onTouchEvent(event);
if (event.getAction() == MotionEvent.ACTION_UP && isMoving) {
isMoving = false;
diff --git a/app/src/main/java/org/schabi/newpipe/player/PopupVideoPlayer.java b/app/src/main/java/org/schabi/newpipe/player/PopupVideoPlayer.java
index fc14e8d51..b7638eda7 100644
--- a/app/src/main/java/org/schabi/newpipe/player/PopupVideoPlayer.java
+++ b/app/src/main/java/org/schabi/newpipe/player/PopupVideoPlayer.java
@@ -571,7 +571,8 @@ public final class PopupVideoPlayer extends Service {
this.getPlaybackSkipSilence(),
this.getPlaybackQuality(),
false,
- !isPlaying()
+ !isPlaying(),
+ isMuted()
);
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
context.startActivity(intent);
@@ -607,6 +608,12 @@ public final class PopupVideoPlayer extends Service {
updatePlayback();
}
+ @Override
+ public void onMuteUnmuteButtonClicked() {
+ super.onMuteUnmuteButtonClicked();
+ updatePlayback();
+ }
+
@Override
public void onUpdateProgress(int currentProgress, int duration, int bufferPercent) {
updateProgress(currentProgress, duration, bufferPercent);
diff --git a/app/src/main/java/org/schabi/newpipe/player/ServicePlayerActivity.java b/app/src/main/java/org/schabi/newpipe/player/ServicePlayerActivity.java
index 7aa2be05d..113592b47 100644
--- a/app/src/main/java/org/schabi/newpipe/player/ServicePlayerActivity.java
+++ b/app/src/main/java/org/schabi/newpipe/player/ServicePlayerActivity.java
@@ -3,14 +3,17 @@ package org.schabi.newpipe.player;
import android.content.ComponentName;
import android.content.Intent;
import android.content.ServiceConnection;
+import android.graphics.drawable.Drawable;
import android.os.Bundle;
import android.os.IBinder;
import android.provider.Settings;
+
import androidx.appcompat.app.AppCompatActivity;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import androidx.appcompat.widget.Toolbar;
import androidx.recyclerview.widget.ItemTouchHelper;
+
import android.util.Log;
import android.view.Menu;
import android.view.MenuItem;
@@ -92,6 +95,8 @@ public abstract class ServicePlayerActivity extends AppCompatActivity
private TextView playbackSpeedButton;
private TextView playbackPitchButton;
+ private Menu menu;
+
////////////////////////////////////////////////////////////////////////////
// Abstracts
////////////////////////////////////////////////////////////////////////////
@@ -145,8 +150,10 @@ public abstract class ServicePlayerActivity extends AppCompatActivity
@Override
public boolean onCreateOptionsMenu(Menu menu) {
+ this.menu = menu;
getMenuInflater().inflate(R.menu.menu_play_queue, menu);
getMenuInflater().inflate(getPlayerOptionMenuResource(), menu);
+ onMaybeMuteChanged();
return true;
}
@@ -162,6 +169,9 @@ public abstract class ServicePlayerActivity extends AppCompatActivity
case R.id.action_append_playlist:
appendAllToPlaylist();
return true;
+ case R.id.action_mute:
+ player.onMuteUnmuteButtonClicked();
+ return true;
case R.id.action_system_audio:
startActivity(new Intent(Settings.ACTION_SOUND_SETTINGS));
return true;
@@ -169,8 +179,8 @@ public abstract class ServicePlayerActivity extends AppCompatActivity
this.player.setRecovery();
getApplicationContext().sendBroadcast(getPlayerShutdownIntent());
getApplicationContext().startActivity(
- getSwitchIntent(MainVideoPlayer.class)
- .putExtra(BasePlayer.START_PAUSED, !this.player.isPlaying())
+ getSwitchIntent(MainVideoPlayer.class)
+ .putExtra(BasePlayer.START_PAUSED, !this.player.isPlaying())
);
return true;
}
@@ -194,7 +204,8 @@ public abstract class ServicePlayerActivity extends AppCompatActivity
this.player.getPlaybackSkipSilence(),
null,
false,
- false
+ false,
+ this.player.isMuted()
).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
.putExtra(BasePlayer.START_PAUSED, !this.player.isPlaying());
}
@@ -212,7 +223,7 @@ public abstract class ServicePlayerActivity extends AppCompatActivity
}
private void unbind() {
- if(serviceBound) {
+ if (serviceBound) {
unbindService(serviceConnection);
serviceBound = false;
stopPlayerListener();
@@ -554,6 +565,7 @@ public abstract class ServicePlayerActivity extends AppCompatActivity
onPlayModeChanged(repeatMode, shuffled);
onPlaybackParameterChanged(parameters);
onMaybePlaybackAdapterChanged();
+ onMaybeMuteChanged();
}
@Override
@@ -676,4 +688,23 @@ public abstract class ServicePlayerActivity extends AppCompatActivity
itemsList.setAdapter(maybeNewAdapter);
}
}
+
+ private void onMaybeMuteChanged() {
+ if (menu != null && player != null) {
+ MenuItem item = menu.findItem(R.id.action_mute);
+
+ //Change the mute-button item in ActionBar
+ //1) Text change:
+ item.setTitle(player.isMuted() ? R.string.unmute : R.string.mute);
+
+ //2) Icon change accordingly to current App Theme
+ item.setIcon(player.isMuted() ? getThemedDrawable(R.attr.volume_off) : getThemedDrawable(R.attr.volume_on));
+ }
+ }
+
+ private Drawable getThemedDrawable(int attribute) {
+ return getResources().getDrawable(
+ getTheme().obtainStyledAttributes(R.style.Theme_AppCompat, new int[]{attribute})
+ .getResourceId(0, 0));
+ }
}
diff --git a/app/src/main/java/org/schabi/newpipe/report/UserAction.java b/app/src/main/java/org/schabi/newpipe/report/UserAction.java
index 2cca9305a..f4f3e31b6 100644
--- a/app/src/main/java/org/schabi/newpipe/report/UserAction.java
+++ b/app/src/main/java/org/schabi/newpipe/report/UserAction.java
@@ -16,6 +16,7 @@ public enum UserAction {
REQUESTED_PLAYLIST("requested playlist"),
REQUESTED_KIOSK("requested kiosk"),
REQUESTED_COMMENTS("requested comments"),
+ REQUESTED_FEED("requested feed"),
DELETE_FROM_HISTORY("delete from history"),
PLAY_STREAM("Play stream"),
DOWNLOAD_POSTPROCESSING("download post-processing"),
diff --git a/app/src/main/java/org/schabi/newpipe/settings/ContentSettingsFragment.java b/app/src/main/java/org/schabi/newpipe/settings/ContentSettingsFragment.java
index 03d48ca5b..0be72d0eb 100644
--- a/app/src/main/java/org/schabi/newpipe/settings/ContentSettingsFragment.java
+++ b/app/src/main/java/org/schabi/newpipe/settings/ContentSettingsFragment.java
@@ -17,6 +17,7 @@ import androidx.preference.Preference;
import com.nononsenseapps.filepicker.Utils;
import com.nostra13.universalimageloader.core.ImageLoader;
+import org.schabi.newpipe.NewPipeDatabase;
import org.schabi.newpipe.R;
import org.schabi.newpipe.extractor.NewPipe;
import org.schabi.newpipe.extractor.localization.ContentCountry;
@@ -168,6 +169,9 @@ public class ContentSettingsFragment extends BasePreferenceFragment {
private void exportDatabase(String path) {
try {
+ //checkpoint before export
+ NewPipeDatabase.checkpoint();
+
ZipOutputStream outZip = new ZipOutputStream(
new BufferedOutputStream(
new FileOutputStream(path)));
diff --git a/app/src/main/java/org/schabi/newpipe/settings/NewPipeSettings.java b/app/src/main/java/org/schabi/newpipe/settings/NewPipeSettings.java
index e0003ccaa..6c765dc3d 100644
--- a/app/src/main/java/org/schabi/newpipe/settings/NewPipeSettings.java
+++ b/app/src/main/java/org/schabi/newpipe/settings/NewPipeSettings.java
@@ -23,7 +23,7 @@ package org.schabi.newpipe.settings;
import android.content.Context;
import android.content.SharedPreferences;
import android.os.Environment;
-import android.preference.PreferenceManager;
+import androidx.preference.PreferenceManager;
import androidx.annotation.NonNull;
import org.schabi.newpipe.R;
diff --git a/app/src/main/java/org/schabi/newpipe/settings/SelectChannelFragment.java b/app/src/main/java/org/schabi/newpipe/settings/SelectChannelFragment.java
index 7064aec33..9ee12facc 100644
--- a/app/src/main/java/org/schabi/newpipe/settings/SelectChannelFragment.java
+++ b/app/src/main/java/org/schabi/newpipe/settings/SelectChannelFragment.java
@@ -18,9 +18,9 @@ import com.nostra13.universalimageloader.core.ImageLoader;
import org.schabi.newpipe.R;
import org.schabi.newpipe.database.subscription.SubscriptionEntity;
+import org.schabi.newpipe.local.subscription.SubscriptionManager;
import org.schabi.newpipe.report.ErrorActivity;
import org.schabi.newpipe.report.UserAction;
-import org.schabi.newpipe.local.subscription.SubscriptionService;
import java.util.List;
import java.util.Vector;
@@ -99,8 +99,8 @@ public class SelectChannelFragment extends DialogFragment {
emptyView.setVisibility(View.GONE);
- SubscriptionService subscriptionService = SubscriptionService.getInstance(getContext());
- subscriptionService.getSubscription().toObservable()
+ SubscriptionManager subscriptionManager = new SubscriptionManager(getContext());
+ subscriptionManager.subscriptions().toObservable()
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(getSubscriptionObserver());
diff --git a/app/src/main/java/org/schabi/newpipe/settings/VideoAudioSettingsFragment.java b/app/src/main/java/org/schabi/newpipe/settings/VideoAudioSettingsFragment.java
index d5f46fb22..383cf7f74 100644
--- a/app/src/main/java/org/schabi/newpipe/settings/VideoAudioSettingsFragment.java
+++ b/app/src/main/java/org/schabi/newpipe/settings/VideoAudioSettingsFragment.java
@@ -6,11 +6,15 @@ import android.os.Build;
import android.os.Bundle;
import android.provider.Settings;
+import android.text.format.DateUtils;
+import android.widget.Toast;
import androidx.annotation.Nullable;
import androidx.preference.ListPreference;
import com.google.android.material.snackbar.Snackbar;
+import java.util.LinkedList;
+import java.util.List;
import org.schabi.newpipe.R;
import org.schabi.newpipe.util.PermissionHelper;
@@ -22,23 +26,7 @@ public class VideoAudioSettingsFragment extends BasePreferenceFragment {
public void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
- //initializing R.array.seek_duration_description to display the translation of seconds
- Resources res = getResources();
- String[] durationsValues = res.getStringArray(R.array.seek_duration_value);
- String[] durationsDescriptions = res.getStringArray(R.array.seek_duration_description);
- int currentDurationValue;
- for (int i = 0; i < durationsDescriptions.length; i++) {
- currentDurationValue = Integer.parseInt(durationsValues[i]) / 1000;
- try {
- durationsDescriptions[i] = String.format(
- res.getQuantityString(R.plurals.dynamic_seek_duration_description, currentDurationValue),
- currentDurationValue);
- } catch (Resources.NotFoundException ignored) {
- //if this happens, the translation is missing, and the english string will be displayed instead
- }
- }
- ListPreference durations = (ListPreference) findPreference(getString(R.string.seek_duration_key));
- durations.setEntries(durationsDescriptions);
+ updateSeekOptions();
listener = (sharedPreferences, s) -> {
@@ -58,10 +46,59 @@ public class VideoAudioSettingsFragment extends BasePreferenceFragment {
.show();
}
+ } else if (s.equals(getString(R.string.use_inexact_seek_key))) {
+ updateSeekOptions();
}
};
}
+ /**
+ * Update fast-forward/-rewind seek duration options according to language and inexact seek setting.
+ * Exoplayer can't seek 5 seconds in audio when using inexact seek.
+ */
+ private void updateSeekOptions() {
+ //initializing R.array.seek_duration_description to display the translation of seconds
+ final Resources res = getResources();
+ final String[] durationsValues = res.getStringArray(R.array.seek_duration_value);
+ final List displayedDurationValues = new LinkedList<>();
+ final List displayedDescriptionValues = new LinkedList<>();
+ int currentDurationValue;
+ final boolean inexactSeek = getPreferenceManager().getSharedPreferences()
+ .getBoolean(res.getString(R.string.use_inexact_seek_key), false);
+
+ for (String durationsValue : durationsValues) {
+ currentDurationValue =
+ Integer.parseInt(durationsValue) / (int) DateUtils.SECOND_IN_MILLIS;
+ if (inexactSeek && currentDurationValue % 10 == 5) {
+ continue;
+ }
+
+ displayedDurationValues.add(durationsValue);
+ try {
+ displayedDescriptionValues.add(String.format(
+ res.getQuantityString(R.plurals.seconds,
+ currentDurationValue),
+ currentDurationValue));
+ } catch (Resources.NotFoundException ignored) {
+ //if this happens, the translation is missing, and the english string will be displayed instead
+ }
+ }
+
+ final ListPreference durations = (ListPreference) findPreference(getString(R.string.seek_duration_key));
+ durations.setEntryValues(displayedDurationValues.toArray(new CharSequence[0]));
+ durations.setEntries(displayedDescriptionValues.toArray(new CharSequence[0]));
+ final int selectedDuration = Integer.parseInt(durations.getValue());
+ if (inexactSeek && selectedDuration / (int) DateUtils.SECOND_IN_MILLIS % 10 == 5) {
+ final int newDuration = selectedDuration / (int) DateUtils.SECOND_IN_MILLIS + 5;
+ durations.setValue(Integer.toString(newDuration * (int) DateUtils.SECOND_IN_MILLIS));
+
+ Toast toast = Toast
+ .makeText(getContext(),
+ getString(R.string.new_seek_duration_toast, newDuration),
+ Toast.LENGTH_LONG);
+ toast.show();
+ }
+ }
@Override
public void onCreatePreferences(Bundle savedInstanceState, String rootKey) {
diff --git a/app/src/main/java/org/schabi/newpipe/settings/custom/DurationListPreference.kt b/app/src/main/java/org/schabi/newpipe/settings/custom/DurationListPreference.kt
new file mode 100644
index 000000000..4bc59fcee
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/settings/custom/DurationListPreference.kt
@@ -0,0 +1,46 @@
+package org.schabi.newpipe.settings.custom
+
+import android.content.Context
+import android.util.AttributeSet
+import androidx.preference.ListPreference
+import org.schabi.newpipe.util.Localization
+
+/**
+ * An extension of a common ListPreference where it sets the duration values to human readable strings.
+ *
+ * The values in the entry values array will be interpreted as seconds. If the value of a specific position
+ * is less than or equals to zero, its original entry title will be used.
+ *
+ * If the entry values array have anything other than numbers in it, an exception will be raised.
+ */
+class DurationListPreference : ListPreference {
+ constructor(context: Context?, attrs: AttributeSet?, defStyleAttr: Int, defStyleRes: Int) : super(context, attrs, defStyleAttr, defStyleRes)
+ constructor(context: Context?, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr)
+ constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs)
+ constructor(context: Context?) : super(context)
+
+ override fun onAttached() {
+ super.onAttached()
+
+ val originalEntryTitles = entries
+ val originalEntryValues = entryValues
+ val newEntryTitles = arrayOfNulls(originalEntryValues.size)
+
+ for (i in originalEntryValues.indices) {
+ val currentDurationValue: Int
+ try {
+ currentDurationValue = (originalEntryValues[i] as String).toInt()
+ } catch (e: NumberFormatException) {
+ throw RuntimeException("Invalid number was set in the preference entry values array", e)
+ }
+
+ if (currentDurationValue <= 0) {
+ newEntryTitles[i] = originalEntryTitles[i]
+ } else {
+ newEntryTitles[i] = Localization.localizeDuration(context, currentDurationValue)
+ }
+ }
+
+ entries = newEntryTitles
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/schabi/newpipe/settings/tabs/Tab.java b/app/src/main/java/org/schabi/newpipe/settings/tabs/Tab.java
index cba3c4534..cc40298b9 100644
--- a/app/src/main/java/org/schabi/newpipe/settings/tabs/Tab.java
+++ b/app/src/main/java/org/schabi/newpipe/settings/tabs/Tab.java
@@ -218,7 +218,7 @@ public abstract class Tab {
@Override
public String getTabName(Context context) {
- return context.getString(R.string.fragment_whats_new);
+ return context.getString(R.string.fragment_feed_title);
}
@DrawableRes
diff --git a/app/src/main/java/org/schabi/newpipe/streams/WebMReader.java b/app/src/main/java/org/schabi/newpipe/streams/WebMReader.java
index 42875c364..b1628d954 100644
--- a/app/src/main/java/org/schabi/newpipe/streams/WebMReader.java
+++ b/app/src/main/java/org/schabi/newpipe/streams/WebMReader.java
@@ -37,6 +37,7 @@ public class WebMReader {
private final static int ID_DefaultDuration = 0x3E383;
private final static int ID_FlagLacing = 0x1C;
private final static int ID_CodecDelay = 0x16AA;
+ private final static int ID_SeekPreRoll = 0x16BB;
private final static int ID_Cluster = 0x0F43B675;
private final static int ID_Timecode = 0x67;
@@ -332,6 +333,10 @@ public class WebMReader {
break;
case ID_CodecDelay:
entry.codecDelay = readNumber(elem);
+ break;
+ case ID_SeekPreRoll:
+ entry.seekPreRoll = readNumber(elem);
+ break;
default:
break;
}
@@ -414,8 +419,9 @@ public class WebMReader {
public byte[] codecPrivate;
public byte[] bMetadata;
public TrackKind kind;
- public long defaultDuration;
- public long codecDelay;
+ public long defaultDuration = -1;
+ public long codecDelay = -1;
+ public long seekPreRoll = -1;
}
public class Segment {
diff --git a/app/src/main/java/org/schabi/newpipe/streams/WebMWriter.java b/app/src/main/java/org/schabi/newpipe/streams/WebMWriter.java
index 8525fabd2..39db33ad0 100644
--- a/app/src/main/java/org/schabi/newpipe/streams/WebMWriter.java
+++ b/app/src/main/java/org/schabi/newpipe/streams/WebMWriter.java
@@ -23,7 +23,10 @@ public class WebMWriter implements Closeable {
private final static int BUFFER_SIZE = 8 * 1024;
private final static int DEFAULT_TIMECODE_SCALE = 1000000;
private final static int INTERV = 100;// 100ms on 1000000us timecode scale
- private final static int DEFAULT_CUES_EACH_MS = 5000;// 100ms on 1000000us timecode scale
+ private final static int DEFAULT_CUES_EACH_MS = 5000;// 5000ms on 1000000us timecode scale
+ private final static byte CLUSTER_HEADER_SIZE = 8;
+ private final static int CUE_RESERVE_SIZE = 65535;
+ private final static byte MINIMUM_EBML_VOID_SIZE = 4;
private WebMReader.WebMTrack[] infoTracks;
private SharpStream[] sourceTracks;
@@ -38,15 +41,18 @@ public class WebMWriter implements Closeable {
private Segment[] readersSegment;
private Cluster[] readersCluster;
- private int[] predefinedDurations;
+ private ArrayList clustersOffsetsSizes;
private byte[] outBuffer;
+ private ByteBuffer outByteBuffer;
public WebMWriter(SharpStream... source) {
sourceTracks = source;
readers = new WebMReader[sourceTracks.length];
infoTracks = new WebMTrack[sourceTracks.length];
outBuffer = new byte[BUFFER_SIZE];
+ outByteBuffer = ByteBuffer.wrap(outBuffer);
+ clustersOffsetsSizes = new ArrayList<>(256);
}
public WebMTrack[] getTracksFromSource(int sourceIndex) throws IllegalStateException {
@@ -83,11 +89,9 @@ public class WebMWriter implements Closeable {
try {
readersSegment = new Segment[readers.length];
readersCluster = new Cluster[readers.length];
- predefinedDurations = new int[readers.length];
for (int i = 0; i < readers.length; i++) {
infoTracks[i] = readers[i].selectTrack(trackIndex[i]);
- predefinedDurations[i] = -1;
readersSegment[i] = readers[i].getNextSegment();
}
} finally {
@@ -118,6 +122,8 @@ public class WebMWriter implements Closeable {
readersSegment = null;
readersCluster = null;
outBuffer = null;
+ outByteBuffer = null;
+ clustersOffsetsSizes = null;
}
public void build(SharpStream out) throws IOException, RuntimeException {
@@ -140,7 +146,7 @@ public class WebMWriter implements Closeable {
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00// segment content size
});
- long baseSegmentOffset = written + listBuffer.get(0).length;
+ long segmentOffset = written + listBuffer.get(0).length;
/* seek head */
listBuffer.add(new byte[]{
@@ -177,20 +183,22 @@ public class WebMWriter implements Closeable {
/* tracks */
listBuffer.addAll(makeTracks());
- for (byte[] buff : listBuffer) {
- dump(buff, out);
- }
+ dump(listBuffer, out);
- // reserve space for Cues element, but is a waste of space (actually is 64 KiB)
- // TODO: better Cue maker
- long cueReservedOffset = written;
- dump(new byte[]{(byte) 0xec, 0x20, (byte) 0xff, (byte) 0xfb}, out);
- int reserved = (1024 * 63) - 4;
- while (reserved > 0) {
- int write = Math.min(reserved, outBuffer.length);
- out.write(outBuffer, 0, write);
- reserved -= write;
- written += write;
+ // reserve space for Cues element
+ long cueOffset = written;
+ make_EBML_void(out, CUE_RESERVE_SIZE, true);
+
+ int[] defaultSampleDuration = new int[infoTracks.length];
+ long[] duration = new long[infoTracks.length];
+
+ for (int i = 0; i < infoTracks.length; i++) {
+ if (infoTracks[i].defaultDuration < 0) {
+ defaultSampleDuration[i] = -1;// not available
+ } else {
+ defaultSampleDuration[i] = (int) Math.ceil(infoTracks[i].defaultDuration / (float) DEFAULT_TIMECODE_SCALE);
+ }
+ duration[i] = -1;
}
// Select a track for the cue
@@ -198,16 +206,8 @@ public class WebMWriter implements Closeable {
long nextCueTime = infoTracks[cuesForTrackId].trackType == 1 ? -1 : 0;
ArrayList keyFrames = new ArrayList<>(32);
- ArrayList clusterOffsets = new ArrayList<>(32);
- ArrayList clusterSizes = new ArrayList<>(32);
-
- long duration = 0;
- int durationFromTrackId = 0;
-
- byte[] bTimecode = makeTimecode(0);
-
int firstClusterOffset = (int) written;
- long currentClusterOffset = makeCluster(out, bTimecode, 0, clusterOffsets, clusterSizes);
+ long currentClusterOffset = makeCluster(out, 0, 0, true);
long baseTimecode = 0;
long limitTimecode = -1;
@@ -239,8 +239,7 @@ public class WebMWriter implements Closeable {
newClusterByTrackId = -1;
baseTimecode = bloq.absoluteTimecode;
limitTimecode = baseTimecode + INTERV;
- bTimecode = makeTimecode(baseTimecode);
- currentClusterOffset = makeCluster(out, bTimecode, currentClusterOffset, clusterOffsets, clusterSizes);
+ currentClusterOffset = makeCluster(out, baseTimecode, currentClusterOffset, true);
}
if (cuesForTrackId == i) {
@@ -248,19 +247,18 @@ public class WebMWriter implements Closeable {
if (nextCueTime > -1) {
nextCueTime += DEFAULT_CUES_EACH_MS;
}
- keyFrames.add(
- new KeyFrame(baseSegmentOffset, currentClusterOffset - 8, written, bTimecode.length, bloq.absoluteTimecode)
- );
+ keyFrames.add(new KeyFrame(segmentOffset, currentClusterOffset, written, bloq.absoluteTimecode));
}
}
writeBlock(out, bloq, baseTimecode);
blockWritten++;
- if (bloq.absoluteTimecode > duration) {
- duration = bloq.absoluteTimecode;
- durationFromTrackId = bloq.trackNumber;
+ if (defaultSampleDuration[i] < 0 && duration[i] >= 0) {
+ // if the sample duration in unknown, calculate using current_duration - previous_duration
+ defaultSampleDuration[i] = (int) (bloq.absoluteTimecode - duration[i]);
}
+ duration[i] = bloq.absoluteTimecode;
if (limitTimecode < 0) {
limitTimecode = bloq.absoluteTimecode + INTERV;
@@ -276,55 +274,61 @@ public class WebMWriter implements Closeable {
}
}
- makeCluster(out, null, currentClusterOffset, null, clusterSizes);
+ makeCluster(out, -1, currentClusterOffset, false);
long segmentSize = written - offsetSegmentSizeSet - 7;
- /* ---- final step write offsets and sizes ---- */
+ /* Segment size */
seekTo(out, offsetSegmentSizeSet);
- writeLong(out, segmentSize);
+ outByteBuffer.putLong(0, segmentSize);
+ out.write(outBuffer, 1, DataReader.LONG_SIZE - 1);
- if (predefinedDurations[durationFromTrackId] > -1) {
- duration += predefinedDurations[durationFromTrackId];// this value is full-filled in makeTrackEntry() method
- }
- seekTo(out, offsetInfoDurationSet);
- writeFloat(out, duration);
-
- firstClusterOffset -= baseSegmentOffset;
- seekTo(out, offsetClusterSet);
- writeInt(out, firstClusterOffset);
-
- seekTo(out, cueReservedOffset);
-
- /* Cue */
- dump(new byte[]{0x1c, 0x53, (byte) 0xbb, 0x6b, 0x20, 0x00, 0x00}, out);
-
- for (KeyFrame keyFrame : keyFrames) {
- for (byte[] buffer : makeCuePoint(cuesForTrackId, keyFrame)) {
- dump(buffer, out);
- if (written >= (cueReservedOffset + 65535 - 16)) {
- throw new IOException("Too many Cues");
- }
+ /* Segment duration */
+ long longestDuration = 0;
+ for (int i = 0; i < duration.length; i++) {
+ if (defaultSampleDuration[i] > 0) {
+ duration[i] += defaultSampleDuration[i];
+ }
+ if (duration[i] > longestDuration) {
+ longestDuration = duration[i];
}
}
- short cueSize = (short) (written - cueReservedOffset - 7);
+ seekTo(out, offsetInfoDurationSet);
+ outByteBuffer.putFloat(0, longestDuration);
+ dump(outBuffer, DataReader.FLOAT_SIZE, out);
- /* EBML Void */
- ByteBuffer voidBuffer = ByteBuffer.allocate(4);
- voidBuffer.putShort((short) 0xec20);
- voidBuffer.putShort((short) (firstClusterOffset - written - 4));
- dump(voidBuffer.array(), out);
+ /* first Cluster offset */
+ firstClusterOffset -= segmentOffset;
+ writeInt(out, offsetClusterSet, firstClusterOffset);
- seekTo(out, offsetCuesSet);
- writeInt(out, (int) (cueReservedOffset - baseSegmentOffset));
+ seekTo(out, cueOffset);
- seekTo(out, cueReservedOffset + 5);
- writeShort(out, cueSize);
+ /* Cue */
+ short cueSize = 0;
+ dump(new byte[]{0x1c, 0x53, (byte) 0xbb, 0x6b, 0x20, 0x00, 0x00}, out);// header size is 7
- for (int i = 0; i < clusterSizes.size(); i++) {
- seekTo(out, clusterOffsets.get(i));
- byte[] buffer = ByteBuffer.allocate(4).putInt(clusterSizes.get(i) | 0x10000000).array();
- dump(buffer, out);
+ for (KeyFrame keyFrame : keyFrames) {
+ int size = makeCuePoint(cuesForTrackId, keyFrame, outBuffer);
+
+ if ((cueSize + size + 7 + MINIMUM_EBML_VOID_SIZE) > CUE_RESERVE_SIZE) {
+ break;// no space left
+ }
+
+ cueSize += size;
+ dump(outBuffer, size, out);
+ }
+
+ make_EBML_void(out, CUE_RESERVE_SIZE - cueSize - 7, false);
+
+ seekTo(out, cueOffset + 5);
+ outByteBuffer.putShort(0, cueSize);
+ dump(outBuffer, DataReader.SHORT_SIZE, out);
+
+ /* seek head, seek for cues element */
+ writeInt(out, offsetCuesSet, (int) (cueOffset - segmentOffset));
+
+ for (ClusterInfo cluster : clustersOffsetsSizes) {
+ writeInt(out, cluster.offset, cluster.size | 0x10000000);
}
}
@@ -375,25 +379,10 @@ public class WebMWriter implements Closeable {
written = offset;
}
- private void writeLong(SharpStream stream, long number) throws IOException {
- byte[] buffer = ByteBuffer.allocate(DataReader.LONG_SIZE).putLong(number).array();
- stream.write(buffer, 1, buffer.length - 1);
- written += buffer.length - 1;
- }
-
- private void writeFloat(SharpStream stream, float number) throws IOException {
- byte[] buffer = ByteBuffer.allocate(DataReader.FLOAT_SIZE).putFloat(number).array();
- dump(buffer, stream);
- }
-
- private void writeShort(SharpStream stream, short number) throws IOException {
- byte[] buffer = ByteBuffer.allocate(DataReader.SHORT_SIZE).putShort(number).array();
- dump(buffer, stream);
- }
-
- private void writeInt(SharpStream stream, int number) throws IOException {
- byte[] buffer = ByteBuffer.allocate(DataReader.INTEGER_SIZE).putInt(number).array();
- dump(buffer, stream);
+ private void writeInt(SharpStream stream, long offset, int number) throws IOException {
+ seekTo(stream, offset);
+ outByteBuffer.putInt(0, number);
+ dump(outBuffer, DataReader.INTEGER_SIZE, stream);
}
private void writeBlock(SharpStream stream, Block bloq, long clusterTimecode) throws IOException {
@@ -416,47 +405,43 @@ public class WebMWriter implements Closeable {
}
listBuffer.set(1, encode(blockSize, false));
- for (byte[] buff : listBuffer) {
- dump(buff, stream);
- }
+ dump(listBuffer, stream);
int read;
while ((read = bloq.data.read(outBuffer)) > 0) {
- stream.write(outBuffer, 0, read);
- written += read;
+ dump(outBuffer, read, stream);
}
}
- private byte[] makeTimecode(long timecode) {
- ByteBuffer buffer = ByteBuffer.allocate(9);
- buffer.put((byte) 0xe7);
- buffer.put(encode(timecode, true));
+ private long makeCluster(SharpStream stream, long timecode, long offset, boolean create) throws IOException {
+ ClusterInfo cluster;
- byte[] res = new byte[buffer.position()];
- System.arraycopy(buffer.array(), 0, res, 0, res.length);
-
- return res;
- }
-
- private long makeCluster(SharpStream stream, byte[] bTimecode, long startOffset, ArrayList clusterOffsets, ArrayList clusterSizes) throws IOException {
- if (startOffset > 0) {
- clusterSizes.add((int) (written - startOffset));// size for last offset
+ if (offset > 0) {
+ // save the size of the previous cluster (maximum 256 MiB)
+ cluster = clustersOffsetsSizes.get(clustersOffsetsSizes.size() - 1);
+ cluster.size = (int) (written - offset - CLUSTER_HEADER_SIZE);
}
- if (clusterOffsets != null) {
+ offset = written;
+
+ if (create) {
/* cluster */
dump(new byte[]{0x1f, 0x43, (byte) 0xb6, 0x75}, stream);
- clusterOffsets.add(written);// warning: max cluster size is 256 MiB
- dump(new byte[]{0x10, 0x00, 0x00, 0x00}, stream);
- startOffset = written;// size for the this cluster
+ cluster = new ClusterInfo();
+ cluster.offset = written;
+ clustersOffsetsSizes.add(cluster);
- dump(bTimecode, stream);
+ dump(new byte[]{
+ 0x10, 0x00, 0x00, 0x00,
+ /* timestamp */
+ (byte) 0xe7
+ }, stream);
- return startOffset;
+ dump(encode(timecode, true), stream);
}
- return -1;
+ return offset;
}
private void makeEBML(SharpStream stream) throws IOException {
@@ -509,13 +494,24 @@ public class WebMWriter implements Closeable {
buffer.add(new byte[]{(byte) 0x86});
buffer.addAll(encode(track.codecId));
+ /* codec delay*/
+ if (track.codecDelay >= 0) {
+ buffer.add(new byte[]{0x56, (byte) 0xAA});
+ buffer.add(encode(track.codecDelay, true));
+ }
+
+ /* codec seek pre-roll*/
+ if (track.seekPreRoll >= 0) {
+ buffer.add(new byte[]{0x56, (byte) 0xBB});
+ buffer.add(encode(track.seekPreRoll, true));
+ }
+
/* type */
buffer.add(new byte[]{(byte) 0x83});
buffer.add(encode(track.trackType, true));
/* default duration */
- if (track.defaultDuration != 0) {
- predefinedDurations[internalTrackId] = (int) Math.ceil(track.defaultDuration / (float) DEFAULT_TIMECODE_SCALE);
+ if (track.defaultDuration >= 0) {
buffer.add(new byte[]{0x23, (byte) 0xe3, (byte) 0x83});
buffer.add(encode(track.defaultDuration, true));
}
@@ -538,21 +534,29 @@ public class WebMWriter implements Closeable {
}
- private ArrayList makeCuePoint(int internalTrackId, KeyFrame keyFrame) {
- ArrayList buffer = new ArrayList<>(5);
+ private int makeCuePoint(int internalTrackId, KeyFrame keyFrame, byte[] buffer) {
+ ArrayList cue = new ArrayList<>(5);
/* CuePoint */
- buffer.add(new byte[]{(byte) 0xbb});
- buffer.add(null);
+ cue.add(new byte[]{(byte) 0xbb});
+ cue.add(null);
/* CueTime */
- buffer.add(new byte[]{(byte) 0xb3});
- buffer.add(encode(keyFrame.atTimecode, true));
+ cue.add(new byte[]{(byte) 0xb3});
+ cue.add(encode(keyFrame.duration, true));
/* CueTrackPosition */
- buffer.addAll(makeCueTrackPosition(internalTrackId, keyFrame));
+ cue.addAll(makeCueTrackPosition(internalTrackId, keyFrame));
- return lengthFor(buffer);
+ int size = 0;
+ lengthFor(cue);
+
+ for (byte[] buff : cue) {
+ System.arraycopy(buff, 0, buffer, size, buff.length);
+ size += buff.length;
+ }
+
+ return size;
}
private ArrayList makeCueTrackPosition(int internalTrackId, KeyFrame keyFrame) {
@@ -568,20 +572,48 @@ public class WebMWriter implements Closeable {
/* CueClusterPosition */
buffer.add(new byte[]{(byte) 0xf1});
- buffer.add(encode(keyFrame.atCluster, true));
+ buffer.add(encode(keyFrame.clusterPosition, true));
/* CueRelativePosition */
- if (keyFrame.atBlock > 0) {
+ if (keyFrame.relativePosition > 0) {
buffer.add(new byte[]{(byte) 0xf0});
- buffer.add(encode(keyFrame.atBlock, true));
+ buffer.add(encode(keyFrame.relativePosition, true));
}
return lengthFor(buffer);
}
+ private void make_EBML_void(SharpStream out, int size, boolean wipe) throws IOException {
+ /* ebml void */
+ outByteBuffer.putShort(0, (short) 0xec20);
+ outByteBuffer.putShort(2, (short) (size - 4));
+
+ dump(outBuffer, 4, out);
+
+ if (wipe) {
+ size -= 4;
+ while (size > 0) {
+ int write = Math.min(size, outBuffer.length);
+ dump(outBuffer, write, out);
+ size -= write;
+ }
+ }
+ }
+
private void dump(byte[] buffer, SharpStream stream) throws IOException {
- stream.write(buffer);
- written += buffer.length;
+ dump(buffer, buffer.length, stream);
+ }
+
+ private void dump(byte[] buffer, int count, SharpStream stream) throws IOException {
+ stream.write(buffer, 0, count);
+ written += count;
+ }
+
+ private void dump(ArrayList buffers, SharpStream stream) throws IOException {
+ for (byte[] buffer : buffers) {
+ stream.write(buffer);
+ written += buffer.length;
+ }
}
private ArrayList lengthFor(ArrayList buffer) {
@@ -614,11 +646,11 @@ public class WebMWriter implements Closeable {
byte[] buffer = new byte[offset + length];
long marker = (long) Math.floor((length - 1f) / 8f);
- float mul = 1;
- for (int i = length - 1; i >= 0; i--, mul *= 0x100) {
- long b = (long) Math.floor(number / mul);
+ int shift = 0;
+ for (int i = length - 1; i >= 0; i--, shift += 8) {
+ long b = number >>> shift;
if (!withLength && i == marker) {
- b = b | (0x80 >> (length - 1));
+ b = b | (0x80 >>> (length - 1));
}
buffer[offset + i] = (byte) b;
}
@@ -686,17 +718,15 @@ public class WebMWriter implements Closeable {
class KeyFrame {
- KeyFrame(long segment, long cluster, long block, int bTimecodeLength, long timecode) {
- atCluster = cluster - segment;
- if ((block - bTimecodeLength) > cluster) {
- atBlock = (int) (block - cluster);
- }
- atTimecode = timecode;
+ KeyFrame(long segment, long cluster, long block, long timecode) {
+ clusterPosition = cluster - segment;
+ relativePosition = (int) (block - cluster - CLUSTER_HEADER_SIZE);
+ duration = timecode;
}
- long atCluster;
- int atBlock;
- long atTimecode;
+ final long clusterPosition;
+ final int relativePosition;
+ final long duration;
}
class Block {
@@ -717,4 +747,11 @@ public class WebMWriter implements Closeable {
return String.format("trackNumber=%s isKeyFrame=%S absoluteTimecode=%s", trackNumber, isKeyframe(), absoluteTimecode);
}
}
+
+ class ClusterInfo {
+
+ long offset;
+ int size;
+ }
+
}
diff --git a/app/src/main/java/org/schabi/newpipe/util/ConstantsKt.kt b/app/src/main/java/org/schabi/newpipe/util/ConstantsKt.kt
new file mode 100644
index 000000000..8d24cb04e
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/util/ConstantsKt.kt
@@ -0,0 +1,6 @@
+package org.schabi.newpipe.util
+
+/**
+ * Default duration when using throttle functions across the app, in milliseconds.
+ */
+const val DEFAULT_THROTTLE_TIMEOUT = 120L
diff --git a/app/src/main/java/org/schabi/newpipe/util/ExtractorHelper.java b/app/src/main/java/org/schabi/newpipe/util/ExtractorHelper.java
index 0cebe5af3..cf4477223 100644
--- a/app/src/main/java/org/schabi/newpipe/util/ExtractorHelper.java
+++ b/app/src/main/java/org/schabi/newpipe/util/ExtractorHelper.java
@@ -31,18 +31,23 @@ import org.schabi.newpipe.ReCaptchaActivity;
import org.schabi.newpipe.extractor.Info;
import org.schabi.newpipe.extractor.InfoItem;
import org.schabi.newpipe.extractor.ListExtractor.InfoItemsPage;
+import org.schabi.newpipe.extractor.ListInfo;
import org.schabi.newpipe.extractor.NewPipe;
-import org.schabi.newpipe.extractor.suggestion.SuggestionExtractor;
+import org.schabi.newpipe.extractor.StreamingService;
import org.schabi.newpipe.extractor.channel.ChannelInfo;
import org.schabi.newpipe.extractor.comments.CommentsInfo;
import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException;
import org.schabi.newpipe.extractor.exceptions.ParsingException;
import org.schabi.newpipe.extractor.exceptions.ReCaptchaException;
+import org.schabi.newpipe.extractor.feed.FeedExtractor;
+import org.schabi.newpipe.extractor.feed.FeedInfo;
import org.schabi.newpipe.extractor.kiosk.KioskInfo;
import org.schabi.newpipe.extractor.playlist.PlaylistInfo;
import org.schabi.newpipe.extractor.search.SearchInfo;
import org.schabi.newpipe.extractor.services.youtube.extractors.YoutubeStreamExtractor;
import org.schabi.newpipe.extractor.stream.StreamInfo;
+import org.schabi.newpipe.extractor.stream.StreamInfoItem;
+import org.schabi.newpipe.extractor.suggestion.SuggestionExtractor;
import org.schabi.newpipe.report.ErrorActivity;
import org.schabi.newpipe.report.UserAction;
@@ -131,6 +136,22 @@ public final class ExtractorHelper {
ChannelInfo.getMoreItems(NewPipe.getService(serviceId), url, nextStreamsUrl));
}
+ public static Single> getFeedInfoFallbackToChannelInfo(final int serviceId,
+ final String url) {
+ final Maybe> maybeFeedInfo = Maybe.fromCallable(() -> {
+ final StreamingService service = NewPipe.getService(serviceId);
+ final FeedExtractor feedExtractor = service.getFeedExtractor(url);
+
+ if (feedExtractor == null) {
+ return null;
+ }
+
+ return FeedInfo.getInfo(feedExtractor);
+ });
+
+ return maybeFeedInfo.switchIfEmpty(getChannelInfo(serviceId, url, true));
+ }
+
public static Single getCommentsInfo(final int serviceId,
final String url,
boolean forceLoad) {
diff --git a/app/src/main/java/org/schabi/newpipe/util/Localization.java b/app/src/main/java/org/schabi/newpipe/util/Localization.java
index 47b914bde..9c8fc25b8 100644
--- a/app/src/main/java/org/schabi/newpipe/util/Localization.java
+++ b/app/src/main/java/org/schabi/newpipe/util/Localization.java
@@ -213,6 +213,42 @@ public class Localization {
return output;
}
+ /**
+ * Localize an amount of seconds into a human readable string.
+ *
+ * The seconds will be converted to the closest whole time unit.
+ *
For example, 60 seconds would give "1 minute", 119 would also give "1 minute".
+ *
+ * @param context used to get plurals resources.
+ * @param durationInSecs an amount of seconds.
+ * @return duration in a human readable string.
+ */
+ @NonNull
+ public static String localizeDuration(Context context, int durationInSecs) {
+ if (durationInSecs < 0) {
+ throw new IllegalArgumentException("duration can not be negative");
+ }
+
+ final int days = (int) (durationInSecs / (24 * 60 * 60L)); /* greater than a day */
+ durationInSecs %= (24 * 60 * 60L);
+ final int hours = (int) (durationInSecs / (60 * 60L)); /* greater than an hour */
+ durationInSecs %= (60 * 60L);
+ final int minutes = (int) (durationInSecs / 60L);
+ final int seconds = (int) (durationInSecs % 60L);
+
+ final Resources resources = context.getResources();
+
+ if (days > 0) {
+ return resources.getQuantityString(R.plurals.days, days, days);
+ } else if (hours > 0) {
+ return resources.getQuantityString(R.plurals.hours, hours, hours);
+ } else if (minutes > 0) {
+ return resources.getQuantityString(R.plurals.minutes, minutes, minutes);
+ } else {
+ return resources.getQuantityString(R.plurals.seconds, seconds, seconds);
+ }
+ }
+
/*//////////////////////////////////////////////////////////////////////////
// Pretty Time
//////////////////////////////////////////////////////////////////////////*/
diff --git a/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java b/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java
index a19aa92ae..b6f73dac7 100644
--- a/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java
+++ b/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java
@@ -23,6 +23,7 @@ import org.schabi.newpipe.MainActivity;
import org.schabi.newpipe.R;
import org.schabi.newpipe.RouterActivity;
import org.schabi.newpipe.about.AboutActivity;
+import org.schabi.newpipe.database.feed.model.FeedGroupEntity;
import org.schabi.newpipe.download.DownloadActivity;
import org.schabi.newpipe.extractor.NewPipe;
import org.schabi.newpipe.extractor.StreamingService;
@@ -110,13 +111,15 @@ public class NavigationHelper {
final boolean playbackSkipSilence,
@Nullable final String playbackQuality,
final boolean resumePlayback,
- final boolean startPaused) {
+ final boolean startPaused,
+ final boolean isMuted) {
return getPlayerIntent(context, targetClazz, playQueue, playbackQuality, resumePlayback)
.putExtra(BasePlayer.REPEAT_MODE, repeatMode)
.putExtra(BasePlayer.PLAYBACK_SPEED, playbackSpeed)
.putExtra(BasePlayer.PLAYBACK_PITCH, playbackPitch)
.putExtra(BasePlayer.PLAYBACK_SKIP_SILENCE, playbackSkipSilence)
- .putExtra(BasePlayer.START_PAUSED, startPaused);
+ .putExtra(BasePlayer.START_PAUSED, startPaused)
+ .putExtra(BasePlayer.IS_MUTED, isMuted);
}
public static void playOnMainPlayer(final Context context, final PlayQueue queue, final boolean resumePlayback) {
@@ -341,9 +344,13 @@ public class NavigationHelper {
.commit();
}
- public static void openWhatsNewFragment(FragmentManager fragmentManager) {
+ public static void openFeedFragment(FragmentManager fragmentManager) {
+ openFeedFragment(fragmentManager, FeedGroupEntity.GROUP_ALL_ID, null);
+ }
+
+ public static void openFeedFragment(FragmentManager fragmentManager, long groupId, @Nullable String groupName) {
defaultTransaction(fragmentManager)
- .replace(R.id.fragment_holder, new FeedFragment())
+ .replace(R.id.fragment_holder, FeedFragment.newInstance(groupId, groupName))
.addToBackStack(null)
.commit();
}
diff --git a/app/src/main/java/org/schabi/newpipe/util/ThemeHelper.java b/app/src/main/java/org/schabi/newpipe/util/ThemeHelper.java
index 661aa47c1..bd51919c7 100644
--- a/app/src/main/java/org/schabi/newpipe/util/ThemeHelper.java
+++ b/app/src/main/java/org/schabi/newpipe/util/ThemeHelper.java
@@ -99,6 +99,17 @@ public class ThemeHelper {
return isLightThemeSelected(context) ? R.style.LightDialogTheme : R.style.DarkDialogTheme;
}
+ /**
+ * Return a min-width dialog theme styled according to the (default) selected theme.
+ *
+ * @param context context to get the selected theme
+ * @return the dialog style (the default one)
+ */
+ @StyleRes
+ public static int getMinWidthDialogTheme(Context context) {
+ return isLightThemeSelected(context) ? R.style.LightDialogMinWidthTheme : R.style.DarkDialogMinWidthTheme;
+ }
+
/**
* Return the selected theme styled according to the serviceId.
*
diff --git a/app/src/main/java/org/schabi/newpipe/util/urlfinder/PatternsCompat.java b/app/src/main/java/org/schabi/newpipe/util/urlfinder/PatternsCompat.java
new file mode 100644
index 000000000..bbad56c37
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/util/urlfinder/PatternsCompat.java
@@ -0,0 +1,360 @@
+/* THIS FILE WAS MODIFIED, CHANGES ARE DOCUMENTED. */
+
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.schabi.newpipe.util.urlfinder;
+
+import androidx.annotation.RestrictTo;
+
+import java.util.regex.Pattern;
+
+import static androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX;
+
+/**
+ * Commonly used regular expression patterns.
+ */
+public final class PatternsCompat {
+ /**
+ * Regular expression to match all IANA top-level domains.
+ *
+ * List accurate as of 2015/11/24. List taken from:
+ * http://data.iana.org/TLD/tlds-alpha-by-domain.txt
+ * This pattern is auto-generated by frameworks/ex/common/tools/make-iana-tld-pattern.py
+ */
+ static final String IANA_TOP_LEVEL_DOMAINS =
+ "(?:"
+ + "(?:aaa|aarp|abb|abbott|abogado|academy|accenture|accountant|accountants|aco|active"
+ + "|actor|ads|adult|aeg|aero|afl|agency|aig|airforce|airtel|allfinanz|alsace|amica|amsterdam"
+ + "|android|apartments|app|apple|aquarelle|aramco|archi|army|arpa|arte|asia|associates"
+ + "|attorney|auction|audio|auto|autos|axa|azure|a[cdefgilmoqrstuwxz])"
+ + "|(?:band|bank|bar|barcelona|barclaycard|barclays|bargains|bauhaus|bayern|bbc|bbva"
+ + "|bcn|beats|beer|bentley|berlin|best|bet|bharti|bible|bid|bike|bing|bingo|bio|biz|black"
+ + "|blackfriday|bloomberg|blue|bms|bmw|bnl|bnpparibas|boats|bom|bond|boo|boots|boutique"
+ + "|bradesco|bridgestone|broadway|broker|brother|brussels|budapest|build|builders|business"
+ + "|buzz|bzh|b[abdefghijmnorstvwyz])"
+ + "|(?:cab|cafe|cal|camera|camp|cancerresearch|canon|capetown|capital|car|caravan|cards"
+ + "|care|career|careers|cars|cartier|casa|cash|casino|cat|catering|cba|cbn|ceb|center|ceo"
+ + "|cern|cfa|cfd|chanel|channel|chat|cheap|chloe|christmas|chrome|church|cipriani|cisco"
+ + "|citic|city|cityeats|claims|cleaning|click|clinic|clothing|cloud|club|clubmed|coach"
+ + "|codes|coffee|college|cologne|com|commbank|community|company|computer|comsec|condos"
+ + "|construction|consulting|contractors|cooking|cool|coop|corsica|country|coupons|courses"
+ + "|credit|creditcard|creditunion|cricket|crown|crs|cruises|csc|cuisinella|cymru|cyou|c[acdfghiklmnoruvwxyz])"
+ + "|(?:dabur|dad|dance|date|dating|datsun|day|dclk|deals|degree|delivery|dell|delta"
+ + "|democrat|dental|dentist|desi|design|dev|diamonds|diet|digital|direct|directory|discount"
+ + "|dnp|docs|dog|doha|domains|doosan|download|drive|durban|dvag|d[ejkmoz])"
+ + "|(?:earth|eat|edu|education|email|emerck|energy|engineer|engineering|enterprises"
+ + "|epson|equipment|erni|esq|estate|eurovision|eus|events|everbank|exchange|expert|exposed"
+ + "|express|e[cegrstu])"
+ + "|(?:fage|fail|fairwinds|faith|family|fan|fans|farm|fashion|feedback|ferrero|film"
+ + "|final|finance|financial|firmdale|fish|fishing|fit|fitness|flights|florist|flowers|flsmidth"
+ + "|fly|foo|football|forex|forsale|forum|foundation|frl|frogans|fund|furniture|futbol|fyi"
+ + "|f[ijkmor])"
+ + "|(?:gal|gallery|game|garden|gbiz|gdn|gea|gent|genting|ggee|gift|gifts|gives|giving"
+ + "|glass|gle|global|globo|gmail|gmo|gmx|gold|goldpoint|golf|goo|goog|google|gop|gov|grainger"
+ + "|graphics|gratis|green|gripe|group|gucci|guge|guide|guitars|guru|g[abdefghilmnpqrstuwy])"
+ + "|(?:hamburg|hangout|haus|healthcare|help|here|hermes|hiphop|hitachi|hiv|hockey|holdings"
+ + "|holiday|homedepot|homes|honda|horse|host|hosting|hoteles|hotmail|house|how|hsbc|hyundai"
+ + "|h[kmnrtu])"
+ + "|(?:ibm|icbc|ice|icu|ifm|iinet|immo|immobilien|industries|infiniti|info|ing|ink|institute"
+ + "|insure|int|international|investments|ipiranga|irish|ist|istanbul|itau|iwc|i[delmnoqrst])"
+ + "|(?:jaguar|java|jcb|jetzt|jewelry|jlc|jll|jobs|joburg|jprs|juegos|j[emop])"
+ + "|(?:kaufen|kddi|kia|kim|kinder|kitchen|kiwi|koeln|komatsu|krd|kred|kyoto|k[eghimnprwyz])"
+ + "|(?:lacaixa|lancaster|land|landrover|lasalle|lat|latrobe|law|lawyer|lds|lease|leclerc"
+ + "|legal|lexus|lgbt|liaison|lidl|life|lifestyle|lighting|limited|limo|linde|link|live"
+ + "|lixil|loan|loans|lol|london|lotte|lotto|love|ltd|ltda|lupin|luxe|luxury|l[abcikrstuvy])"
+ + "|(?:madrid|maif|maison|man|management|mango|market|marketing|markets|marriott|mba"
+ + "|media|meet|melbourne|meme|memorial|men|menu|meo|miami|microsoft|mil|mini|mma|mobi|moda"
+ + "|moe|moi|mom|monash|money|montblanc|mormon|mortgage|moscow|motorcycles|mov|movie|movistar"
+ + "|mtn|mtpc|mtr|museum|mutuelle|m[acdeghklmnopqrstuvwxyz])"
+ + "|(?:nadex|nagoya|name|navy|nec|net|netbank|network|neustar|new|news|nexus|ngo|nhk"
+ + "|nico|ninja|nissan|nokia|nra|nrw|ntt|nyc|n[acefgilopruz])"
+ + "|(?:obi|office|okinawa|omega|one|ong|onl|online|ooo|oracle|orange|org|organic|osaka"
+ + "|otsuka|ovh|om)"
+ + "|(?:page|panerai|paris|partners|parts|party|pet|pharmacy|philips|photo|photography"
+ + "|photos|physio|piaget|pics|pictet|pictures|ping|pink|pizza|place|play|playstation|plumbing"
+ + "|plus|pohl|poker|porn|post|praxi|press|pro|prod|productions|prof|properties|property"
+ + "|protection|pub|p[aefghklmnrstwy])"
+ + "|(?:qpon|quebec|qa)"
+ + "|(?:racing|realtor|realty|recipes|red|redstone|rehab|reise|reisen|reit|ren|rent|rentals"
+ + "|repair|report|republican|rest|restaurant|review|reviews|rich|ricoh|rio|rip|rocher|rocks"
+ + "|rodeo|rsvp|ruhr|run|rwe|ryukyu|r[eosuw])"
+ + "|(?:saarland|sakura|sale|samsung|sandvik|sandvikcoromant|sanofi|sap|sapo|sarl|saxo"
+ + "|sbs|sca|scb|schmidt|scholarships|school|schule|schwarz|science|scor|scot|seat|security"
+ + "|seek|sener|services|seven|sew|sex|sexy|shiksha|shoes|show|shriram|singles|site|ski"
+ + "|sky|skype|sncf|soccer|social|software|sohu|solar|solutions|sony|soy|space|spiegel|spreadbetting"
+ + "|srl|stada|starhub|statoil|stc|stcgroup|stockholm|studio|study|style|sucks|supplies"
+ + "|supply|support|surf|surgery|suzuki|swatch|swiss|sydney|systems|s[abcdeghijklmnortuvxyz])"
+ + "|(?:tab|taipei|tatamotors|tatar|tattoo|tax|taxi|team|tech|technology|tel|telefonica"
+ + "|temasek|tennis|thd|theater|theatre|tickets|tienda|tips|tires|tirol|today|tokyo|tools"
+ + "|top|toray|toshiba|tours|town|toyota|toys|trade|trading|training|travel|trust|tui|t[cdfghjklmnortvwz])"
+ + "|(?:ubs|university|uno|uol|u[agksyz])"
+ + "|(?:vacations|vana|vegas|ventures|versicherung|vet|viajes|video|villas|vin|virgin"
+ + "|vision|vista|vistaprint|viva|vlaanderen|vodka|vote|voting|voto|voyage|v[aceginu])"
+ + "|(?:wales|walter|wang|watch|webcam|website|wed|wedding|weir|whoswho|wien|wiki|williamhill"
+ + "|win|windows|wine|wme|work|works|world|wtc|wtf|w[fs])"
+ + "|(?:\u03b5\u03bb|\u0431\u0435\u043b|\u0434\u0435\u0442\u0438|\u043a\u043e\u043c|\u043c\u043a\u0434"
+ + "|\u043c\u043e\u043d|\u043c\u043e\u0441\u043a\u0432\u0430|\u043e\u043d\u043b\u0430\u0439\u043d"
+ + "|\u043e\u0440\u0433|\u0440\u0443\u0441|\u0440\u0444|\u0441\u0430\u0439\u0442|\u0441\u0440\u0431"
+ + "|\u0443\u043a\u0440|\u049b\u0430\u0437|\u0570\u0561\u0575|\u05e7\u05d5\u05dd|\u0627\u0631\u0627\u0645\u0643\u0648"
+ + "|\u0627\u0644\u0627\u0631\u062f\u0646|\u0627\u0644\u062c\u0632\u0627\u0626\u0631|\u0627\u0644\u0633\u0639\u0648\u062f\u064a\u0629"
+ + "|\u0627\u0644\u0645\u063a\u0631\u0628|\u0627\u0645\u0627\u0631\u0627\u062a|\u0627\u06cc\u0631\u0627\u0646"
+ + "|\u0628\u0627\u0632\u0627\u0631|\u0628\u06be\u0627\u0631\u062a|\u062a\u0648\u0646\u0633"
+ + "|\u0633\u0648\u062f\u0627\u0646|\u0633\u0648\u0631\u064a\u0629|\u0634\u0628\u0643\u0629"
+ + "|\u0639\u0631\u0627\u0642|\u0639\u0645\u0627\u0646|\u0641\u0644\u0633\u0637\u064a\u0646"
+ + "|\u0642\u0637\u0631|\u0643\u0648\u0645|\u0645\u0635\u0631|\u0645\u0644\u064a\u0633\u064a\u0627"
+ + "|\u0645\u0648\u0642\u0639|\u0915\u0949\u092e|\u0928\u0947\u091f|\u092d\u093e\u0930\u0924"
+ + "|\u0938\u0902\u0917\u0920\u0928|\u09ad\u09be\u09b0\u09a4|\u0a2d\u0a3e\u0a30\u0a24|\u0aad\u0abe\u0ab0\u0aa4"
+ + "|\u0b87\u0ba8\u0bcd\u0ba4\u0bbf\u0baf\u0bbe|\u0b87\u0bb2\u0b99\u0bcd\u0b95\u0bc8|\u0b9a\u0bbf\u0b99\u0bcd\u0b95\u0baa\u0bcd\u0baa\u0bc2\u0bb0\u0bcd"
+ + "|\u0c2d\u0c3e\u0c30\u0c24\u0c4d|\u0dbd\u0d82\u0d9a\u0dcf|\u0e04\u0e2d\u0e21|\u0e44\u0e17\u0e22"
+ + "|\u10d2\u10d4|\u307f\u3093\u306a|\u30b0\u30fc\u30b0\u30eb|\u30b3\u30e0|\u4e16\u754c"
+ + "|\u4e2d\u4fe1|\u4e2d\u56fd|\u4e2d\u570b|\u4e2d\u6587\u7f51|\u4f01\u4e1a|\u4f5b\u5c71"
+ + "|\u4fe1\u606f|\u5065\u5eb7|\u516b\u5366|\u516c\u53f8|\u516c\u76ca|\u53f0\u6e7e|\u53f0\u7063"
+ + "|\u5546\u57ce|\u5546\u5e97|\u5546\u6807|\u5728\u7ebf|\u5927\u62ff|\u5a31\u4e50|\u5de5\u884c"
+ + "|\u5e7f\u4e1c|\u6148\u5584|\u6211\u7231\u4f60|\u624b\u673a|\u653f\u52a1|\u653f\u5e9c"
+ + "|\u65b0\u52a0\u5761|\u65b0\u95fb|\u65f6\u5c1a|\u673a\u6784|\u6de1\u9a6c\u9521|\u6e38\u620f"
+ + "|\u70b9\u770b|\u79fb\u52a8|\u7ec4\u7ec7\u673a\u6784|\u7f51\u5740|\u7f51\u5e97|\u7f51\u7edc"
+ + "|\u8c37\u6b4c|\u96c6\u56e2|\u98de\u5229\u6d66|\u9910\u5385|\u9999\u6e2f|\ub2f7\ub137"
+ + "|\ub2f7\ucef4|\uc0bc\uc131|\ud55c\uad6d|xbox"
+ + "|xerox|xin|xn\\-\\-11b4c3d|xn\\-\\-1qqw23a|xn\\-\\-30rr7y|xn\\-\\-3bst00m|xn\\-\\-3ds443g"
+ + "|xn\\-\\-3e0b707e|xn\\-\\-3pxu8k|xn\\-\\-42c2d9a|xn\\-\\-45brj9c|xn\\-\\-45q11c|xn\\-\\-4gbrim"
+ + "|xn\\-\\-55qw42g|xn\\-\\-55qx5d|xn\\-\\-6frz82g|xn\\-\\-6qq986b3xl|xn\\-\\-80adxhks"
+ + "|xn\\-\\-80ao21a|xn\\-\\-80asehdb|xn\\-\\-80aswg|xn\\-\\-90a3ac|xn\\-\\-90ais|xn\\-\\-9dbq2a"
+ + "|xn\\-\\-9et52u|xn\\-\\-b4w605ferd|xn\\-\\-c1avg|xn\\-\\-c2br7g|xn\\-\\-cg4bki|xn\\-\\-clchc0ea0b2g2a9gcd"
+ + "|xn\\-\\-czr694b|xn\\-\\-czrs0t|xn\\-\\-czru2d|xn\\-\\-d1acj3b|xn\\-\\-d1alf|xn\\-\\-efvy88h"
+ + "|xn\\-\\-estv75g|xn\\-\\-fhbei|xn\\-\\-fiq228c5hs|xn\\-\\-fiq64b|xn\\-\\-fiqs8s|xn\\-\\-fiqz9s"
+ + "|xn\\-\\-fjq720a|xn\\-\\-flw351e|xn\\-\\-fpcrj9c3d|xn\\-\\-fzc2c9e2c|xn\\-\\-gecrj9c"
+ + "|xn\\-\\-h2brj9c|xn\\-\\-hxt814e|xn\\-\\-i1b6b1a6a2e|xn\\-\\-imr513n|xn\\-\\-io0a7i"
+ + "|xn\\-\\-j1aef|xn\\-\\-j1amh|xn\\-\\-j6w193g|xn\\-\\-kcrx77d1x4a|xn\\-\\-kprw13d|xn\\-\\-kpry57d"
+ + "|xn\\-\\-kput3i|xn\\-\\-l1acc|xn\\-\\-lgbbat1ad8j|xn\\-\\-mgb9awbf|xn\\-\\-mgba3a3ejt"
+ + "|xn\\-\\-mgba3a4f16a|xn\\-\\-mgbaam7a8h|xn\\-\\-mgbab2bd|xn\\-\\-mgbayh7gpa|xn\\-\\-mgbbh1a71e"
+ + "|xn\\-\\-mgbc0a9azcg|xn\\-\\-mgberp4a5d4ar|xn\\-\\-mgbpl2fh|xn\\-\\-mgbtx2b|xn\\-\\-mgbx4cd0ab"
+ + "|xn\\-\\-mk1bu44c|xn\\-\\-mxtq1m|xn\\-\\-ngbc5azd|xn\\-\\-node|xn\\-\\-nqv7f|xn\\-\\-nqv7fs00ema"
+ + "|xn\\-\\-nyqy26a|xn\\-\\-o3cw4h|xn\\-\\-ogbpf8fl|xn\\-\\-p1acf|xn\\-\\-p1ai|xn\\-\\-pgbs0dh"
+ + "|xn\\-\\-pssy2u|xn\\-\\-q9jyb4c|xn\\-\\-qcka1pmc|xn\\-\\-qxam|xn\\-\\-rhqv96g|xn\\-\\-s9brj9c"
+ + "|xn\\-\\-ses554g|xn\\-\\-t60b56a|xn\\-\\-tckwe|xn\\-\\-unup4y|xn\\-\\-vermgensberater\\-ctb"
+ + "|xn\\-\\-vermgensberatung\\-pwb|xn\\-\\-vhquv|xn\\-\\-vuq861b|xn\\-\\-wgbh1c|xn\\-\\-wgbl6a"
+ + "|xn\\-\\-xhq521b|xn\\-\\-xkc2al3hye2a|xn\\-\\-xkc2dl3a5ee0h|xn\\-\\-y9a3aq|xn\\-\\-yfro4i67o"
+ + "|xn\\-\\-ygbi2ammx|xn\\-\\-zfr164b|xperia|xxx|xyz)"
+ + "|(?:yachts|yamaxun|yandex|yodobashi|yoga|yokohama|youtube|y[et])"
+ + "|(?:zara|zip|zone|zuerich|z[amw]))";
+
+ public static final Pattern IP_ADDRESS
+ = Pattern.compile(
+ "((25[0-5]|2[0-4][0-9]|[0-1][0-9]{2}|[1-9][0-9]|[1-9])\\.(25[0-5]|2[0-4]"
+ + "[0-9]|[0-1][0-9]{2}|[1-9][0-9]|[1-9]|0)\\.(25[0-5]|2[0-4][0-9]|[0-1]"
+ + "[0-9]{2}|[1-9][0-9]|[1-9]|0)\\.(25[0-5]|2[0-4][0-9]|[0-1][0-9]{2}"
+ + "|[1-9][0-9]|[0-9]))");
+
+ /**
+ * Valid UCS characters defined in RFC 3987. Excludes space characters.
+ */
+ private static final String UCS_CHAR = "[" +
+ "\u00A0-\uD7FF" +
+ "\uF900-\uFDCF" +
+ "\uFDF0-\uFFEF" +
+ "\uD800\uDC00-\uD83F\uDFFD" +
+ "\uD840\uDC00-\uD87F\uDFFD" +
+ "\uD880\uDC00-\uD8BF\uDFFD" +
+ "\uD8C0\uDC00-\uD8FF\uDFFD" +
+ "\uD900\uDC00-\uD93F\uDFFD" +
+ "\uD940\uDC00-\uD97F\uDFFD" +
+ "\uD980\uDC00-\uD9BF\uDFFD" +
+ "\uD9C0\uDC00-\uD9FF\uDFFD" +
+ "\uDA00\uDC00-\uDA3F\uDFFD" +
+ "\uDA40\uDC00-\uDA7F\uDFFD" +
+ "\uDA80\uDC00-\uDABF\uDFFD" +
+ "\uDAC0\uDC00-\uDAFF\uDFFD" +
+ "\uDB00\uDC00-\uDB3F\uDFFD" +
+ "\uDB44\uDC00-\uDB7F\uDFFD" +
+ "&&[^\u00A0[\u2000-\u200A]\u2028\u2029\u202F\u3000]]";
+
+ /**
+ * Valid characters for IRI label defined in RFC 3987.
+ */
+ private static final String LABEL_CHAR = "a-zA-Z0-9" + UCS_CHAR;
+
+ /**
+ * Valid characters for IRI TLD defined in RFC 3987.
+ */
+ private static final String TLD_CHAR = "a-zA-Z" + UCS_CHAR;
+
+ /**
+ * RFC 1035 Section 2.3.4 limits the labels to a maximum 63 octets.
+ */
+ private static final String IRI_LABEL =
+ "[" + LABEL_CHAR + "](?:[" + LABEL_CHAR + "_\\-]{0,61}[" + LABEL_CHAR + "]){0,1}";
+
+ /**
+ * RFC 3492 references RFC 1034 and limits Punycode algorithm output to 63 characters.
+ */
+ private static final String PUNYCODE_TLD = "xn\\-\\-[\\w\\-]{0,58}\\w";
+
+ private static final String TLD = "(" + PUNYCODE_TLD + "|" + "[" + TLD_CHAR + "]{2,63}" +")";
+
+ private static final String HOST_NAME = "(" + IRI_LABEL + "\\.)+" + TLD;
+
+ public static final Pattern DOMAIN_NAME
+ = Pattern.compile("(" + HOST_NAME + "|" + IP_ADDRESS + ")");
+
+ //!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
+ // CHANGED: Removed rtsp from supported protocols //
+ //!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
+ private static final String PROTOCOL = "(?i:http|https)://";
+
+ /* A word boundary or end of input. This is to stop foo.sure from matching as foo.su */
+ private static final String WORD_BOUNDARY = "(?:\\b|$|^)";
+
+ private static final String USER_INFO = "(?:[a-zA-Z0-9\\$\\-\\_\\.\\+\\!\\*\\'\\(\\)"
+ + "\\,\\;\\?\\&\\=]|(?:\\%[a-fA-F0-9]{2})){1,64}(?:\\:(?:[a-zA-Z0-9\\$\\-\\_"
+ + "\\.\\+\\!\\*\\'\\(\\)\\,\\;\\?\\&\\=]|(?:\\%[a-fA-F0-9]{2})){1,25})?\\@";
+
+ private static final String PORT_NUMBER = "\\:\\d{1,5}";
+
+ private static final String PATH_AND_QUERY = "[/\\?](?:(?:[" + LABEL_CHAR
+ + ";/\\?:@&=#~" // plus optional query params
+ + "\\-\\.\\+!\\*'\\(\\),_\\$])|(?:%[a-fA-F0-9]{2}))*";
+
+ /**
+ * Regular expression pattern to match most part of RFC 3987
+ * Internationalized URLs, aka IRIs.
+ */
+ public static final Pattern WEB_URL = Pattern.compile("("
+ + "("
+ + "(?:" + PROTOCOL + "(?:" + USER_INFO + ")?" + ")?"
+ + "(?:" + DOMAIN_NAME + ")"
+ + "(?:" + PORT_NUMBER + ")?"
+ + ")"
+ + "(" + PATH_AND_QUERY + ")?"
+ + WORD_BOUNDARY
+ + ")");
+
+ /**
+ * Regular expression that matches known TLDs and punycode TLDs
+ */
+ private static final String STRICT_TLD = "(?:" +
+ IANA_TOP_LEVEL_DOMAINS + "|" + PUNYCODE_TLD + ")";
+
+ /**
+ * Regular expression that matches host names using {@link #STRICT_TLD}
+ */
+ private static final String STRICT_HOST_NAME = "(?:(?:" + IRI_LABEL + "\\.)+"
+ + STRICT_TLD + ")";
+
+ /**
+ * Regular expression that matches domain names using either {@link #STRICT_HOST_NAME} or
+ * {@link #IP_ADDRESS}
+ */
+ private static final Pattern STRICT_DOMAIN_NAME
+ = Pattern.compile("(?:" + STRICT_HOST_NAME + "|" + IP_ADDRESS + ")");
+
+ /**
+ * Regular expression that matches domain names without a TLD
+ */
+ private static final String RELAXED_DOMAIN_NAME =
+ "(?:" + "(?:" + IRI_LABEL + "(?:\\.(?=\\S))" +"?)+" + "|" + IP_ADDRESS + ")";
+
+ /**
+ * Regular expression to match strings that do not start with a supported protocol. The TLDs
+ * are expected to be one of the known TLDs.
+ */
+ private static final String WEB_URL_WITHOUT_PROTOCOL = "("
+ + WORD_BOUNDARY
+ + "(? implements Handler.Callb
if (BuildConfig.DEBUG)
Log.v(TAG, "Mime: " + mimeType + " package: " + BuildConfig.APPLICATION_ID + ".provider");
- Uri uri;
-
- if (mission.storage.isDirect()) {
- uri = FileProvider.getUriForFile(
- mContext,
- BuildConfig.APPLICATION_ID + ".provider",
- new File(URI.create(mission.storage.getUri().toString()))
- );
- } else {
- uri = mission.storage.getUri();
- }
+ Uri uri = resolveShareableUri(mission);
Intent intent = new Intent();
intent.setAction(Intent.ACTION_VIEW);
@@ -379,11 +369,30 @@ public class MissionAdapter extends Adapter implements Handler.Callb
Intent intent = new Intent(Intent.ACTION_SEND);
intent.setType(resolveMimeType(mission));
- intent.putExtra(Intent.EXTRA_STREAM, mission.storage.getUri());
+ intent.putExtra(Intent.EXTRA_STREAM, resolveShareableUri(mission));
+ intent.addFlags(FLAG_GRANT_READ_URI_PERMISSION);
mContext.startActivity(Intent.createChooser(intent, null));
}
+ /**
+ * Returns an Uri which can be shared to other applications.
+ *
+ * @see
+ * https://stackoverflow.com/questions/38200282/android-os-fileuriexposedexception-file-storage-emulated-0-test-txt-exposed
+ */
+ private Uri resolveShareableUri(Mission mission) {
+ if (mission.storage.isDirect()) {
+ return FileProvider.getUriForFile(
+ mContext,
+ BuildConfig.APPLICATION_ID + ".provider",
+ new File(URI.create(mission.storage.getUri().toString()))
+ );
+ } else {
+ return mission.storage.getUri();
+ }
+ }
+
private static String resolveMimeType(@NonNull Mission mission) {
String mimeType;
diff --git a/app/src/main/res/drawable/dark_focused_selector.xml b/app/src/main/res/drawable/dark_focused_selector.xml
new file mode 100644
index 000000000..102f40d76
--- /dev/null
+++ b/app/src/main/res/drawable/dark_focused_selector.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/drawable/dashed_border_black.xml b/app/src/main/res/drawable/dashed_border_black.xml
new file mode 100644
index 000000000..b6bac6252
--- /dev/null
+++ b/app/src/main/res/drawable/dashed_border_black.xml
@@ -0,0 +1,8 @@
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/drawable/dashed_border_dark.xml b/app/src/main/res/drawable/dashed_border_dark.xml
new file mode 100644
index 000000000..5af152ecc
--- /dev/null
+++ b/app/src/main/res/drawable/dashed_border_dark.xml
@@ -0,0 +1,8 @@
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/drawable/dashed_border_light.xml b/app/src/main/res/drawable/dashed_border_light.xml
new file mode 100644
index 000000000..5d29112bd
--- /dev/null
+++ b/app/src/main/res/drawable/dashed_border_light.xml
@@ -0,0 +1,8 @@
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/drawable/ic_asterisk_black_24dp.xml b/app/src/main/res/drawable/ic_asterisk_black_24dp.xml
new file mode 100644
index 000000000..fa16cd5e8
--- /dev/null
+++ b/app/src/main/res/drawable/ic_asterisk_black_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/drawable/ic_asterisk_white_24dp.xml b/app/src/main/res/drawable/ic_asterisk_white_24dp.xml
new file mode 100644
index 000000000..bd487cb55
--- /dev/null
+++ b/app/src/main/res/drawable/ic_asterisk_white_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/drawable/ic_car_black_24dp.xml b/app/src/main/res/drawable/ic_car_black_24dp.xml
new file mode 100644
index 000000000..6aa8cdd82
--- /dev/null
+++ b/app/src/main/res/drawable/ic_car_black_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_car_white_24dp.xml b/app/src/main/res/drawable/ic_car_white_24dp.xml
new file mode 100644
index 000000000..7ad263933
--- /dev/null
+++ b/app/src/main/res/drawable/ic_car_white_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_computer_black_24dp.xml b/app/src/main/res/drawable/ic_computer_black_24dp.xml
new file mode 100644
index 000000000..b03d9c0ce
--- /dev/null
+++ b/app/src/main/res/drawable/ic_computer_black_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_computer_white_24dp.xml b/app/src/main/res/drawable/ic_computer_white_24dp.xml
new file mode 100644
index 000000000..c4bdad688
--- /dev/null
+++ b/app/src/main/res/drawable/ic_computer_white_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_edit_black_24dp.xml b/app/src/main/res/drawable/ic_edit_black_24dp.xml
new file mode 100644
index 000000000..43489826e
--- /dev/null
+++ b/app/src/main/res/drawable/ic_edit_black_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_edit_white_24dp.xml b/app/src/main/res/drawable/ic_edit_white_24dp.xml
new file mode 100644
index 000000000..88f94780f
--- /dev/null
+++ b/app/src/main/res/drawable/ic_edit_white_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_emoticon_black_24dp.xml b/app/src/main/res/drawable/ic_emoticon_black_24dp.xml
new file mode 100644
index 000000000..45f489d80
--- /dev/null
+++ b/app/src/main/res/drawable/ic_emoticon_black_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_emoticon_white_24dp.xml b/app/src/main/res/drawable/ic_emoticon_white_24dp.xml
new file mode 100644
index 000000000..89ca90fb5
--- /dev/null
+++ b/app/src/main/res/drawable/ic_emoticon_white_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_explore_black_24dp.xml b/app/src/main/res/drawable/ic_explore_black_24dp.xml
new file mode 100644
index 000000000..c898ed9a5
--- /dev/null
+++ b/app/src/main/res/drawable/ic_explore_black_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_explore_white_24dp.xml b/app/src/main/res/drawable/ic_explore_white_24dp.xml
new file mode 100644
index 000000000..65f2818a6
--- /dev/null
+++ b/app/src/main/res/drawable/ic_explore_white_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_fastfood_black_24dp.xml b/app/src/main/res/drawable/ic_fastfood_black_24dp.xml
new file mode 100644
index 000000000..fac047550
--- /dev/null
+++ b/app/src/main/res/drawable/ic_fastfood_black_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_fastfood_white_24dp.xml b/app/src/main/res/drawable/ic_fastfood_white_24dp.xml
new file mode 100644
index 000000000..39bbee49a
--- /dev/null
+++ b/app/src/main/res/drawable/ic_fastfood_white_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_fitness_black_24dp.xml b/app/src/main/res/drawable/ic_fitness_black_24dp.xml
new file mode 100644
index 000000000..40a1cf9c1
--- /dev/null
+++ b/app/src/main/res/drawable/ic_fitness_black_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_fitness_white_24dp.xml b/app/src/main/res/drawable/ic_fitness_white_24dp.xml
new file mode 100644
index 000000000..1b2d3b4be
--- /dev/null
+++ b/app/src/main/res/drawable/ic_fitness_white_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_heart_black_24dp.xml b/app/src/main/res/drawable/ic_heart_black_24dp.xml
new file mode 100644
index 000000000..25cb46e83
--- /dev/null
+++ b/app/src/main/res/drawable/ic_heart_black_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_heart_white_24dp.xml b/app/src/main/res/drawable/ic_heart_white_24dp.xml
new file mode 100644
index 000000000..02c6396ee
--- /dev/null
+++ b/app/src/main/res/drawable/ic_heart_white_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_help_black_24dp.xml b/app/src/main/res/drawable/ic_help_black_24dp.xml
new file mode 100644
index 000000000..1517747d0
--- /dev/null
+++ b/app/src/main/res/drawable/ic_help_black_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_help_white_24dp.xml b/app/src/main/res/drawable/ic_help_white_24dp.xml
new file mode 100644
index 000000000..d813b72b8
--- /dev/null
+++ b/app/src/main/res/drawable/ic_help_white_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_kids_black_24dp.xml b/app/src/main/res/drawable/ic_kids_black_24dp.xml
new file mode 100644
index 000000000..d1d8e01e7
--- /dev/null
+++ b/app/src/main/res/drawable/ic_kids_black_24dp.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_kids_white_24dp.xml b/app/src/main/res/drawable/ic_kids_white_24dp.xml
new file mode 100644
index 000000000..c5dda16c8
--- /dev/null
+++ b/app/src/main/res/drawable/ic_kids_white_24dp.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_megaphone_black_24dp.xml b/app/src/main/res/drawable/ic_megaphone_black_24dp.xml
new file mode 100644
index 000000000..21622c162
--- /dev/null
+++ b/app/src/main/res/drawable/ic_megaphone_black_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_megaphone_white_24dp.xml b/app/src/main/res/drawable/ic_megaphone_white_24dp.xml
new file mode 100644
index 000000000..90e6ff215
--- /dev/null
+++ b/app/src/main/res/drawable/ic_megaphone_white_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_mic_black_24dp.xml b/app/src/main/res/drawable/ic_mic_black_24dp.xml
new file mode 100644
index 000000000..25d8951a7
--- /dev/null
+++ b/app/src/main/res/drawable/ic_mic_black_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_mic_white_24dp.xml b/app/src/main/res/drawable/ic_mic_white_24dp.xml
new file mode 100644
index 000000000..36ee9ff81
--- /dev/null
+++ b/app/src/main/res/drawable/ic_mic_white_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_money_black_24dp.xml b/app/src/main/res/drawable/ic_money_black_24dp.xml
new file mode 100644
index 000000000..4019c2e46
--- /dev/null
+++ b/app/src/main/res/drawable/ic_money_black_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_money_white_24dp.xml b/app/src/main/res/drawable/ic_money_white_24dp.xml
new file mode 100644
index 000000000..2407a2b73
--- /dev/null
+++ b/app/src/main/res/drawable/ic_money_white_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_motorcycle_black_24dp.xml b/app/src/main/res/drawable/ic_motorcycle_black_24dp.xml
new file mode 100644
index 000000000..6009979dd
--- /dev/null
+++ b/app/src/main/res/drawable/ic_motorcycle_black_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_motorcycle_white_24dp.xml b/app/src/main/res/drawable/ic_motorcycle_white_24dp.xml
new file mode 100644
index 000000000..b94c29f8f
--- /dev/null
+++ b/app/src/main/res/drawable/ic_motorcycle_white_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_movie_black_24dp.xml b/app/src/main/res/drawable/ic_movie_black_24dp.xml
new file mode 100644
index 000000000..d70c00f00
--- /dev/null
+++ b/app/src/main/res/drawable/ic_movie_black_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_movie_white_24dp.xml b/app/src/main/res/drawable/ic_movie_white_24dp.xml
new file mode 100644
index 000000000..f73e76774
--- /dev/null
+++ b/app/src/main/res/drawable/ic_movie_white_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_music_note_black_24dp.xml b/app/src/main/res/drawable/ic_music_note_black_24dp.xml
new file mode 100644
index 000000000..698159295
--- /dev/null
+++ b/app/src/main/res/drawable/ic_music_note_black_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_music_note_white_24dp.xml b/app/src/main/res/drawable/ic_music_note_white_24dp.xml
new file mode 100644
index 000000000..1d38e6e22
--- /dev/null
+++ b/app/src/main/res/drawable/ic_music_note_white_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_people_black_24dp.xml b/app/src/main/res/drawable/ic_people_black_24dp.xml
new file mode 100644
index 000000000..d0fe31838
--- /dev/null
+++ b/app/src/main/res/drawable/ic_people_black_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_people_white_24dp.xml b/app/src/main/res/drawable/ic_people_white_24dp.xml
new file mode 100644
index 000000000..e6fa4c583
--- /dev/null
+++ b/app/src/main/res/drawable/ic_people_white_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_person_black_24dp.xml b/app/src/main/res/drawable/ic_person_black_24dp.xml
new file mode 100644
index 000000000..f0ff6a871
--- /dev/null
+++ b/app/src/main/res/drawable/ic_person_black_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_person_white_24dp.xml b/app/src/main/res/drawable/ic_person_white_24dp.xml
new file mode 100644
index 000000000..99f299963
--- /dev/null
+++ b/app/src/main/res/drawable/ic_person_white_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_pets_black_24dp.xml b/app/src/main/res/drawable/ic_pets_black_24dp.xml
new file mode 100644
index 000000000..b6247bd87
--- /dev/null
+++ b/app/src/main/res/drawable/ic_pets_black_24dp.xml
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_pets_white_24dp.xml b/app/src/main/res/drawable/ic_pets_white_24dp.xml
new file mode 100644
index 000000000..46724a33d
--- /dev/null
+++ b/app/src/main/res/drawable/ic_pets_white_24dp.xml
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_radio_black_24dp.xml b/app/src/main/res/drawable/ic_radio_black_24dp.xml
new file mode 100644
index 000000000..00da9101f
--- /dev/null
+++ b/app/src/main/res/drawable/ic_radio_black_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_radio_white_24dp.xml b/app/src/main/res/drawable/ic_radio_white_24dp.xml
new file mode 100644
index 000000000..df563ec1d
--- /dev/null
+++ b/app/src/main/res/drawable/ic_radio_white_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_refresh_black_24dp.xml b/app/src/main/res/drawable/ic_refresh_black_24dp.xml
new file mode 100644
index 000000000..8229a9a64
--- /dev/null
+++ b/app/src/main/res/drawable/ic_refresh_black_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_refresh_white_24dp.xml b/app/src/main/res/drawable/ic_refresh_white_24dp.xml
new file mode 100644
index 000000000..a8175c316
--- /dev/null
+++ b/app/src/main/res/drawable/ic_refresh_white_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_restaurant_black_24dp.xml b/app/src/main/res/drawable/ic_restaurant_black_24dp.xml
new file mode 100644
index 000000000..0a8c6bde9
--- /dev/null
+++ b/app/src/main/res/drawable/ic_restaurant_black_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_restaurant_white_24dp.xml b/app/src/main/res/drawable/ic_restaurant_white_24dp.xml
new file mode 100644
index 000000000..c81618bb7
--- /dev/null
+++ b/app/src/main/res/drawable/ic_restaurant_white_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_school_black_24dp.xml b/app/src/main/res/drawable/ic_school_black_24dp.xml
new file mode 100644
index 000000000..8f52f0dde
--- /dev/null
+++ b/app/src/main/res/drawable/ic_school_black_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_school_white_24dp.xml b/app/src/main/res/drawable/ic_school_white_24dp.xml
new file mode 100644
index 000000000..e3888411a
--- /dev/null
+++ b/app/src/main/res/drawable/ic_school_white_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_shopping_cart_black_24dp.xml b/app/src/main/res/drawable/ic_shopping_cart_black_24dp.xml
new file mode 100644
index 000000000..452332095
--- /dev/null
+++ b/app/src/main/res/drawable/ic_shopping_cart_black_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/drawable/ic_shopping_cart_white_24dp.xml b/app/src/main/res/drawable/ic_shopping_cart_white_24dp.xml
new file mode 100644
index 000000000..a55bf8a88
--- /dev/null
+++ b/app/src/main/res/drawable/ic_shopping_cart_white_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_sort_black_24dp.xml b/app/src/main/res/drawable/ic_sort_black_24dp.xml
new file mode 100644
index 000000000..fd4c56f0e
--- /dev/null
+++ b/app/src/main/res/drawable/ic_sort_black_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_sort_white_24dp.xml b/app/src/main/res/drawable/ic_sort_white_24dp.xml
new file mode 100644
index 000000000..a0c153ad0
--- /dev/null
+++ b/app/src/main/res/drawable/ic_sort_white_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_sports_black_24dp.xml b/app/src/main/res/drawable/ic_sports_black_24dp.xml
new file mode 100644
index 000000000..5a54580c1
--- /dev/null
+++ b/app/src/main/res/drawable/ic_sports_black_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_sports_white_24dp.xml b/app/src/main/res/drawable/ic_sports_white_24dp.xml
new file mode 100644
index 000000000..611852728
--- /dev/null
+++ b/app/src/main/res/drawable/ic_sports_white_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_stars_black_24dp.xml b/app/src/main/res/drawable/ic_stars_black_24dp.xml
new file mode 100644
index 000000000..66a89110e
--- /dev/null
+++ b/app/src/main/res/drawable/ic_stars_black_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_stars_white_24dp.xml b/app/src/main/res/drawable/ic_stars_white_24dp.xml
new file mode 100644
index 000000000..2de1fd808
--- /dev/null
+++ b/app/src/main/res/drawable/ic_stars_white_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_sunny_black_24dp.xml b/app/src/main/res/drawable/ic_sunny_black_24dp.xml
new file mode 100644
index 000000000..fee59df13
--- /dev/null
+++ b/app/src/main/res/drawable/ic_sunny_black_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_sunny_white_24dp.xml b/app/src/main/res/drawable/ic_sunny_white_24dp.xml
new file mode 100644
index 000000000..c6cb469ef
--- /dev/null
+++ b/app/src/main/res/drawable/ic_sunny_white_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_telescope_black_24dp.xml b/app/src/main/res/drawable/ic_telescope_black_24dp.xml
new file mode 100644
index 000000000..9c6132ecc
--- /dev/null
+++ b/app/src/main/res/drawable/ic_telescope_black_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_telescope_white_24dp.xml b/app/src/main/res/drawable/ic_telescope_white_24dp.xml
new file mode 100644
index 000000000..ea870fd87
--- /dev/null
+++ b/app/src/main/res/drawable/ic_telescope_white_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_trending_up_black_24dp.xml b/app/src/main/res/drawable/ic_trending_up_black_24dp.xml
new file mode 100644
index 000000000..706af95a4
--- /dev/null
+++ b/app/src/main/res/drawable/ic_trending_up_black_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_trending_up_white_24dp.xml b/app/src/main/res/drawable/ic_trending_up_white_24dp.xml
new file mode 100644
index 000000000..403674223
--- /dev/null
+++ b/app/src/main/res/drawable/ic_trending_up_white_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_videogame_black_24dp.xml b/app/src/main/res/drawable/ic_videogame_black_24dp.xml
new file mode 100644
index 000000000..df872c96c
--- /dev/null
+++ b/app/src/main/res/drawable/ic_videogame_black_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_videogame_white_24dp.xml b/app/src/main/res/drawable/ic_videogame_white_24dp.xml
new file mode 100644
index 000000000..593e49e14
--- /dev/null
+++ b/app/src/main/res/drawable/ic_videogame_white_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_volume_off_gray_24dp.xml b/app/src/main/res/drawable/ic_volume_off_gray_24dp.xml
new file mode 100644
index 000000000..ade6bfec2
--- /dev/null
+++ b/app/src/main/res/drawable/ic_volume_off_gray_24dp.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_volume_up_black_24dp.xml b/app/src/main/res/drawable/ic_volume_up_black_24dp.xml
new file mode 100644
index 000000000..bb0c74ba1
--- /dev/null
+++ b/app/src/main/res/drawable/ic_volume_up_black_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_volume_up_white_24dp.xml b/app/src/main/res/drawable/ic_volume_up_white_24dp.xml
new file mode 100644
index 000000000..271540946
--- /dev/null
+++ b/app/src/main/res/drawable/ic_volume_up_white_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_watch_later_black_24dp.xml b/app/src/main/res/drawable/ic_watch_later_black_24dp.xml
new file mode 100644
index 000000000..5a1b9ac74
--- /dev/null
+++ b/app/src/main/res/drawable/ic_watch_later_black_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_watch_later_white_24dp.xml b/app/src/main/res/drawable/ic_watch_later_white_24dp.xml
new file mode 100644
index 000000000..f9fffbc43
--- /dev/null
+++ b/app/src/main/res/drawable/ic_watch_later_white_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_work_black_24dp.xml b/app/src/main/res/drawable/ic_work_black_24dp.xml
new file mode 100644
index 000000000..2668f2c43
--- /dev/null
+++ b/app/src/main/res/drawable/ic_work_black_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_work_white_24dp.xml b/app/src/main/res/drawable/ic_work_white_24dp.xml
new file mode 100644
index 000000000..8a1db7828
--- /dev/null
+++ b/app/src/main/res/drawable/ic_work_white_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_world_black_24dp.xml b/app/src/main/res/drawable/ic_world_black_24dp.xml
new file mode 100644
index 000000000..48785e7d7
--- /dev/null
+++ b/app/src/main/res/drawable/ic_world_black_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_world_white_24dp.xml b/app/src/main/res/drawable/ic_world_white_24dp.xml
new file mode 100644
index 000000000..01583e467
--- /dev/null
+++ b/app/src/main/res/drawable/ic_world_white_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/light_focused_selector.xml b/app/src/main/res/drawable/light_focused_selector.xml
new file mode 100644
index 000000000..102f40d76
--- /dev/null
+++ b/app/src/main/res/drawable/light_focused_selector.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout-large-land/activity_main_player.xml b/app/src/main/res/layout-large-land/activity_main_player.xml
index 8e11b99f3..1499eec36 100644
--- a/app/src/main/res/layout-large-land/activity_main_player.xml
+++ b/app/src/main/res/layout-large-land/activity_main_player.xml
@@ -291,7 +291,7 @@
android:layout_height="wrap_content"
android:layout_marginLeft="8dp"
android:layout_marginRight="8dp"
- android:layout_toLeftOf="@id/switchBackground"
+ android:layout_toLeftOf="@id/switchMute"
android:layout_toRightOf="@id/resizeTextView"
android:gravity="center|left"
android:minHeight="35dp"
@@ -389,6 +389,23 @@
android:background="?attr/selectableItemBackground"
android:contentDescription="@string/switch_to_background"
tools:ignore="RtlHardcoded"/>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/dialog_feed_group_reorder.xml b/app/src/main/res/layout/dialog_feed_group_reorder.xml
new file mode 100644
index 000000000..82a9b1591
--- /dev/null
+++ b/app/src/main/res/layout/dialog_feed_group_reorder.xml
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/feed_group_add_new_item.xml b/app/src/main/res/layout/feed_group_add_new_item.xml
new file mode 100644
index 000000000..3424762e2
--- /dev/null
+++ b/app/src/main/res/layout/feed_group_add_new_item.xml
@@ -0,0 +1,46 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/feed_group_card_item.xml b/app/src/main/res/layout/feed_group_card_item.xml
new file mode 100644
index 000000000..b6bf8656b
--- /dev/null
+++ b/app/src/main/res/layout/feed_group_card_item.xml
@@ -0,0 +1,48 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/feed_group_reorder_item.xml b/app/src/main/res/layout/feed_group_reorder_item.xml
new file mode 100644
index 000000000..d3bbf8005
--- /dev/null
+++ b/app/src/main/res/layout/feed_group_reorder_item.xml
@@ -0,0 +1,62 @@
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/subscription_header.xml b/app/src/main/res/layout/feed_import_export_group.xml
similarity index 53%
rename from app/src/main/res/layout/subscription_header.xml
rename to app/src/main/res/layout/feed_import_export_group.xml
index 821e1b2f4..2049db65e 100644
--- a/app/src/main/res/layout/subscription_header.xml
+++ b/app/src/main/res/layout/feed_import_export_group.xml
@@ -1,100 +1,79 @@
+ android:layout_marginTop="16dp"
+ android:orientation="vertical">
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
+ android:focusable="true"
+ android:gravity="center_vertical"
+ android:orientation="horizontal">
+ tools:text="Header"/>
+
-
-
-
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/feed_item_carousel.xml b/app/src/main/res/layout/feed_item_carousel.xml
new file mode 100644
index 000000000..db3d9cb11
--- /dev/null
+++ b/app/src/main/res/layout/feed_item_carousel.xml
@@ -0,0 +1,7 @@
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/fragment_feed.xml b/app/src/main/res/layout/fragment_feed.xml
index 71217eea3..7d166a3f5 100644
--- a/app/src/main/res/layout/fragment_feed.xml
+++ b/app/src/main/res/layout/fragment_feed.xml
@@ -1,18 +1,116 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ android:visibility="gone"
+ tools:listitem="@layout/list_stream_item"
+ tools:visibility="visible" />
+
+
+
+
+
+
+
+ tools:visibility="visible" />
+ tools:visibility="visible" />
-
+ android:layout_alignParentTop="true"
+ android:background="?attr/toolbar_shadow_drawable" />
\ No newline at end of file
diff --git a/app/src/main/res/layout/fragment_subscription.xml b/app/src/main/res/layout/fragment_subscription.xml
index 979993a56..d1281f462 100644
--- a/app/src/main/res/layout/fragment_subscription.xml
+++ b/app/src/main/res/layout/fragment_subscription.xml
@@ -1,7 +1,6 @@
@@ -9,11 +8,10 @@
+ tools:listitem="@layout/list_channel_item"/>
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/header_item.xml b/app/src/main/res/layout/header_item.xml
new file mode 100644
index 000000000..4d4e1b884
--- /dev/null
+++ b/app/src/main/res/layout/header_item.xml
@@ -0,0 +1,16 @@
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/header_with_menu_item.xml b/app/src/main/res/layout/header_with_menu_item.xml
new file mode 100644
index 000000000..580e8db4d
--- /dev/null
+++ b/app/src/main/res/layout/header_with_menu_item.xml
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/header_with_text_item.xml b/app/src/main/res/layout/header_with_text_item.xml
new file mode 100644
index 000000000..871893ad6
--- /dev/null
+++ b/app/src/main/res/layout/header_with_text_item.xml
@@ -0,0 +1,36 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/list_channel_grid_item.xml b/app/src/main/res/layout/list_channel_grid_item.xml
index 3fe642974..423bfeb9e 100644
--- a/app/src/main/res/layout/list_channel_grid_item.xml
+++ b/app/src/main/res/layout/list_channel_grid_item.xml
@@ -1,48 +1,48 @@
-
+
-
+
-
+
-
+
diff --git a/app/src/main/res/layout/list_empty_view.xml b/app/src/main/res/layout/list_empty_view.xml
index e1833f243..094353324 100644
--- a/app/src/main/res/layout/list_empty_view.xml
+++ b/app/src/main/res/layout/list_empty_view.xml
@@ -3,7 +3,8 @@
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
- android:layout_height="match_parent"
+ android:layout_height="wrap_content"
+ android:minHeight="128dp"
android:gravity="center"
android:orientation="vertical">
diff --git a/app/src/main/res/layout/list_stream_item.xml b/app/src/main/res/layout/list_stream_item.xml
index 02e8f1531..d2000381d 100644
--- a/app/src/main/res/layout/list_stream_item.xml
+++ b/app/src/main/res/layout/list_stream_item.xml
@@ -75,6 +75,7 @@
android:layout_alignParentBottom="true"
android:layout_toRightOf="@+id/itemThumbnailView"
android:layout_toEndOf="@+id/itemThumbnailView"
+ android:ellipsize="end"
android:lines="1"
android:textAppearance="?android:attr/textAppearanceSmall"
android:textSize="@dimen/video_item_search_upload_date_text_size"
diff --git a/app/src/main/res/layout/list_stream_playlist_item.xml b/app/src/main/res/layout/list_stream_playlist_item.xml
index 2747038f6..00b431cc6 100644
--- a/app/src/main/res/layout/list_stream_playlist_item.xml
+++ b/app/src/main/res/layout/list_stream_playlist_item.xml
@@ -78,6 +78,7 @@
android:layout_toStartOf="@id/itemHandle"
android:layout_toRightOf="@+id/itemThumbnailView"
android:layout_toEndOf="@+id/itemThumbnailView"
+ android:ellipsize="end"
android:lines="1"
android:textAppearance="?android:attr/textAppearanceSmall"
android:textSize="@dimen/video_item_search_uploader_text_size"
diff --git a/app/src/main/res/layout/picker_icon_item.xml b/app/src/main/res/layout/picker_icon_item.xml
new file mode 100644
index 000000000..f156772b6
--- /dev/null
+++ b/app/src/main/res/layout/picker_icon_item.xml
@@ -0,0 +1,15 @@
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/picker_subscription_item.xml b/app/src/main/res/layout/picker_subscription_item.xml
new file mode 100644
index 000000000..474f068df
--- /dev/null
+++ b/app/src/main/res/layout/picker_subscription_item.xml
@@ -0,0 +1,59 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/toolbar_search_layout.xml b/app/src/main/res/layout/toolbar_search_layout.xml
index fdc7e6d6b..b369cc282 100644
--- a/app/src/main/res/layout/toolbar_search_layout.xml
+++ b/app/src/main/res/layout/toolbar_search_layout.xml
@@ -15,8 +15,6 @@
android:layout_marginRight="48dp"
android:layout_marginTop="4dp"
android:background="?attr/colorPrimary"
- android:drawableLeft="?attr/search"
- android:drawablePadding="8dp"
android:focusable="true"
android:focusableInTouchMode="true"
android:hint="@string/search"
diff --git a/app/src/main/res/menu/menu_feed_fragment.xml b/app/src/main/res/menu/menu_feed_fragment.xml
new file mode 100644
index 000000000..3519875eb
--- /dev/null
+++ b/app/src/main/res/menu/menu_feed_fragment.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/menu/menu_play_queue.xml b/app/src/main/res/menu/menu_play_queue.xml
index be6cea46c..fc3fd07a8 100644
--- a/app/src/main/res/menu/menu_play_queue.xml
+++ b/app/src/main/res/menu/menu_play_queue.xml
@@ -10,6 +10,13 @@
android:visible="true"
app:showAsAction="ifRoom"/>
+
+
- مشترك
الرئيسية
الاشتراكات
- ما الجديد
+ ما الجديد
في الخلفية
تشغيل تلقائي
اسود
@@ -94,7 +94,7 @@
تذكر حجم النافذة و وضعها
تذكر آخر مكان و حجم للنافذة المنبثقة
اعدادات إيماءة المشغل
- استخدم إيماءات التحكم في سطوع وصوت المشغل
+ استخدم الإيماءات للتحكم في سطوع وصوت المشغل
اقتراحات البحث
عرض الاقتراحات عند البحث
سجل البحث
@@ -111,7 +111,7 @@
تم وضعه على قائمة الانتظار في مشغل الخلفية
تم وضعه على قائمة الانتظار في مشغل النافذة المنبثقة
محتوى مقيد بحسب العمر
- "إظهار الفيديو المقيد بحسب العمر. يمكن السماح باستخدام هذه المواد من \"الإعدادات\"."
+ إظهار الفيديو المقيد بحسب العمر. التغييرات المستقبلية ممكنة من \"الإعدادات\".
بث مباشر
تقرير خطأ
قائمة التشغيل
@@ -205,7 +205,7 @@
إذا كانت لديك أفكار؛ أو ترجمة، أو تغييرات تخص التصميم، أو تنظيف و تحسين الشفرة البرمجية ، أو تعديلات عميقة عليها، فتذكر أنّ مساعدتك دائما موضع ترحيب. وكلما أتممنا شيئا كلما كان ذلك أفضل !
عرض على GitHub
تبرع
- يتم تطوير NewPipe من قبل متطوعين يقضون وقت فراغهم لتقديم أفضل تجربة لك. حان الوقت لرد المساعدة مع المطورين وجعل NewPipe أكثر و أفضل بينما تستمتع بفنجان من القهوة.
+ يتم تطوير NewPipe من قبل متطوعين يقضون وقت فراغهم لتقديم أفضل تجربة لك. حان الوقت لرد المساعدة مع المطورين وجعل NewPipe أكثر و أفضل بينما يستمتعون بفنجان من القهوة.
تبرع
موقع الويب
قم بزيارة موقع NewPipe لمزيد من المعلومات والمستجدات.
@@ -329,7 +329,6 @@
عملية التصدير جارية …
إستيراد ملف
"معرفك , soundcloud.com/ الخاص بك "
- إفتراضي
عند إيقاف تحميل أي صور مصغرة ، وتوفير البيانات واستخدام الذاكرة. تعمل التغييرات على محو ذاكرة التخزين المؤقت للصورة الموجودة على الذاكرة أو على القرص.
امسح البيانات الوصفية المخزنة مؤقتًا
إزالة جميع بيانات صفحات الويب المخزنة مؤقتًا
@@ -417,7 +416,7 @@
إلغاء الاشتراك
علامة تبويب جديدة
اختر علامة التبويب
- استخدم إيماءات التحكم في سطوع وصوت المشغل
+ استخدم إيماءات التحكم في صوت المشغل
التحكم بالإيماءات السطوع
استخدام الإيماءات للتحكم في سطوع المشغل
التحديثات
@@ -463,14 +462,13 @@
لا يمكن إنشاء الملف
لا يمكن إنشاء المجلد الوجهة
تم رفضها من قبل النظام
- فشل اتصال الأمن
+ فشل الاتصال الآمن
تعذر العثور على الخادم
لا يمكن الاتصال بالخادم
الخادم لايقوم بإرسال البيانات
الخادم لا يقبل التنزيل المتعدد، إعادة المحاولة مع @string/msg_threads = 1
غير موجود
فشلت المعالجة الاولية
- حذف التنزيلات المنتهية
توقف
أقصى عدد للمحاولات
الحد الأقصى لعدد محاولات قبل إلغاء التحميل
@@ -556,8 +554,8 @@
لا يمكن استرداد هذا التنزيل
اختيار مثيل
ابحث عن مثيلات الخوادم التي تناسبك على %s
- تمكين قفل شاشة الصور المصغرة الفيديو
- عند استخدام مشغل الخلفية ، سيتم عرض صورة مصغرة للفيديو على شاشة القفل
+ تمكين صورة العرض للفيديو في شاشة القفل
+ عند استخدام مشغل الخلفية، سيتم عرض صورة العرض للفيديو على شاشة القفل
تنظيف تاريخ التحميل
حذف الملفات التي تم تنزيلها
التنزيلات %1$s المحذوفة
@@ -567,12 +565,12 @@
اضغط على \"تم\" عند حلها
منجز
الفيديوهات
-
- - %s ثوانٍ
-
-
-
-
-
+
+ - %d ثوانٍ
+ - %d ثوانٍ
+ - %d ثوانٍ
+ - %d ثوانٍ
+ - %d ثوانٍ
+ - %d ثوانٍ
\ No newline at end of file
diff --git a/app/src/main/res/values-az/strings.xml b/app/src/main/res/values-az/strings.xml
index d39e46d5a..bf3b0f3f2 100644
--- a/app/src/main/res/values-az/strings.xml
+++ b/app/src/main/res/values-az/strings.xml
@@ -27,7 +27,7 @@
Abunəliklər
Əlfəcinlər
- Yeni nə var
+ Yeni nə var
Arxa fon
Video yükləmə ünvanı
diff --git a/app/src/main/res/values-b+ast/strings.xml b/app/src/main/res/values-b+ast/strings.xml
index 129c5d2eb..ff6f204ba 100644
--- a/app/src/main/res/values-b+ast/strings.xml
+++ b/app/src/main/res/values-b+ast/strings.xml
@@ -74,9 +74,9 @@
Soscribise
Nun pudo anovase la soscripción
Soscripciones
- Novedaes
+ Novedaes
Historial de gueta
-
+
Sigue cola reproducción dempués de les interrupciones (llamaes telefóniques, por exemplu)
Reproductor
Comportamientu
@@ -189,7 +189,6 @@
Nun pue crease la carpeta de destín
Nun pudo afitase una conexón segura
Nun pue coneutase col sirvidor
- Llimpiar les descargues finaes
Reintentos máximos
Eventos
Conferencies
@@ -211,28 +210,27 @@
Nun hai comentarios
Llimpieza de datos
Amosar comentarios
-
- Pa cumplir cola GDPR (Regulación Xeneral de Proteición de Datos) europea, pidímoste que revises la política de privacidá de NewPipe. Lléila con procuru.
- ¿Desaniciar tol historial de gueta\?
-\nHas aceutala unviándonos un informe de fallos.
+
+ Pa cumplir cola GDPR (Regulación Xeneral de Proteición de Datos) europea, pidímoste que revises la política de privacidá de NewPipe. Lléila con procuru.
+\nHas aceutala unviándonos un informe de fallos.
+
Aición al cambiar a otra aplicación dende\'l reproductor de vídeos principal — %s
El númberu máximu d\'intentos enantes d\'encaboxar la descarga
- Posiciones nes llistes
¿Quies llimpiar l\'historial de descargues o desaniciar tolos ficheros baxaos\?
Esportación anterior
Importar el ficheru
- Importa les soscripciones de YouTube baxando\'l ficheru d\'esportación:
-\n
-\n1.- Vete a esta URL: %1$s
-\n2.- Anicia sesión cuando se te pida
-\n3.- Debería aniciar una descarga (que ye\'l ficheru d\'esportación)
+ Importa les soscripciones de YouTube baxando\'l ficheru d\'esportación:
+\n
+\n1.- Vete a esta URL: %1$s
+\n2.- Anicia sesión cuando se te pida
+\n3.- Debería aniciase una descarga (que ye\'l ficheru d\'esportación)
Importa un perfil de SoundCloud teclexando la URL o la ID de to:
\n1.- Activa\'l mou escritoriu nun restolador web (el sitiu nun ta disponible pa móviles)
\n
\n3.- Anicia sesión cuando se te pida
\n2.- Vete a esta URL: %1$s
- LaToID, soundcloud.com/latoid
\n4.- Copia la URL del perfil al que se te redirixa.
+ LaToID, soundcloud.com/latoid
Cargar miniatures
Desactiva esta opción pa evitar la carga de miniatures, aforrar datos y usu de la memoria. Los cambeos van llimpiar la memoria y la caché d\'imáxenes.
Minimizar al cambiar a otra aplicación
@@ -276,8 +274,35 @@
Llingua de l\'aplicación
La predeterminada del sistema
Vídeos
-
- - %s segundos
-
+
+ - %d segundu
+ - %d segundos
+ Tempu
+ Tonu
+ Refugar
+ La URL nun se sofita
+ Reproduciendo en segundu planu
+ Reproduciendo nel mou ventanu
+ Atroxa llocalmente les consultes de gueta
+ Los reproductores esternos nun so
+ Esportóse
+ Importóse
+ Alvertencia: Nun pudieron importase tolos ficheros.
+ Anovamientos
+ Finó la descarga
+ Nun pudo validase la instancia
+
+ - %d minutu
+ - %d minutos
+
+
+ - %d hora
+ - %d hores
+
+
+ - %d día
+ - %d díes
+
+ ¿Quies desaniciar esti grupu\?
\ No newline at end of file
diff --git a/app/src/main/res/values-b+zh+HANS+CN/strings.xml b/app/src/main/res/values-b+zh+HANS+CN/strings.xml
index e547220c4..1fb28f4de 100644
--- a/app/src/main/res/values-b+zh+HANS+CN/strings.xml
+++ b/app/src/main/res/values-b+zh+HANS+CN/strings.xml
@@ -38,8 +38,7 @@
稍后
网络错误
- - 视频
-
+ - %s 视频
禁用
后台播放
@@ -90,8 +89,7 @@
重试
存储访问权限已被拒绝
- - %s 次观看
-
+ - %s 次观看
千
百万
@@ -130,8 +128,7 @@
没有结果
没有订阅者
- - %s 位订阅者
-
+ - %s 位订阅者
没有视频
拖动以重新排序
@@ -236,7 +233,7 @@
无法更新订阅
主页
订阅
- 最新
+ 最新
恢复前台焦点
中断后继续播放(例如突然来电后)
搜索历史记录
@@ -352,7 +349,7 @@
报告『提前结束Android生命周期』错误
强制报告处理后的未送达的Activity或Fragment生命周期之外的Rx异常
使用快速不精确搜索
- 粗略定位播放:允许播放器以略低的精确度为代价换取更快的定位速度
+ 粗略定位播放:允许播放器以略低的精确度为代价换取更快的定位速度。此功能不适用于每隔5、15或25秒定位.
自动播放下一个
当播放完非循环列表中的最后一个视频时,自动加入一个相关视频到播放列表
没有此文件夹
@@ -457,7 +454,6 @@
服务器未发送数据
找不到 NOT FOUND
后期处理失败
- 清除已完成的下载
停止
最大重试次数
取消下载前的最多重试着次数
@@ -468,8 +464,7 @@
显示评论
禁用,以停止显示评论
- - %s 条评论
-
+ - %s 条评论
无法加载评论
关闭
@@ -497,8 +492,8 @@
系统将询问您将每次下载的保存位置。
\n(如果要下载到外部 SD 卡,请选择 SAF)
使用 SAF
- 存储访问框架(SAF)允许下载文件到外部SD卡。
-\n注:一些设备不兼容SAF
+ 存储访问框架(SAF)允许下载文件到外部SD卡。
+\n一些设备不兼容SAF
删除播放位置记录
删除所有播放位置记录
删除所有播放位置记录?
@@ -506,13 +501,11 @@
『时下流行』页-默认
没有人在观看
- - %s 人在观看
-
+ - %s 人在观看
没有人在听
- - %s 人在听
-
+ - %s 人在听
重新启动应用后,语言将更改。
PeerTube 服务器
@@ -542,8 +535,53 @@
完成后请按\"完成(Done)\"
完成
视频
-
- - %s秒
-
+
+ - %d秒
+ 由于ExoPlayer的限制,搜寻间隔设置为%d秒
+ 静音
+ 取消静音
+ 帮助
+
+ - %d分钟
+
+
+ - %d小时
+
+
+ - %d天
+
+ Feed组
+ 最早订阅更新:%s
+ 未加载: %d
+ 正在加载feed…
+ 正在处理feed…
+ 选择订阅
+ 未选中订阅
+
+ - 已选中%d
+
+ 组名为空
+ 名称
+ 您要删除该组吗?
+ 新建
+ Feed
+ Feed更新阈值
+ 上次更新后,订阅被视为过时的时间-%s
+ 始终更新
+ 可用时从专用feed获取
+ 在某些服务中可用,通常速度要快得多,但可能返回的条目数量有限,而且信息通常不完整(例如,没有持续时间,条目类型,没有实时状态)。
+ 启用快速模式
+ 禁用快速模式
+ 您是否认为Feed加载太慢?如果是这样,请尝试启用快速加载(您可以在设置中更改它,也可以按下面的按钮更改它)。
+\n
+\nNewPipe提供两种feed加载策略:
+\n•获取整个订阅频道,这很慢但是很是完整。
+\n•使用专用的服务端点,这样会比较快但通常不完整。
+\n
+\n两者之间的区别在于,后者通常缺少一些信息,例如条目的持续时间或类型(无法区分直播视频和普通视频),并且可能返回更少的条目。
+\n
+\nYouTube是一个通过其RSS feed提供这种快速方法的服务示例。
+\n
+\n因此,选择哪种方式取决于您更看重什么:是速度还是精确的信息。
\ No newline at end of file
diff --git a/app/src/main/res/values-be/strings.xml b/app/src/main/res/values-be/strings.xml
index b3a09cb8f..4b1531def 100644
--- a/app/src/main/res/values-be/strings.xml
+++ b/app/src/main/res/values-be/strings.xml
@@ -31,7 +31,7 @@
Галоўная
Падпіскі
Адзначаныя плэйлісты
- Што новага
+ Што новага
У фоне
У акне
Дадаць да
@@ -457,7 +457,6 @@
Сервер не падтрымлівае шматструменную загрузку, паспрабуйце з @string/msg_threads = 1
Не знойдзена
Пасляапрацоўка не ўдалася
- Ачысціць завершаныя
Спыніць
Максімум спробаў
Колькасць спробаў перад адменай загрузкі
diff --git a/app/src/main/res/values-bg/strings.xml b/app/src/main/res/values-bg/strings.xml
index 7c813d0a9..26d394254 100644
--- a/app/src/main/res/values-bg/strings.xml
+++ b/app/src/main/res/values-bg/strings.xml
@@ -26,7 +26,7 @@
Неуспешна промяна на абонамента
Неуспешно обновление на абонамента
Абонаменти
- Обновления
+ Обновления
Във фонов режим
В прозорец
Директория за изтегляне на видео
diff --git a/app/src/main/res/values-bn-rBD/strings.xml b/app/src/main/res/values-bn-rBD/strings.xml
index 60b87caa9..6ba96425a 100644
--- a/app/src/main/res/values-bn-rBD/strings.xml
+++ b/app/src/main/res/values-bn-rBD/strings.xml
@@ -144,7 +144,7 @@
কি:\\nঅনুরোধ:\\nকন্টেন্ট ভাষা:\\nসার্ভিস:\\nসময়(GMT এ):\\nপ্যাকেজ:\\nসংস্করণ:\\nওএস সংস্করণ:\\nআইপি পরিসর:
স্ট্রিম ফাইল ডাউনলোড করুন।
তথ্য দেখুন
- কি নতুন
+ কি নতুন
যুক্ত করুন
খোজ ইতিহাস
ইতিহাস
diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml
index 20deb3160..dc8c7f9e1 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
@@ -114,7 +114,6 @@
S\'està important…
S\'està exportant…
Importa un fitxer
- Per defecte
%1$s reproduccions
Publicat el %1$s
No s\'ha trobat cap reproductor de fluxos. Voleu instal·lar el VLC\?
@@ -447,7 +446,6 @@
No s\'ha pogut trobar el servidor
No s\'ha pogut connectar amb el servidor
Ha fallat el post-processament
- Neteja les baixades finalitzades
Intents màxims
Nombre màxim d\'intents abans de cancel·lar la baixada
Interromp en connexions limitades
diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml
index 84d83827f..154296626 100644
--- a/app/src/main/res/values-cs/strings.xml
+++ b/app/src/main/res/values-cs/strings.xml
@@ -133,7 +133,7 @@ otevření ve vyskakovacím okně
Nelze aktualizovat odběr
Hlavní
Odběry
- Co je nového
+ Co je nového
Na pozadí
V okně
Výchozí rozlišení vyskakovacího okna
@@ -321,7 +321,7 @@ otevření ve vyskakovacím okně
Nahlásit mimo-cyklické chyby
Vynutit hlášení nedoručitelných výjimek Rx mimo životnost fragmentu nebo aktivity po odstranění
Použít rychlé nepřesné hledání
- Nepřesné hledání umožní přehrávači posouvat se rychleji, ale se sníženou přesností
+ Nepřesné hledání umožní přehrávači posouvat se rychleji, ale se sníženou přesností. Posouvání po 5, 15 nebo 25 vteřinách s tímto nefunguje.
Načítat náhledy
Vypnout, aby se zabránilo načítání náhledů a tím se ušetřily data a používání paměti. Změna tohoto nastavení vyčistí mezipamět obrázků v paměti i na disku.
Mezipaměť obrázků vymazána
@@ -368,7 +368,6 @@ otevření ve vyskakovacím okně
Tempo
Výška tónu
Odpojit (může způsobit zkreslení)
- Výchozí nastavení
Ke stažení nejsou dostupné žádné streamy
Preferovaná \'otevřít\' akce
Výchozí chování při otevírání obsahu — %s
@@ -464,7 +463,6 @@ otevření ve vyskakovacím okně
Server neakceptuje vícevláknové stahování, opakujte akci s @string/msg_threads = 1
Nenalezeno
Post-processing selhal
- Vyčistit dokončená stahování
Zastavit
Maximální počet pokusů o opakování
Maximální počet pokusů před zrušením stahování
@@ -503,7 +501,7 @@ otevření ve vyskakovacím okně