diff --git a/app/src/main/java/org/schabi/newpipe/database/feed/dao/FeedDAO.kt b/app/src/main/java/org/schabi/newpipe/database/feed/dao/FeedDAO.kt index 689f1ead6..72692a9f5 100644 --- a/app/src/main/java/org/schabi/newpipe/database/feed/dao/FeedDAO.kt +++ b/app/src/main/java/org/schabi/newpipe/database/feed/dao/FeedDAO.kt @@ -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> + abstract fun getAllStreams(): Maybe> @Query( """ @@ -62,7 +63,7 @@ abstract class FeedDAO { LIMIT 500 """ ) - abstract fun getAllStreamsForGroup(groupId: Long): Flowable> + abstract fun getAllStreamsForGroup(groupId: Long): Maybe> /** * @see StreamStateEntity.isFinished() @@ -97,7 +98,7 @@ abstract class FeedDAO { LIMIT 500 """ ) - abstract fun getLiveOrNotPlayedStreams(): Flowable> + abstract fun getLiveOrNotPlayedStreams(): Maybe> /** * @see StreamStateEntity.isFinished() @@ -137,7 +138,7 @@ abstract class FeedDAO { LIMIT 500 """ ) - abstract fun getLiveOrNotPlayedStreamsForGroup(groupId: Long): Flowable> + abstract fun getLiveOrNotPlayedStreamsForGroup(groupId: Long): Maybe> @Query( """ diff --git a/app/src/main/java/org/schabi/newpipe/ktx/View.kt b/app/src/main/java/org/schabi/newpipe/ktx/View.kt index 8f2249493..a1a96b20d 100644 --- a/app/src/main/java/org/schabi/newpipe/ktx/View.kt +++ b/app/src/main/java/org/schabi/newpipe/ktx/View.kt @@ -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() } diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/FeedDatabaseManager.kt b/app/src/main/java/org/schabi/newpipe/local/feed/FeedDatabaseManager.kt index ff7c2848e..e28f2d31a 100644 --- a/app/src/main/java/org/schabi/newpipe/local/feed/FeedDatabaseManager.kt +++ b/app/src/main/java/org/schabi/newpipe/local/feed/FeedDatabaseManager.kt @@ -42,7 +42,7 @@ class FeedDatabaseManager(context: Context) { fun getStreams( groupId: Long = FeedGroupEntity.GROUP_ALL_ID, getPlayedStreams: Boolean = true - ): Flowable> { + ): Maybe> { return when (groupId) { FeedGroupEntity.GROUP_ALL_ID -> { if (getPlayedStreams) feedTable.getAllStreams() 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 index 118e65023..01ff0b1c1 100644 --- a/app/src/main/java/org/schabi/newpipe/local/feed/FeedFragment.kt +++ b/app/src/main/java/org/schabi/newpipe/local/feed/FeedFragment.kt @@ -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() { private var _feedBinding: FragmentFeedBinding? = null @@ -97,6 +108,8 @@ class FeedFragment : BaseStateFragment() { private var updateListViewModeOnResume = false private var isRefreshing = false + private var lastNewItemsCount = 0 + init { setHasOptionsMenu(true) } @@ -136,6 +149,20 @@ class FeedFragment : BaseStateFragment() { 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() { 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() { } 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() { } 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() { ) } + /** + * 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() { override fun doInitialLoadLogic() {} override fun reloadContent() { + hideNewItemsLoaded(false) + getActivity()?.startService( Intent(requireContext(), FeedLoadService::class.java).apply { putExtra(FeedLoadService.EXTRA_GROUP_ID, groupId) 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 index ecdcb7349..2cbf9ad05 100644 --- a/app/src/main/java/org/schabi/newpipe/local/feed/FeedViewModel.kt +++ b/app/src/main/java/org/schabi/newpipe/local/feed/FeedViewModel.kt @@ -33,12 +33,9 @@ class FeedViewModel( private var feedDatabaseManager: FeedDatabaseManager = FeedDatabaseManager(applicationContext) private val toggleShowPlayedItems = BehaviorProcessor.create() - private val streamItems = toggleShowPlayedItems + private val toggleShowPlayedItemsFlowable = toggleShowPlayedItems .startWithItem(initialShowPlayedItems) .distinctUntilChanged() - .switchMap { showPlayedItems -> - feedDatabaseManager.getStreams(groupId, showPlayedItems) - } private val mutableStateLiveData = MutableLiveData() val stateLiveData: LiveData = 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, + Function4 { t1: FeedEventManager.Event, t2: Boolean, t3: Long, t4: List -> - 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, 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, + val t3: Long, + val t4: OffsetDateTime? + ) fun togglePlayedItems(showPlayedItems: Boolean) { toggleShowPlayedItems.onNext(showPlayedItems) diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/item/StreamItem.kt b/app/src/main/java/org/schabi/newpipe/local/feed/item/StreamItem.kt index 0d2caf126..217e3f3e3 100644 --- a/app/src/main/java/org/schabi/newpipe/local/feed/item/StreamItem.kt +++ b/app/src/main/java/org/schabi/newpipe/local/feed/item/StreamItem.kt @@ -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? = 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) { diff --git a/app/src/main/java/org/schabi/newpipe/util/DeviceUtils.java b/app/src/main/java/org/schabi/newpipe/util/DeviceUtils.java index bdf5e8ce4..bbe9a7edb 100644 --- a/app/src/main/java/org/schabi/newpipe/util/DeviceUtils.java +++ b/app/src/main/java/org/schabi/newpipe/util/DeviceUtils.java @@ -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; + } } diff --git a/app/src/main/res/layout/fragment_feed.xml b/app/src/main/res/layout/fragment_feed.xml index d5ba0e8e3..ebe76af0c 100644 --- a/app/src/main/res/layout/fragment_feed.xml +++ b/app/src/main/res/layout/fragment_feed.xml @@ -87,6 +87,19 @@ +