Merge pull request #7050 from litetex/feed-refactor-new-items-handling

Rework feed new items handling
This commit is contained in:
Robin 2021-11-15 23:20:07 +01:00 committed by GitHub
commit d5199eac3e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 251 additions and 16 deletions

View File

@ -7,6 +7,7 @@ import androidx.room.Query
import androidx.room.Transaction import androidx.room.Transaction
import androidx.room.Update import androidx.room.Update
import io.reactivex.rxjava3.core.Flowable import io.reactivex.rxjava3.core.Flowable
import io.reactivex.rxjava3.core.Maybe
import org.schabi.newpipe.database.feed.model.FeedEntity import org.schabi.newpipe.database.feed.model.FeedEntity
import org.schabi.newpipe.database.feed.model.FeedLastUpdatedEntity import org.schabi.newpipe.database.feed.model.FeedLastUpdatedEntity
import org.schabi.newpipe.database.stream.StreamWithState import org.schabi.newpipe.database.stream.StreamWithState
@ -37,7 +38,7 @@ abstract class FeedDAO {
LIMIT 500 LIMIT 500
""" """
) )
abstract fun getAllStreams(): Flowable<List<StreamWithState>> abstract fun getAllStreams(): Maybe<List<StreamWithState>>
@Query( @Query(
""" """
@ -62,7 +63,7 @@ abstract class FeedDAO {
LIMIT 500 LIMIT 500
""" """
) )
abstract fun getAllStreamsForGroup(groupId: Long): Flowable<List<StreamWithState>> abstract fun getAllStreamsForGroup(groupId: Long): Maybe<List<StreamWithState>>
/** /**
* @see StreamStateEntity.isFinished() * @see StreamStateEntity.isFinished()
@ -97,7 +98,7 @@ abstract class FeedDAO {
LIMIT 500 LIMIT 500
""" """
) )
abstract fun getLiveOrNotPlayedStreams(): Flowable<List<StreamWithState>> abstract fun getLiveOrNotPlayedStreams(): Maybe<List<StreamWithState>>
/** /**
* @see StreamStateEntity.isFinished() * @see StreamStateEntity.isFinished()
@ -137,7 +138,7 @@ abstract class FeedDAO {
LIMIT 500 LIMIT 500
""" """
) )
abstract fun getLiveOrNotPlayedStreamsForGroup(groupId: Long): Flowable<List<StreamWithState>> abstract fun getLiveOrNotPlayedStreamsForGroup(groupId: Long): Maybe<List<StreamWithState>>
@Query( @Query(
""" """

View File

@ -299,18 +299,36 @@ private fun View.animateLightSlideAndAlpha(enterOrExit: Boolean, duration: Long,
} }
} }
fun View.slideUp(duration: Long, delay: Long, @FloatRange(from = 0.0, to = 1.0) translationPercent: Float) { fun View.slideUp(
duration: Long,
delay: Long,
@FloatRange(from = 0.0, to = 1.0) translationPercent: Float
) {
slideUp(duration, delay, translationPercent, null)
}
fun View.slideUp(
duration: Long,
delay: Long = 0L,
@FloatRange(from = 0.0, to = 1.0) translationPercent: Float = 1.0F,
execOnEnd: Runnable? = null
) {
val newTranslationY = (resources.displayMetrics.heightPixels * translationPercent).toInt() val newTranslationY = (resources.displayMetrics.heightPixels * translationPercent).toInt()
animate().setListener(null).cancel() animate().setListener(null).cancel()
alpha = 0f alpha = 0f
translationY = newTranslationY.toFloat() translationY = newTranslationY.toFloat()
visibility = View.VISIBLE isVisible = true
animate() animate()
.alpha(1f) .alpha(1f)
.translationY(0f) .translationY(0f)
.setStartDelay(delay) .setStartDelay(delay)
.setDuration(duration) .setDuration(duration)
.setInterpolator(FastOutSlowInInterpolator()) .setInterpolator(FastOutSlowInInterpolator())
.setListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator) {
execOnEnd?.run()
}
})
.start() .start()
} }

View File

@ -42,7 +42,7 @@ class FeedDatabaseManager(context: Context) {
fun getStreams( fun getStreams(
groupId: Long = FeedGroupEntity.GROUP_ALL_ID, groupId: Long = FeedGroupEntity.GROUP_ALL_ID,
getPlayedStreams: Boolean = true getPlayedStreams: Boolean = true
): Flowable<List<StreamWithState>> { ): Maybe<List<StreamWithState>> {
return when (groupId) { return when (groupId) {
FeedGroupEntity.GROUP_ALL_ID -> { FeedGroupEntity.GROUP_ALL_ID -> {
if (getPlayedStreams) feedTable.getAllStreams() if (getPlayedStreams) feedTable.getAllStreams()

View File

@ -21,8 +21,12 @@ package org.schabi.newpipe.local.feed
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.app.Activity import android.app.Activity
import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.SharedPreferences import android.content.SharedPreferences
import android.graphics.Typeface
import android.graphics.drawable.Drawable
import android.graphics.drawable.LayerDrawable
import android.os.Bundle import android.os.Bundle
import android.os.Parcelable import android.os.Parcelable
import android.view.LayoutInflater import android.view.LayoutInflater
@ -31,6 +35,8 @@ import android.view.MenuInflater
import android.view.MenuItem import android.view.MenuItem
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.Button
import androidx.annotation.AttrRes
import androidx.annotation.Nullable import androidx.annotation.Nullable
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.appcompat.content.res.AppCompatResources import androidx.appcompat.content.res.AppCompatResources
@ -40,8 +46,10 @@ import androidx.core.view.isVisible
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.xwray.groupie.GroupieAdapter import com.xwray.groupie.GroupieAdapter
import com.xwray.groupie.Item import com.xwray.groupie.Item
import com.xwray.groupie.OnAsyncUpdateListener
import com.xwray.groupie.OnItemClickListener import com.xwray.groupie.OnItemClickListener
import com.xwray.groupie.OnItemLongClickListener import com.xwray.groupie.OnItemLongClickListener
import icepick.State import icepick.State
@ -65,10 +73,12 @@ import org.schabi.newpipe.fragments.BaseStateFragment
import org.schabi.newpipe.info_list.InfoItemDialog import org.schabi.newpipe.info_list.InfoItemDialog
import org.schabi.newpipe.ktx.animate import org.schabi.newpipe.ktx.animate
import org.schabi.newpipe.ktx.animateHideRecyclerViewAllowingScrolling import org.schabi.newpipe.ktx.animateHideRecyclerViewAllowingScrolling
import org.schabi.newpipe.ktx.slideUp
import org.schabi.newpipe.local.feed.item.StreamItem import org.schabi.newpipe.local.feed.item.StreamItem
import org.schabi.newpipe.local.feed.service.FeedLoadService import org.schabi.newpipe.local.feed.service.FeedLoadService
import org.schabi.newpipe.local.subscription.SubscriptionManager import org.schabi.newpipe.local.subscription.SubscriptionManager
import org.schabi.newpipe.player.helper.PlayerHolder import org.schabi.newpipe.player.helper.PlayerHolder
import org.schabi.newpipe.util.DeviceUtils
import org.schabi.newpipe.util.Localization import org.schabi.newpipe.util.Localization
import org.schabi.newpipe.util.NavigationHelper import org.schabi.newpipe.util.NavigationHelper
import org.schabi.newpipe.util.StreamDialogEntry import org.schabi.newpipe.util.StreamDialogEntry
@ -76,6 +86,7 @@ import org.schabi.newpipe.util.ThemeHelper.getGridSpanCountStreams
import org.schabi.newpipe.util.ThemeHelper.shouldUseGridLayout import org.schabi.newpipe.util.ThemeHelper.shouldUseGridLayout
import java.time.OffsetDateTime import java.time.OffsetDateTime
import java.util.ArrayList import java.util.ArrayList
import java.util.function.Consumer
class FeedFragment : BaseStateFragment<FeedState>() { class FeedFragment : BaseStateFragment<FeedState>() {
private var _feedBinding: FragmentFeedBinding? = null private var _feedBinding: FragmentFeedBinding? = null
@ -97,6 +108,8 @@ class FeedFragment : BaseStateFragment<FeedState>() {
private var updateListViewModeOnResume = false private var updateListViewModeOnResume = false
private var isRefreshing = false private var isRefreshing = false
private var lastNewItemsCount = 0
init { init {
setHasOptionsMenu(true) setHasOptionsMenu(true)
} }
@ -136,6 +149,20 @@ class FeedFragment : BaseStateFragment<FeedState>() {
setOnItemLongClickListener(listenerStreamItem) setOnItemLongClickListener(listenerStreamItem)
} }
feedBinding.itemsList.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
// Check if we scrolled to the top
if (newState == RecyclerView.SCROLL_STATE_IDLE &&
!recyclerView.canScrollVertically(-1)
) {
if (tryGetNewItemsLoadedButton()?.isVisible == true) {
hideNewItemsLoaded(true)
}
}
}
})
feedBinding.itemsList.adapter = groupAdapter feedBinding.itemsList.adapter = groupAdapter
setupListViewMode() setupListViewMode()
} }
@ -171,6 +198,10 @@ class FeedFragment : BaseStateFragment<FeedState>() {
super.initListeners() super.initListeners()
feedBinding.refreshRootView.setOnClickListener { reloadContent() } feedBinding.refreshRootView.setOnClickListener { reloadContent() }
feedBinding.swipeRefreshLayout.setOnRefreshListener { reloadContent() } feedBinding.swipeRefreshLayout.setOnRefreshListener { reloadContent() }
feedBinding.newItemsLoadedButton.setOnClickListener {
hideNewItemsLoaded(true)
feedBinding.itemsList.scrollToPosition(0)
}
} }
// ///////////////////////////////////////////////////////////////////////// // /////////////////////////////////////////////////////////////////////////
@ -238,6 +269,9 @@ class FeedFragment : BaseStateFragment<FeedState>() {
} }
override fun onDestroyView() { override fun onDestroyView() {
// Ensure that all animations are canceled
feedBinding.newItemsLoadedButton?.clearAnimation()
feedBinding.itemsList.adapter = null feedBinding.itemsList.adapter = null
_feedBinding = null _feedBinding = null
super.onDestroyView() super.onDestroyView()
@ -400,7 +434,17 @@ class FeedFragment : BaseStateFragment<FeedState>() {
} }
loadedState.items.forEach { it.itemVersion = itemVersion } loadedState.items.forEach { it.itemVersion = itemVersion }
groupAdapter.updateAsync(loadedState.items, false, null) // This need to be saved in a variable as the update occurs async
val oldOldestSubscriptionUpdate = oldestSubscriptionUpdate
groupAdapter.updateAsync(
loadedState.items, false,
OnAsyncUpdateListener {
oldOldestSubscriptionUpdate?.run {
highlightNewItemsAfter(oldOldestSubscriptionUpdate)
}
}
)
listState?.run { listState?.run {
feedBinding.itemsList.layoutManager?.onRestoreInstanceState(listState) feedBinding.itemsList.layoutManager?.onRestoreInstanceState(listState)
@ -522,6 +566,125 @@ class FeedFragment : BaseStateFragment<FeedState>() {
) )
} }
/**
* Highlights all items that are after the specified time
*/
private fun highlightNewItemsAfter(updateTime: OffsetDateTime) {
var highlightCount = 0
var doCheck = true
for (i in 0 until groupAdapter.itemCount) {
val item = groupAdapter.getItem(i) as StreamItem
var typeface = Typeface.DEFAULT
var backgroundSupplier = { ctx: Context ->
resolveDrawable(ctx, R.attr.selectableItemBackground)
}
if (doCheck) {
// If the uploadDate is null or true we should highlight the item
if (item.streamWithState.stream.uploadDate?.isAfter(updateTime) != false) {
highlightCount++
typeface = Typeface.DEFAULT_BOLD
backgroundSupplier = { ctx: Context ->
// Merge the drawables together. Otherwise we would lose the "select" effect
LayerDrawable(
arrayOf(
resolveDrawable(ctx, R.attr.dashed_border),
resolveDrawable(ctx, R.attr.selectableItemBackground)
)
)
}
} else {
// Decreases execution time due to the order of the items (newest always on top)
// Once a item is is before the updateTime we can skip all following items
doCheck = false
}
}
// The highlighter has to be always set
// When it's only set on items that are highlighted it will highlight all items
// due to the fact that itemRoot is getting recycled
item.execBindEnd = Consumer { viewBinding ->
val context = viewBinding.itemRoot.context
viewBinding.itemRoot.background = backgroundSupplier.invoke(context)
viewBinding.itemVideoTitleView.typeface = typeface
}
}
// Force updates all items so that the highlighting is correct
// If this isn't done visible items that are already highlighted will stay in a highlighted
// state until the user scrolls them out of the visible area which causes a update/bind-call
groupAdapter.notifyItemRangeChanged(
0,
minOf(groupAdapter.itemCount, maxOf(highlightCount, lastNewItemsCount))
)
if (highlightCount > 0) {
showNewItemsLoaded()
}
lastNewItemsCount = highlightCount
}
private fun resolveDrawable(context: Context, @AttrRes attrResId: Int): Drawable? {
return androidx.core.content.ContextCompat.getDrawable(
context,
android.util.TypedValue().apply {
context.theme.resolveAttribute(
attrResId,
this,
true
)
}.resourceId
)
}
private fun showNewItemsLoaded() {
tryGetNewItemsLoadedButton()?.clearAnimation()
tryGetNewItemsLoadedButton()
?.slideUp(
250L,
delay = 100,
execOnEnd = {
// Disabled animations would result in immediately hiding the button
// after it showed up
if (DeviceUtils.hasAnimationsAnimatorDurationEnabled(context)) {
// Hide the new items-"popup" after 10s
hideNewItemsLoaded(true, 10000)
}
}
)
}
private fun hideNewItemsLoaded(animate: Boolean, delay: Long = 0) {
tryGetNewItemsLoadedButton()?.clearAnimation()
if (animate) {
tryGetNewItemsLoadedButton()?.animate(
false,
200,
delay = delay,
execOnEnd = {
// Make the layout invisible so that the onScroll toTop method
// only does necessary work
tryGetNewItemsLoadedButton()?.isVisible = false
}
)
} else {
tryGetNewItemsLoadedButton()?.isVisible = false
}
}
/**
* The view/button can be disposed/set to null under certain circumstances.
* E.g. when the animation is still in progress but the view got destroyed.
* This method is a helper for such states and can be used in affected code blocks.
*/
private fun tryGetNewItemsLoadedButton(): Button? {
return _feedBinding?.newItemsLoadedButton
}
// ///////////////////////////////////////////////////////////////////////// // /////////////////////////////////////////////////////////////////////////
// Load Service Handling // Load Service Handling
// ///////////////////////////////////////////////////////////////////////// // /////////////////////////////////////////////////////////////////////////
@ -529,6 +692,8 @@ class FeedFragment : BaseStateFragment<FeedState>() {
override fun doInitialLoadLogic() {} override fun doInitialLoadLogic() {}
override fun reloadContent() { override fun reloadContent() {
hideNewItemsLoaded(false)
getActivity()?.startService( getActivity()?.startService(
Intent(requireContext(), FeedLoadService::class.java).apply { Intent(requireContext(), FeedLoadService::class.java).apply {
putExtra(FeedLoadService.EXTRA_GROUP_ID, groupId) putExtra(FeedLoadService.EXTRA_GROUP_ID, groupId)

View File

@ -33,12 +33,9 @@ class FeedViewModel(
private var feedDatabaseManager: FeedDatabaseManager = FeedDatabaseManager(applicationContext) private var feedDatabaseManager: FeedDatabaseManager = FeedDatabaseManager(applicationContext)
private val toggleShowPlayedItems = BehaviorProcessor.create<Boolean>() private val toggleShowPlayedItems = BehaviorProcessor.create<Boolean>()
private val streamItems = toggleShowPlayedItems private val toggleShowPlayedItemsFlowable = toggleShowPlayedItems
.startWithItem(initialShowPlayedItems) .startWithItem(initialShowPlayedItems)
.distinctUntilChanged() .distinctUntilChanged()
.switchMap { showPlayedItems ->
feedDatabaseManager.getStreams(groupId, showPlayedItems)
}
private val mutableStateLiveData = MutableLiveData<FeedState>() private val mutableStateLiveData = MutableLiveData<FeedState>()
val stateLiveData: LiveData<FeedState> = mutableStateLiveData val stateLiveData: LiveData<FeedState> = mutableStateLiveData
@ -46,17 +43,28 @@ class FeedViewModel(
private var combineDisposable = Flowable private var combineDisposable = Flowable
.combineLatest( .combineLatest(
FeedEventManager.events(), FeedEventManager.events(),
streamItems, toggleShowPlayedItemsFlowable,
feedDatabaseManager.notLoadedCount(groupId), feedDatabaseManager.notLoadedCount(groupId),
feedDatabaseManager.oldestSubscriptionUpdate(groupId), feedDatabaseManager.oldestSubscriptionUpdate(groupId),
Function4 { t1: FeedEventManager.Event, t2: List<StreamWithState>, Function4 { t1: FeedEventManager.Event, t2: Boolean,
t3: Long, t4: List<OffsetDateTime> -> t3: Long, t4: List<OffsetDateTime> ->
return@Function4 CombineResultHolder(t1, t2, t3, t4.firstOrNull()) return@Function4 CombineResultEventHolder(t1, t2, t3, t4.firstOrNull())
} }
) )
.throttleLatest(DEFAULT_THROTTLE_TIMEOUT, TimeUnit.MILLISECONDS) .throttleLatest(DEFAULT_THROTTLE_TIMEOUT, TimeUnit.MILLISECONDS)
.subscribeOn(Schedulers.io()) .subscribeOn(Schedulers.io())
.observeOn(Schedulers.io())
.map { (event, showPlayedItems, notLoadedCount, oldestUpdate) ->
var streamItems = if (event is SuccessResultEvent || event is IdleEvent)
feedDatabaseManager
.getStreams(groupId, showPlayedItems)
.blockingGet(arrayListOf())
else
arrayListOf()
CombineResultDataHolder(event, streamItems, notLoadedCount, oldestUpdate)
}
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
.subscribe { (event, listFromDB, notLoadedCount, oldestUpdate) -> .subscribe { (event, listFromDB, notLoadedCount, oldestUpdate) ->
mutableStateLiveData.postValue( mutableStateLiveData.postValue(
@ -78,7 +86,19 @@ class FeedViewModel(
combineDisposable.dispose() combineDisposable.dispose()
} }
private data class CombineResultHolder(val t1: FeedEventManager.Event, val t2: List<StreamWithState>, val t3: Long, val t4: OffsetDateTime?) private data class CombineResultEventHolder(
val t1: FeedEventManager.Event,
val t2: Boolean,
val t3: Long,
val t4: OffsetDateTime?
)
private data class CombineResultDataHolder(
val t1: FeedEventManager.Event,
val t2: List<StreamWithState>,
val t3: Long,
val t4: OffsetDateTime?
)
fun togglePlayedItems(showPlayedItems: Boolean) { fun togglePlayedItems(showPlayedItems: Boolean) {
toggleShowPlayedItems.onNext(showPlayedItems) toggleShowPlayedItems.onNext(showPlayedItems)

View File

@ -19,6 +19,7 @@ import org.schabi.newpipe.util.Localization
import org.schabi.newpipe.util.PicassoHelper import org.schabi.newpipe.util.PicassoHelper
import org.schabi.newpipe.util.StreamTypeUtil import org.schabi.newpipe.util.StreamTypeUtil
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import java.util.function.Consumer
data class StreamItem( data class StreamItem(
val streamWithState: StreamWithState, val streamWithState: StreamWithState,
@ -31,6 +32,12 @@ data class StreamItem(
private val stream: StreamEntity = streamWithState.stream private val stream: StreamEntity = streamWithState.stream
private val stateProgressTime: Long? = streamWithState.stateProgressMillis private val stateProgressTime: Long? = streamWithState.stateProgressMillis
/**
* Will be executed at the end of the [StreamItem.bind] (with (ListStreamItemBinding,Int)).
* Can be used e.g. for highlighting a item.
*/
var execBindEnd: Consumer<ListStreamItemBinding>? = null
override fun getId(): Long = stream.uid override fun getId(): Long = stream.uid
enum class ItemVersion { NORMAL, MINI, GRID } enum class ItemVersion { NORMAL, MINI, GRID }
@ -97,6 +104,8 @@ data class StreamItem(
viewBinding.itemAdditionalDetails.text = viewBinding.itemAdditionalDetails.text =
getStreamInfoDetailLine(viewBinding.itemAdditionalDetails.context) getStreamInfoDetailLine(viewBinding.itemAdditionalDetails.context)
} }
execBindEnd?.accept(viewBinding)
} }
override fun isLongClickable() = when (stream.streamType) { override fun isLongClickable() = when (stream.streamType) {

View File

@ -6,6 +6,7 @@ import android.content.pm.PackageManager;
import android.content.res.Configuration; import android.content.res.Configuration;
import android.os.BatteryManager; import android.os.BatteryManager;
import android.os.Build; import android.os.Build;
import android.provider.Settings;
import android.util.TypedValue; import android.util.TypedValue;
import android.view.KeyEvent; import android.view.KeyEvent;
@ -144,4 +145,11 @@ public final class DeviceUtils {
public static boolean isInMultiWindow(final AppCompatActivity activity) { public static boolean isInMultiWindow(final AppCompatActivity activity) {
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && activity.isInMultiWindowMode(); return Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && activity.isInMultiWindowMode();
} }
public static boolean hasAnimationsAnimatorDurationEnabled(final Context context) {
return Settings.System.getFloat(
context.getContentResolver(),
Settings.Global.ANIMATOR_DURATION_SCALE,
1F) != 0F;
}
} }

View File

@ -87,6 +87,19 @@
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout> </androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
<Button
android:id="@+id/new_items_loaded_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignBottom="@id/swipeRefreshLayout"
android:layout_centerHorizontal="true"
android:layout_marginBottom="5sp"
android:text="@string/feed_new_items"
android:textSize="12sp"
android:theme="@style/ServiceColoredButton"
android:visibility="gone"
tools:visibility="visible" />
<LinearLayout <LinearLayout
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"

View File

@ -636,6 +636,7 @@
<string name="feed_subscription_not_loaded_count">Not loaded: %d</string> <string name="feed_subscription_not_loaded_count">Not loaded: %d</string>
<string name="feed_notification_loading">Loading feed…</string> <string name="feed_notification_loading">Loading feed…</string>
<string name="feed_processing_message">Processing feed…</string> <string name="feed_processing_message">Processing feed…</string>
<string name="feed_new_items">New feed items</string>
<string name="feed_group_dialog_select_subscriptions">Select subscriptions</string> <string name="feed_group_dialog_select_subscriptions">Select subscriptions</string>
<string name="feed_group_dialog_empty_selection">No subscription selected</string> <string name="feed_group_dialog_empty_selection">No subscription selected</string>
<plurals name="feed_group_dialog_selection_count"> <plurals name="feed_group_dialog_selection_count">