Skip to content

Commit d5199ea

Browse files
authored
Merge pull request #7050 from litetex/feed-refactor-new-items-handling
Rework feed new items handling
2 parents 72dfe97 + 7638d22 commit d5199ea

9 files changed

Lines changed: 251 additions & 16 deletions

File tree

app/src/main/java/org/schabi/newpipe/database/feed/dao/FeedDAO.kt

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import androidx.room.Query
77
import androidx.room.Transaction
88
import androidx.room.Update
99
import io.reactivex.rxjava3.core.Flowable
10+
import io.reactivex.rxjava3.core.Maybe
1011
import org.schabi.newpipe.database.feed.model.FeedEntity
1112
import org.schabi.newpipe.database.feed.model.FeedLastUpdatedEntity
1213
import org.schabi.newpipe.database.stream.StreamWithState
@@ -37,7 +38,7 @@ abstract class FeedDAO {
3738
LIMIT 500
3839
"""
3940
)
40-
abstract fun getAllStreams(): Flowable<List<StreamWithState>>
41+
abstract fun getAllStreams(): Maybe<List<StreamWithState>>
4142

4243
@Query(
4344
"""
@@ -62,7 +63,7 @@ abstract class FeedDAO {
6263
LIMIT 500
6364
"""
6465
)
65-
abstract fun getAllStreamsForGroup(groupId: Long): Flowable<List<StreamWithState>>
66+
abstract fun getAllStreamsForGroup(groupId: Long): Maybe<List<StreamWithState>>
6667

6768
/**
6869
* @see StreamStateEntity.isFinished()
@@ -97,7 +98,7 @@ abstract class FeedDAO {
9798
LIMIT 500
9899
"""
99100
)
100-
abstract fun getLiveOrNotPlayedStreams(): Flowable<List<StreamWithState>>
101+
abstract fun getLiveOrNotPlayedStreams(): Maybe<List<StreamWithState>>
101102

102103
/**
103104
* @see StreamStateEntity.isFinished()
@@ -137,7 +138,7 @@ abstract class FeedDAO {
137138
LIMIT 500
138139
"""
139140
)
140-
abstract fun getLiveOrNotPlayedStreamsForGroup(groupId: Long): Flowable<List<StreamWithState>>
141+
abstract fun getLiveOrNotPlayedStreamsForGroup(groupId: Long): Maybe<List<StreamWithState>>
141142

142143
@Query(
143144
"""

app/src/main/java/org/schabi/newpipe/ktx/View.kt

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -299,18 +299,36 @@ private fun View.animateLightSlideAndAlpha(enterOrExit: Boolean, duration: Long,
299299
}
300300
}
301301

302-
fun View.slideUp(duration: Long, delay: Long, @FloatRange(from = 0.0, to = 1.0) translationPercent: Float) {
302+
fun View.slideUp(
303+
duration: Long,
304+
delay: Long,
305+
@FloatRange(from = 0.0, to = 1.0) translationPercent: Float
306+
) {
307+
slideUp(duration, delay, translationPercent, null)
308+
}
309+
310+
fun View.slideUp(
311+
duration: Long,
312+
delay: Long = 0L,
313+
@FloatRange(from = 0.0, to = 1.0) translationPercent: Float = 1.0F,
314+
execOnEnd: Runnable? = null
315+
) {
303316
val newTranslationY = (resources.displayMetrics.heightPixels * translationPercent).toInt()
304317
animate().setListener(null).cancel()
305318
alpha = 0f
306319
translationY = newTranslationY.toFloat()
307-
visibility = View.VISIBLE
320+
isVisible = true
308321
animate()
309322
.alpha(1f)
310323
.translationY(0f)
311324
.setStartDelay(delay)
312325
.setDuration(duration)
313326
.setInterpolator(FastOutSlowInInterpolator())
327+
.setListener(object : AnimatorListenerAdapter() {
328+
override fun onAnimationEnd(animation: Animator) {
329+
execOnEnd?.run()
330+
}
331+
})
314332
.start()
315333
}
316334

app/src/main/java/org/schabi/newpipe/local/feed/FeedDatabaseManager.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ class FeedDatabaseManager(context: Context) {
4242
fun getStreams(
4343
groupId: Long = FeedGroupEntity.GROUP_ALL_ID,
4444
getPlayedStreams: Boolean = true
45-
): Flowable<List<StreamWithState>> {
45+
): Maybe<List<StreamWithState>> {
4646
return when (groupId) {
4747
FeedGroupEntity.GROUP_ALL_ID -> {
4848
if (getPlayedStreams) feedTable.getAllStreams()

app/src/main/java/org/schabi/newpipe/local/feed/FeedFragment.kt

Lines changed: 166 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,12 @@ package org.schabi.newpipe.local.feed
2121

2222
import android.annotation.SuppressLint
2323
import android.app.Activity
24+
import android.content.Context
2425
import android.content.Intent
2526
import android.content.SharedPreferences
27+
import android.graphics.Typeface
28+
import android.graphics.drawable.Drawable
29+
import android.graphics.drawable.LayerDrawable
2630
import android.os.Bundle
2731
import android.os.Parcelable
2832
import android.view.LayoutInflater
@@ -31,6 +35,8 @@ import android.view.MenuInflater
3135
import android.view.MenuItem
3236
import android.view.View
3337
import android.view.ViewGroup
38+
import android.widget.Button
39+
import androidx.annotation.AttrRes
3440
import androidx.annotation.Nullable
3541
import androidx.appcompat.app.AlertDialog
3642
import androidx.appcompat.content.res.AppCompatResources
@@ -40,8 +46,10 @@ import androidx.core.view.isVisible
4046
import androidx.lifecycle.ViewModelProvider
4147
import androidx.preference.PreferenceManager
4248
import androidx.recyclerview.widget.GridLayoutManager
49+
import androidx.recyclerview.widget.RecyclerView
4350
import com.xwray.groupie.GroupieAdapter
4451
import com.xwray.groupie.Item
52+
import com.xwray.groupie.OnAsyncUpdateListener
4553
import com.xwray.groupie.OnItemClickListener
4654
import com.xwray.groupie.OnItemLongClickListener
4755
import icepick.State
@@ -65,17 +73,20 @@ import org.schabi.newpipe.fragments.BaseStateFragment
6573
import org.schabi.newpipe.info_list.InfoItemDialog
6674
import org.schabi.newpipe.ktx.animate
6775
import org.schabi.newpipe.ktx.animateHideRecyclerViewAllowingScrolling
76+
import org.schabi.newpipe.ktx.slideUp
6877
import org.schabi.newpipe.local.feed.item.StreamItem
6978
import org.schabi.newpipe.local.feed.service.FeedLoadService
7079
import org.schabi.newpipe.local.subscription.SubscriptionManager
7180
import org.schabi.newpipe.player.helper.PlayerHolder
81+
import org.schabi.newpipe.util.DeviceUtils
7282
import org.schabi.newpipe.util.Localization
7383
import org.schabi.newpipe.util.NavigationHelper
7484
import org.schabi.newpipe.util.StreamDialogEntry
7585
import org.schabi.newpipe.util.ThemeHelper.getGridSpanCountStreams
7686
import org.schabi.newpipe.util.ThemeHelper.shouldUseGridLayout
7787
import java.time.OffsetDateTime
7888
import java.util.ArrayList
89+
import java.util.function.Consumer
7990

8091
class FeedFragment : BaseStateFragment<FeedState>() {
8192
private var _feedBinding: FragmentFeedBinding? = null
@@ -97,6 +108,8 @@ class FeedFragment : BaseStateFragment<FeedState>() {
97108
private var updateListViewModeOnResume = false
98109
private var isRefreshing = false
99110

111+
private var lastNewItemsCount = 0
112+
100113
init {
101114
setHasOptionsMenu(true)
102115
}
@@ -136,6 +149,20 @@ class FeedFragment : BaseStateFragment<FeedState>() {
136149
setOnItemLongClickListener(listenerStreamItem)
137150
}
138151

152+
feedBinding.itemsList.addOnScrollListener(object : RecyclerView.OnScrollListener() {
153+
override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
154+
// Check if we scrolled to the top
155+
if (newState == RecyclerView.SCROLL_STATE_IDLE &&
156+
!recyclerView.canScrollVertically(-1)
157+
) {
158+
159+
if (tryGetNewItemsLoadedButton()?.isVisible == true) {
160+
hideNewItemsLoaded(true)
161+
}
162+
}
163+
}
164+
})
165+
139166
feedBinding.itemsList.adapter = groupAdapter
140167
setupListViewMode()
141168
}
@@ -171,6 +198,10 @@ class FeedFragment : BaseStateFragment<FeedState>() {
171198
super.initListeners()
172199
feedBinding.refreshRootView.setOnClickListener { reloadContent() }
173200
feedBinding.swipeRefreshLayout.setOnRefreshListener { reloadContent() }
201+
feedBinding.newItemsLoadedButton.setOnClickListener {
202+
hideNewItemsLoaded(true)
203+
feedBinding.itemsList.scrollToPosition(0)
204+
}
174205
}
175206

176207
// /////////////////////////////////////////////////////////////////////////
@@ -238,6 +269,9 @@ class FeedFragment : BaseStateFragment<FeedState>() {
238269
}
239270

240271
override fun onDestroyView() {
272+
// Ensure that all animations are canceled
273+
feedBinding.newItemsLoadedButton?.clearAnimation()
274+
241275
feedBinding.itemsList.adapter = null
242276
_feedBinding = null
243277
super.onDestroyView()
@@ -400,7 +434,17 @@ class FeedFragment : BaseStateFragment<FeedState>() {
400434
}
401435
loadedState.items.forEach { it.itemVersion = itemVersion }
402436

403-
groupAdapter.updateAsync(loadedState.items, false, null)
437+
// This need to be saved in a variable as the update occurs async
438+
val oldOldestSubscriptionUpdate = oldestSubscriptionUpdate
439+
440+
groupAdapter.updateAsync(
441+
loadedState.items, false,
442+
OnAsyncUpdateListener {
443+
oldOldestSubscriptionUpdate?.run {
444+
highlightNewItemsAfter(oldOldestSubscriptionUpdate)
445+
}
446+
}
447+
)
404448

405449
listState?.run {
406450
feedBinding.itemsList.layoutManager?.onRestoreInstanceState(listState)
@@ -522,13 +566,134 @@ class FeedFragment : BaseStateFragment<FeedState>() {
522566
)
523567
}
524568

569+
/**
570+
* Highlights all items that are after the specified time
571+
*/
572+
private fun highlightNewItemsAfter(updateTime: OffsetDateTime) {
573+
var highlightCount = 0
574+
575+
var doCheck = true
576+
577+
for (i in 0 until groupAdapter.itemCount) {
578+
val item = groupAdapter.getItem(i) as StreamItem
579+
580+
var typeface = Typeface.DEFAULT
581+
var backgroundSupplier = { ctx: Context ->
582+
resolveDrawable(ctx, R.attr.selectableItemBackground)
583+
}
584+
if (doCheck) {
585+
// If the uploadDate is null or true we should highlight the item
586+
if (item.streamWithState.stream.uploadDate?.isAfter(updateTime) != false) {
587+
highlightCount++
588+
589+
typeface = Typeface.DEFAULT_BOLD
590+
backgroundSupplier = { ctx: Context ->
591+
// Merge the drawables together. Otherwise we would lose the "select" effect
592+
LayerDrawable(
593+
arrayOf(
594+
resolveDrawable(ctx, R.attr.dashed_border),
595+
resolveDrawable(ctx, R.attr.selectableItemBackground)
596+
)
597+
)
598+
}
599+
} else {
600+
// Decreases execution time due to the order of the items (newest always on top)
601+
// Once a item is is before the updateTime we can skip all following items
602+
doCheck = false
603+
}
604+
}
605+
606+
// The highlighter has to be always set
607+
// When it's only set on items that are highlighted it will highlight all items
608+
// due to the fact that itemRoot is getting recycled
609+
item.execBindEnd = Consumer { viewBinding ->
610+
val context = viewBinding.itemRoot.context
611+
viewBinding.itemRoot.background = backgroundSupplier.invoke(context)
612+
viewBinding.itemVideoTitleView.typeface = typeface
613+
}
614+
}
615+
616+
// Force updates all items so that the highlighting is correct
617+
// If this isn't done visible items that are already highlighted will stay in a highlighted
618+
// state until the user scrolls them out of the visible area which causes a update/bind-call
619+
groupAdapter.notifyItemRangeChanged(
620+
0,
621+
minOf(groupAdapter.itemCount, maxOf(highlightCount, lastNewItemsCount))
622+
)
623+
624+
if (highlightCount > 0) {
625+
showNewItemsLoaded()
626+
}
627+
628+
lastNewItemsCount = highlightCount
629+
}
630+
631+
private fun resolveDrawable(context: Context, @AttrRes attrResId: Int): Drawable? {
632+
return androidx.core.content.ContextCompat.getDrawable(
633+
context,
634+
android.util.TypedValue().apply {
635+
context.theme.resolveAttribute(
636+
attrResId,
637+
this,
638+
true
639+
)
640+
}.resourceId
641+
)
642+
}
643+
644+
private fun showNewItemsLoaded() {
645+
tryGetNewItemsLoadedButton()?.clearAnimation()
646+
tryGetNewItemsLoadedButton()
647+
?.slideUp(
648+
250L,
649+
delay = 100,
650+
execOnEnd = {
651+
// Disabled animations would result in immediately hiding the button
652+
// after it showed up
653+
if (DeviceUtils.hasAnimationsAnimatorDurationEnabled(context)) {
654+
// Hide the new items-"popup" after 10s
655+
hideNewItemsLoaded(true, 10000)
656+
}
657+
}
658+
)
659+
}
660+
661+
private fun hideNewItemsLoaded(animate: Boolean, delay: Long = 0) {
662+
tryGetNewItemsLoadedButton()?.clearAnimation()
663+
if (animate) {
664+
tryGetNewItemsLoadedButton()?.animate(
665+
false,
666+
200,
667+
delay = delay,
668+
execOnEnd = {
669+
// Make the layout invisible so that the onScroll toTop method
670+
// only does necessary work
671+
tryGetNewItemsLoadedButton()?.isVisible = false
672+
}
673+
)
674+
} else {
675+
tryGetNewItemsLoadedButton()?.isVisible = false
676+
}
677+
}
678+
679+
/**
680+
* The view/button can be disposed/set to null under certain circumstances.
681+
* E.g. when the animation is still in progress but the view got destroyed.
682+
* This method is a helper for such states and can be used in affected code blocks.
683+
*/
684+
private fun tryGetNewItemsLoadedButton(): Button? {
685+
return _feedBinding?.newItemsLoadedButton
686+
}
687+
525688
// /////////////////////////////////////////////////////////////////////////
526689
// Load Service Handling
527690
// /////////////////////////////////////////////////////////////////////////
528691

529692
override fun doInitialLoadLogic() {}
530693

531694
override fun reloadContent() {
695+
hideNewItemsLoaded(false)
696+
532697
getActivity()?.startService(
533698
Intent(requireContext(), FeedLoadService::class.java).apply {
534699
putExtra(FeedLoadService.EXTRA_GROUP_ID, groupId)

0 commit comments

Comments
 (0)