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