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.Update
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.FeedLastUpdatedEntity
import org.schabi.newpipe.database.stream.StreamWithState
@ -37,7 +38,7 @@ abstract class FeedDAO {
LIMIT 500
"""
)
abstract fun getAllStreams(): Flowable<List<StreamWithState>>
abstract fun getAllStreams(): Maybe<List<StreamWithState>>
@Query(
"""
@ -62,7 +63,7 @@ abstract class FeedDAO {
LIMIT 500
"""
)
abstract fun getAllStreamsForGroup(groupId: Long): Flowable<List<StreamWithState>>
abstract fun getAllStreamsForGroup(groupId: Long): Maybe<List<StreamWithState>>
/**
* @see StreamStateEntity.isFinished()
@ -97,7 +98,7 @@ abstract class FeedDAO {
LIMIT 500
"""
)
abstract fun getLiveOrNotPlayedStreams(): Flowable<List<StreamWithState>>
abstract fun getLiveOrNotPlayedStreams(): Maybe<List<StreamWithState>>
/**
* @see StreamStateEntity.isFinished()
@ -137,7 +138,7 @@ abstract class FeedDAO {
LIMIT 500
"""
)
abstract fun getLiveOrNotPlayedStreamsForGroup(groupId: Long): Flowable<List<StreamWithState>>
abstract fun getLiveOrNotPlayedStreamsForGroup(groupId: Long): Maybe<List<StreamWithState>>
@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()
animate().setListener(null).cancel()
alpha = 0f
translationY = newTranslationY.toFloat()
visibility = View.VISIBLE
isVisible = true
animate()
.alpha(1f)
.translationY(0f)
.setStartDelay(delay)
.setDuration(duration)
.setInterpolator(FastOutSlowInInterpolator())
.setListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator) {
execOnEnd?.run()
}
})
.start()
}

View File

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

View File

@ -21,8 +21,12 @@ package org.schabi.newpipe.local.feed
import android.annotation.SuppressLint
import android.app.Activity
import android.content.Context
import android.content.Intent
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.Parcelable
import android.view.LayoutInflater
@ -31,6 +35,8 @@ import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.widget.Button
import androidx.annotation.AttrRes
import androidx.annotation.Nullable
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.content.res.AppCompatResources
@ -40,8 +46,10 @@ import androidx.core.view.isVisible
import androidx.lifecycle.ViewModelProvider
import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.xwray.groupie.GroupieAdapter
import com.xwray.groupie.Item
import com.xwray.groupie.OnAsyncUpdateListener
import com.xwray.groupie.OnItemClickListener
import com.xwray.groupie.OnItemLongClickListener
import icepick.State
@ -65,10 +73,12 @@ import org.schabi.newpipe.fragments.BaseStateFragment
import org.schabi.newpipe.info_list.InfoItemDialog
import org.schabi.newpipe.ktx.animate
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.service.FeedLoadService
import org.schabi.newpipe.local.subscription.SubscriptionManager
import org.schabi.newpipe.player.helper.PlayerHolder
import org.schabi.newpipe.util.DeviceUtils
import org.schabi.newpipe.util.Localization
import org.schabi.newpipe.util.NavigationHelper
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 java.time.OffsetDateTime
import java.util.ArrayList
import java.util.function.Consumer
class FeedFragment : BaseStateFragment<FeedState>() {
private var _feedBinding: FragmentFeedBinding? = null
@ -97,6 +108,8 @@ class FeedFragment : BaseStateFragment<FeedState>() {
private var updateListViewModeOnResume = false
private var isRefreshing = false
private var lastNewItemsCount = 0
init {
setHasOptionsMenu(true)
}
@ -136,6 +149,20 @@ class FeedFragment : BaseStateFragment<FeedState>() {
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
setupListViewMode()
}
@ -171,6 +198,10 @@ class FeedFragment : BaseStateFragment<FeedState>() {
super.initListeners()
feedBinding.refreshRootView.setOnClickListener { 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() {
// Ensure that all animations are canceled
feedBinding.newItemsLoadedButton?.clearAnimation()
feedBinding.itemsList.adapter = null
_feedBinding = null
super.onDestroyView()
@ -400,7 +434,17 @@ class FeedFragment : BaseStateFragment<FeedState>() {
}
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 {
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
// /////////////////////////////////////////////////////////////////////////
@ -529,6 +692,8 @@ class FeedFragment : BaseStateFragment<FeedState>() {
override fun doInitialLoadLogic() {}
override fun reloadContent() {
hideNewItemsLoaded(false)
getActivity()?.startService(
Intent(requireContext(), FeedLoadService::class.java).apply {
putExtra(FeedLoadService.EXTRA_GROUP_ID, groupId)

View File

@ -33,12 +33,9 @@ class FeedViewModel(
private var feedDatabaseManager: FeedDatabaseManager = FeedDatabaseManager(applicationContext)
private val toggleShowPlayedItems = BehaviorProcessor.create<Boolean>()
private val streamItems = toggleShowPlayedItems
private val toggleShowPlayedItemsFlowable = toggleShowPlayedItems
.startWithItem(initialShowPlayedItems)
.distinctUntilChanged()
.switchMap { showPlayedItems ->
feedDatabaseManager.getStreams(groupId, showPlayedItems)
}
private val mutableStateLiveData = MutableLiveData<FeedState>()
val stateLiveData: LiveData<FeedState> = mutableStateLiveData
@ -46,17 +43,28 @@ class FeedViewModel(
private var combineDisposable = Flowable
.combineLatest(
FeedEventManager.events(),
streamItems,
toggleShowPlayedItemsFlowable,
feedDatabaseManager.notLoadedCount(groupId),
feedDatabaseManager.oldestSubscriptionUpdate(groupId),
Function4 { t1: FeedEventManager.Event, t2: List<StreamWithState>,
Function4 { t1: FeedEventManager.Event, t2: Boolean,
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)
.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())
.subscribe { (event, listFromDB, notLoadedCount, oldestUpdate) ->
mutableStateLiveData.postValue(
@ -78,7 +86,19 @@ class FeedViewModel(
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) {
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.StreamTypeUtil
import java.util.concurrent.TimeUnit
import java.util.function.Consumer
data class StreamItem(
val streamWithState: StreamWithState,
@ -31,6 +32,12 @@ data class StreamItem(
private val stream: StreamEntity = streamWithState.stream
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
enum class ItemVersion { NORMAL, MINI, GRID }
@ -97,6 +104,8 @@ data class StreamItem(
viewBinding.itemAdditionalDetails.text =
getStreamInfoDetailLine(viewBinding.itemAdditionalDetails.context)
}
execBindEnd?.accept(viewBinding)
}
override fun isLongClickable() = when (stream.streamType) {

View File

@ -6,6 +6,7 @@ import android.content.pm.PackageManager;
import android.content.res.Configuration;
import android.os.BatteryManager;
import android.os.Build;
import android.provider.Settings;
import android.util.TypedValue;
import android.view.KeyEvent;
@ -144,4 +145,11 @@ public final class DeviceUtils {
public static boolean isInMultiWindow(final AppCompatActivity activity) {
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>
<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
android:layout_width="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_notification_loading">Loading 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_empty_selection">No subscription selected</string>
<plurals name="feed_group_dialog_selection_count">