From e846f69e38c6f66bdbbab05200ca56d39f4edb94 Mon Sep 17 00:00:00 2001 From: Mauricio Colli Date: Sun, 5 Apr 2020 11:11:03 -0300 Subject: [PATCH] Add ability to hide played items in a feed - Use components from the new Groupie list library for displaying the feed list. --- .../newpipe/database/feed/dao/FeedDAO.kt | 84 +++++++- .../database/stream/StreamWithState.kt | 17 ++ .../newpipe/local/feed/FeedDatabaseManager.kt | 45 +++-- .../schabi/newpipe/local/feed/FeedFragment.kt | 182 ++++++++++++++++-- .../schabi/newpipe/local/feed/FeedState.kt | 4 +- .../newpipe/local/feed/FeedViewModel.kt | 52 +++-- .../newpipe/local/feed/item/StreamItem.kt | 157 +++++++++++++++ .../res/drawable-night/ic_visibility_off.xml | 9 + .../res/drawable-night/ic_visibility_on.xml | 9 + .../main/res/drawable/ic_visibility_off.xml | 9 + .../main/res/drawable/ic_visibility_on.xml | 9 + .../item_in_history_indicator_background.xml | 7 + .../main/res/layout/list_stream_grid_item.xml | 26 +++ app/src/main/res/layout/list_stream_item.xml | 26 +++ .../main/res/layout/list_stream_mini_item.xml | 26 +++ .../layout/list_stream_playlist_grid_item.xml | 26 +++ .../res/layout/list_stream_playlist_item.xml | 26 +++ app/src/main/res/menu/menu_feed_fragment.xml | 10 +- app/src/main/res/values/colors.xml | 1 + app/src/main/res/values/dimens.xml | 3 + app/src/main/res/values/strings.xml | 3 +- 21 files changed, 668 insertions(+), 63 deletions(-) create mode 100644 app/src/main/java/org/schabi/newpipe/database/stream/StreamWithState.kt create mode 100644 app/src/main/java/org/schabi/newpipe/local/feed/item/StreamItem.kt create mode 100644 app/src/main/res/drawable-night/ic_visibility_off.xml create mode 100644 app/src/main/res/drawable-night/ic_visibility_on.xml create mode 100644 app/src/main/res/drawable/ic_visibility_off.xml create mode 100644 app/src/main/res/drawable/ic_visibility_on.xml create mode 100644 app/src/main/res/drawable/item_in_history_indicator_background.xml 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 f216ba1d8..f3a4a13b5 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 @@ -9,7 +9,7 @@ import androidx.room.Update import io.reactivex.rxjava3.core.Flowable import org.schabi.newpipe.database.feed.model.FeedEntity import org.schabi.newpipe.database.feed.model.FeedLastUpdatedEntity -import org.schabi.newpipe.database.stream.model.StreamEntity +import org.schabi.newpipe.database.stream.StreamWithState import org.schabi.newpipe.database.subscription.SubscriptionEntity import java.time.OffsetDateTime @@ -20,21 +20,34 @@ abstract class FeedDAO { @Query( """ - SELECT s.* FROM streams s + SELECT s.*, sst.progress_time, (sh.stream_id IS NOT NULL) AS is_stream_in_history + FROM streams s + + LEFT JOIN stream_state sst + ON s.uid = sst.stream_id + + LEFT JOIN stream_history sh + ON s.uid = sh.stream_id INNER JOIN feed f ON s.uid = f.stream_id ORDER BY s.upload_date IS NULL DESC, s.upload_date DESC, s.uploader ASC - LIMIT 500 """ ) - abstract fun getAllStreams(): Flowable> + abstract fun getAllStreams(): Flowable> @Query( """ - SELECT s.* FROM streams s + SELECT s.*, sst.progress_time, (sh.stream_id IS NOT NULL) AS is_stream_in_history + FROM streams s + + LEFT JOIN stream_state sst + ON s.uid = sst.stream_id + + LEFT JOIN stream_history sh + ON s.uid = sh.stream_id INNER JOIN feed f ON s.uid = f.stream_id @@ -42,16 +55,69 @@ abstract class FeedDAO { INNER JOIN feed_group_subscription_join fgs ON fgs.subscription_id = f.subscription_id - INNER JOIN feed_group fg - ON fg.uid = fgs.group_id - WHERE fgs.group_id = :groupId ORDER BY s.upload_date IS NULL DESC, s.upload_date DESC, s.uploader ASC LIMIT 500 """ ) - abstract fun getAllStreamsFromGroup(groupId: Long): Flowable> + abstract fun getAllStreamsForGroup(groupId: Long): Flowable> + + @Query( + """ + SELECT s.*, sst.progress_time, (sh.stream_id IS NOT NULL) AS is_stream_in_history + FROM streams s + + LEFT JOIN stream_state sst + ON s.uid = sst.stream_id + + LEFT JOIN stream_history sh + ON s.uid = sh.stream_id + + INNER JOIN feed f + ON s.uid = f.stream_id + + WHERE ( + sh.stream_id IS NULL + OR s.stream_type = 'LIVE_STREAM' + OR s.stream_type = 'AUDIO_LIVE_STREAM' + ) + + ORDER BY s.upload_date IS NULL DESC, s.upload_date DESC, s.uploader ASC + LIMIT 500 + """ + ) + abstract fun getLiveOrNotPlayedStreams(): Flowable> + + @Query( + """ + SELECT s.*, sst.progress_time, (sh.stream_id IS NOT NULL) AS is_stream_in_history + FROM streams s + + LEFT JOIN stream_state sst + ON s.uid = sst.stream_id + + LEFT JOIN stream_history sh + ON s.uid = sh.stream_id + + INNER JOIN feed f + ON s.uid = f.stream_id + + INNER JOIN feed_group_subscription_join fgs + ON fgs.subscription_id = f.subscription_id + + WHERE fgs.group_id = :groupId + AND ( + sh.stream_id IS NULL + OR s.stream_type = 'LIVE_STREAM' + OR s.stream_type = 'AUDIO_LIVE_STREAM' + ) + + ORDER BY s.upload_date IS NULL DESC, s.upload_date DESC, s.uploader ASC + LIMIT 500 + """ + ) + abstract fun getLiveOrNotPlayedStreamsForGroup(groupId: Long): Flowable> @Query( """ diff --git a/app/src/main/java/org/schabi/newpipe/database/stream/StreamWithState.kt b/app/src/main/java/org/schabi/newpipe/database/stream/StreamWithState.kt new file mode 100644 index 000000000..40c786246 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/database/stream/StreamWithState.kt @@ -0,0 +1,17 @@ +package org.schabi.newpipe.database.stream + +import androidx.room.ColumnInfo +import androidx.room.Embedded +import org.schabi.newpipe.database.stream.model.StreamEntity +import org.schabi.newpipe.database.stream.model.StreamStateEntity + +data class StreamWithState( + @Embedded + val stream: StreamEntity, + + @ColumnInfo(name = StreamStateEntity.STREAM_PROGRESS_TIME) + val stateProgressTime: Long?, + + @ColumnInfo(name = "is_stream_in_history") + val isInHistory: Boolean = false +) 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 9a4832c81..ff7c2848e 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 @@ -12,6 +12,7 @@ import org.schabi.newpipe.NewPipeDatabase import org.schabi.newpipe.database.feed.model.FeedEntity import org.schabi.newpipe.database.feed.model.FeedGroupEntity import org.schabi.newpipe.database.feed.model.FeedLastUpdatedEntity +import org.schabi.newpipe.database.stream.StreamWithState import org.schabi.newpipe.database.stream.model.StreamEntity import org.schabi.newpipe.extractor.stream.StreamInfoItem import org.schabi.newpipe.extractor.stream.StreamType @@ -38,16 +39,19 @@ class FeedDatabaseManager(context: Context) { fun database() = database - fun asStreamItems(groupId: Long = FeedGroupEntity.GROUP_ALL_ID): Flowable> { - val streams = when (groupId) { - FeedGroupEntity.GROUP_ALL_ID -> feedTable.getAllStreams() - else -> feedTable.getAllStreamsFromGroup(groupId) - } - - return streams.map { - val items = ArrayList(it.size) - it.mapTo(items) { stream -> stream.toStreamInfoItem() } - return@map items + fun getStreams( + groupId: Long = FeedGroupEntity.GROUP_ALL_ID, + getPlayedStreams: Boolean = true + ): Flowable> { + return when (groupId) { + FeedGroupEntity.GROUP_ALL_ID -> { + if (getPlayedStreams) feedTable.getAllStreams() + else feedTable.getLiveOrNotPlayedStreams() + } + else -> { + if (getPlayedStreams) feedTable.getAllStreamsForGroup(groupId) + else feedTable.getLiveOrNotPlayedStreamsForGroup(groupId) + } } } @@ -60,8 +64,10 @@ class FeedDatabaseManager(context: Context) { } } - fun outdatedSubscriptionsForGroup(groupId: Long = FeedGroupEntity.GROUP_ALL_ID, outdatedThreshold: OffsetDateTime) = - feedTable.getAllOutdatedForGroup(groupId, outdatedThreshold) + fun outdatedSubscriptionsForGroup( + groupId: Long = FeedGroupEntity.GROUP_ALL_ID, + outdatedThreshold: OffsetDateTime + ) = feedTable.getAllOutdatedForGroup(groupId, outdatedThreshold) fun markAsOutdated(subscriptionId: Long) = feedTable .setLastUpdatedForSubscription(FeedLastUpdatedEntity(subscriptionId, null)) @@ -93,10 +99,7 @@ class FeedDatabaseManager(context: Context) { } feedTable.setLastUpdatedForSubscription( - FeedLastUpdatedEntity( - subscriptionId, - OffsetDateTime.now(ZoneOffset.UTC) - ) + FeedLastUpdatedEntity(subscriptionId, OffsetDateTime.now(ZoneOffset.UTC)) ) } @@ -108,7 +111,12 @@ class FeedDatabaseManager(context: Context) { fun clear() { feedTable.deleteAll() val deletedOrphans = streamTable.deleteOrphans() - if (DEBUG) Log.d(this::class.java.simpleName, "clear() → streamTable.deleteOrphans() → $deletedOrphans") + if (DEBUG) { + Log.d( + this::class.java.simpleName, + "clear() → streamTable.deleteOrphans() → $deletedOrphans" + ) + } } // ///////////////////////////////////////////////////////////////////////// @@ -122,7 +130,8 @@ class FeedDatabaseManager(context: Context) { } fun updateSubscriptionsForGroup(groupId: Long, subscriptionIds: List): Completable { - return Completable.fromCallable { feedGroupTable.updateSubscriptionsForGroup(groupId, subscriptionIds) } + return Completable + .fromCallable { feedGroupTable.updateSubscriptionsForGroup(groupId, subscriptionIds) } .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) } 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 0d7a9a11f..d6d75fec5 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 @@ -19,7 +19,10 @@ package org.schabi.newpipe.local.feed +import android.annotation.SuppressLint +import android.app.Activity import android.content.Intent +import android.content.res.Configuration import android.os.Bundle import android.os.Parcelable import android.view.LayoutInflater @@ -30,11 +33,18 @@ import android.view.View import android.view.ViewGroup import androidx.annotation.Nullable import androidx.appcompat.app.AlertDialog +import androidx.appcompat.content.res.AppCompatResources import androidx.core.content.edit import androidx.core.os.bundleOf import androidx.core.view.isVisible import androidx.lifecycle.ViewModelProvider import androidx.preference.PreferenceManager +import androidx.recyclerview.widget.GridLayoutManager +import com.xwray.groupie.GroupAdapter +import com.xwray.groupie.GroupieViewHolder +import com.xwray.groupie.Item +import com.xwray.groupie.OnItemClickListener +import com.xwray.groupie.OnItemLongClickListener import icepick.State import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.core.Single @@ -49,33 +59,43 @@ import org.schabi.newpipe.error.ErrorInfo import org.schabi.newpipe.error.UserAction import org.schabi.newpipe.extractor.exceptions.AccountTerminatedException import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException +import org.schabi.newpipe.extractor.stream.StreamInfoItem +import org.schabi.newpipe.extractor.stream.StreamType import org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty -import org.schabi.newpipe.fragments.list.BaseListFragment +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.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.Localization +import org.schabi.newpipe.util.NavigationHelper +import org.schabi.newpipe.util.StreamDialogEntry import java.time.OffsetDateTime +import java.util.ArrayList +import kotlin.math.floor +import kotlin.math.max -class FeedFragment : BaseListFragment() { +class FeedFragment : BaseStateFragment() { private var _feedBinding: FragmentFeedBinding? = null private val feedBinding get() = _feedBinding!! private val disposables = CompositeDisposable() private lateinit var viewModel: FeedViewModel - @State - @JvmField - var listState: Parcelable? = null + @State @JvmField var listState: Parcelable? = null private var groupId = FeedGroupEntity.GROUP_ALL_ID private var groupName = "" private var oldestSubscriptionUpdate: OffsetDateTime? = null + private lateinit var groupAdapter: GroupAdapter + @State @JvmField var showPlayedItems: Boolean = true + init { setHasOptionsMenu(true) - setUseDefaultStateSaving(false) } override fun onCreate(savedInstanceState: Bundle?) { @@ -95,8 +115,22 @@ class FeedFragment : BaseListFragment() { _feedBinding = FragmentFeedBinding.bind(rootView) super.onViewCreated(rootView, savedInstanceState) - viewModel = ViewModelProvider(this, FeedViewModel.Factory(requireContext(), groupId)).get(FeedViewModel::class.java) - viewModel.stateLiveData.observe(viewLifecycleOwner) { it?.let(::handleResult) } + val factory = FeedViewModel.Factory(requireContext(), groupId, showPlayedItems) + viewModel = ViewModelProvider(this, factory).get(FeedViewModel::class.java) + viewModel.stateLiveData.observe(viewLifecycleOwner, { it?.let(::handleResult) }) + + groupAdapter = GroupAdapter().apply { + setOnItemClickListener(listenerStreamItem) + setOnItemLongClickListener(listenerStreamItem) + spanCount = if (shouldUseGridLayout()) getGridSpanCount() else 1 + } + + feedBinding.itemsList.apply { + layoutManager = GridLayoutManager(requireContext(), groupAdapter.spanCount).apply { + spanSizeLookup = groupAdapter.spanSizeLookup + } + adapter = groupAdapter + } } override fun onPause() { @@ -129,13 +163,18 @@ class FeedFragment : BaseListFragment() { override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { super.onCreateOptionsMenu(menu, inflater) + + activity.supportActionBar?.setDisplayShowTitleEnabled(true) activity.supportActionBar?.setTitle(R.string.fragment_feed_title) activity.supportActionBar?.subtitle = groupName inflater.inflate(R.menu.menu_feed_fragment, menu) - if (useAsFrontPage) { - menu.findItem(R.id.menu_item_feed_help).setShowAsAction(MenuItem.SHOW_AS_ACTION_NEVER) + menu.findItem(R.id.menu_item_feed_toggle_played_items).apply { + updateTogglePlayedItemsButton(this) + if (useAsFrontPage) { + setShowAsAction(MenuItem.SHOW_AS_ACTION_NEVER) + } } } @@ -143,7 +182,8 @@ class FeedFragment : BaseListFragment() { if (item.itemId == R.id.menu_item_feed_help) { val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(requireContext()) - val usingDedicatedMethod = sharedPreferences.getBoolean(getString(R.string.feed_use_dedicated_fetch_method_key), false) + val usingDedicatedMethod = sharedPreferences + .getBoolean(getString(R.string.feed_use_dedicated_fetch_method_key), false) val enableDisableButtonText = when { usingDedicatedMethod -> R.string.feed_use_dedicated_fetch_method_disable_button else -> R.string.feed_use_dedicated_fetch_method_enable_button @@ -160,6 +200,10 @@ class FeedFragment : BaseListFragment() { .create() .show() return true + } else if (item.itemId == R.id.menu_item_feed_toggle_played_items) { + showPlayedItems = !item.isChecked + updateTogglePlayedItemsButton(item) + viewModel.togglePlayedItems(showPlayedItems) } return super.onOptionsItemSelected(item) @@ -177,13 +221,22 @@ class FeedFragment : BaseListFragment() { } override fun onDestroyView() { + feedBinding.itemsList.adapter = null _feedBinding = null super.onDestroyView() } - // ///////////////////////////////////////////////////////////////////////// + private fun updateTogglePlayedItemsButton(menuItem: MenuItem) { + menuItem.isChecked = showPlayedItems + menuItem.icon = AppCompatResources.getDrawable( + requireContext(), + if (showPlayedItems) R.drawable.ic_visibility_on else R.drawable.ic_visibility_off + ) + } + + // ////////////////////////////////////////////////////////////////////////// // Handling - // ///////////////////////////////////////////////////////////////////////// + // ////////////////////////////////////////////////////////////////////////// override fun showLoading() { super.showLoading() @@ -195,6 +248,7 @@ class FeedFragment : BaseListFragment() { override fun hideLoading() { super.hideLoading() + feedBinding.itemsList.animate(true, 0) feedBinding.refreshRootView.animate(true, 200) feedBinding.loadingProgressText.animate(false, 0) feedBinding.swipeRefreshLayout.isRefreshing = false @@ -220,7 +274,6 @@ class FeedFragment : BaseListFragment() { override fun handleError() { super.handleError() - infoListAdapter.clearStreamItemList() feedBinding.itemsList.animateHideRecyclerViewAllowingScrolling() feedBinding.refreshRootView.animate(false, 0) feedBinding.loadingProgressText.animate(false, 0) @@ -248,8 +301,71 @@ class FeedFragment : BaseListFragment() { feedBinding.loadingProgressBar.max = progressState.maxProgress } + private fun showStreamDialog(item: StreamInfoItem) { + val context = context + val activity: Activity? = getActivity() + if (context == null || context.resources == null || activity == null) return + + val entries = ArrayList() + if (PlayerHolder.getType() != null) { + entries.add(StreamDialogEntry.enqueue) + } + if (item.streamType == StreamType.AUDIO_STREAM) { + entries.addAll( + listOf( + StreamDialogEntry.start_here_on_background, + StreamDialogEntry.append_playlist, + StreamDialogEntry.share + ) + ) + } else { + entries.addAll( + listOf( + StreamDialogEntry.start_here_on_background, + StreamDialogEntry.start_here_on_popup, + StreamDialogEntry.append_playlist, + StreamDialogEntry.share + ) + ) + } + + InfoItemDialog(activity, item, StreamDialogEntry.getCommands(context)) { _, which -> + StreamDialogEntry.clickOn(which, this, item) + }.show() + } + + private val listenerStreamItem = object : OnItemClickListener, OnItemLongClickListener { + override fun onItemClick(item: Item<*>, view: View) { + if (item is StreamItem) { + val stream = item.streamWithState.stream + NavigationHelper.openVideoDetailFragment( + requireContext(), fm, + stream.serviceId, stream.url, stream.title, null, false + ) + } + } + + override fun onItemLongClick(item: Item<*>, view: View): Boolean { + if (item is StreamItem) { + showStreamDialog(item.streamWithState.stream.toStreamInfoItem()) + return true + } + return false + } + } + + @SuppressLint("StringFormatMatches") private fun handleLoadedState(loadedState: FeedState.LoadedState) { - infoListAdapter.setInfoItemList(loadedState.items) + + val itemVersion = if (shouldUseGridLayout()) { + StreamItem.ItemVersion.GRID + } else { + StreamItem.ItemVersion.NORMAL + } + loadedState.items.forEach { it.itemVersion = itemVersion } + + groupAdapter.updateAsync(loadedState.items, false, null) + listState?.run { feedBinding.itemsList.layoutManager?.onRestoreInstanceState(listState) listState = null @@ -357,7 +473,10 @@ class FeedFragment : BaseListFragment() { private fun updateRelativeTimeViews() { updateRefreshViewState() - infoListAdapter.notifyDataSetChanged() + groupAdapter.notifyItemRangeChanged( + 0, groupAdapter.itemCount, + StreamItem.UPDATE_RELATIVE_TIME + ) } private fun updateRefreshViewState() { @@ -372,8 +491,6 @@ class FeedFragment : BaseListFragment() { // ///////////////////////////////////////////////////////////////////////// override fun doInitialLoadLogic() {} - override fun loadMoreItems() {} - override fun hasMoreItems() = false override fun reloadContent() { getActivity()?.startService( @@ -384,6 +501,35 @@ class FeedFragment : BaseListFragment() { listState = null } + // ///////////////////////////////////////////////////////////////////////// + // Grid Mode + // ///////////////////////////////////////////////////////////////////////// + + // TODO: Move these out of this class, as it can be reused + + private fun shouldUseGridLayout(): Boolean { + val listMode = PreferenceManager.getDefaultSharedPreferences(requireContext()) + .getString(getString(R.string.list_view_mode_key), getString(R.string.list_view_mode_value)) + + return when (listMode) { + getString(R.string.list_view_mode_auto_key) -> { + val configuration = resources.configuration + + ( + configuration.orientation == Configuration.ORIENTATION_LANDSCAPE && + configuration.isLayoutSizeAtLeast(Configuration.SCREENLAYOUT_SIZE_LARGE) + ) + } + getString(R.string.list_view_mode_grid_key) -> true + else -> false + } + } + + private fun getGridSpanCount(): Int { + val minWidth = resources.getDimensionPixelSize(R.dimen.video_item_grid_thumbnail_image_width) + return max(1, floor(resources.displayMetrics.widthPixels / minWidth.toDouble()).toInt()) + } + companion object { const val KEY_GROUP_ID = "ARG_GROUP_ID" const val KEY_GROUP_NAME = "ARG_GROUP_NAME" diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/FeedState.kt b/app/src/main/java/org/schabi/newpipe/local/feed/FeedState.kt index dec2773e1..27613e83e 100644 --- a/app/src/main/java/org/schabi/newpipe/local/feed/FeedState.kt +++ b/app/src/main/java/org/schabi/newpipe/local/feed/FeedState.kt @@ -1,7 +1,7 @@ package org.schabi.newpipe.local.feed import androidx.annotation.StringRes -import org.schabi.newpipe.extractor.stream.StreamInfoItem +import org.schabi.newpipe.local.feed.item.StreamItem import java.time.OffsetDateTime sealed class FeedState { @@ -12,7 +12,7 @@ sealed class FeedState { ) : FeedState() data class LoadedState( - val items: List, + val items: List, val oldestUpdate: OffsetDateTime? = null, val notLoadedCount: Long, val itemsErrors: List = emptyList() 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 e516cdaca..8bdf412b5 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 @@ -8,9 +8,11 @@ import androidx.lifecycle.ViewModelProvider import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.core.Flowable import io.reactivex.rxjava3.functions.Function4 +import io.reactivex.rxjava3.processors.BehaviorProcessor import io.reactivex.rxjava3.schedulers.Schedulers import org.schabi.newpipe.database.feed.model.FeedGroupEntity -import org.schabi.newpipe.extractor.stream.StreamInfoItem +import org.schabi.newpipe.database.stream.StreamWithState +import org.schabi.newpipe.local.feed.item.StreamItem import org.schabi.newpipe.local.feed.service.FeedEventManager import org.schabi.newpipe.local.feed.service.FeedEventManager.Event.ErrorResultEvent import org.schabi.newpipe.local.feed.service.FeedEventManager.Event.IdleEvent @@ -20,26 +22,33 @@ import org.schabi.newpipe.util.DEFAULT_THROTTLE_TIMEOUT import java.time.OffsetDateTime import java.util.concurrent.TimeUnit -class FeedViewModel(applicationContext: Context, val groupId: Long = FeedGroupEntity.GROUP_ALL_ID) : ViewModel() { - class Factory(val context: Context, val groupId: Long = FeedGroupEntity.GROUP_ALL_ID) : ViewModelProvider.Factory { - @Suppress("UNCHECKED_CAST") - override fun create(modelClass: Class): T { - return FeedViewModel(context.applicationContext, groupId) as T - } - } - +class FeedViewModel( + applicationContext: Context, + groupId: Long = FeedGroupEntity.GROUP_ALL_ID, + initialShowPlayedItems: Boolean = true +) : ViewModel() { private var feedDatabaseManager: FeedDatabaseManager = FeedDatabaseManager(applicationContext) + private val toggleShowPlayedItems = BehaviorProcessor.create() + private val streamItems = toggleShowPlayedItems + .startWithItem(initialShowPlayedItems) + .distinctUntilChanged() + .switchMap { showPlayedItems -> + feedDatabaseManager.getStreams(groupId, showPlayedItems) + } + private val mutableStateLiveData = MutableLiveData() val stateLiveData: LiveData = mutableStateLiveData private var combineDisposable = Flowable .combineLatest( FeedEventManager.events(), - feedDatabaseManager.asStreamItems(groupId), + streamItems, feedDatabaseManager.notLoadedCount(groupId), feedDatabaseManager.oldestSubscriptionUpdate(groupId), - Function4 { t1: FeedEventManager.Event, t2: List, t3: Long, t4: List -> + + Function4 { t1: FeedEventManager.Event, t2: List, + t3: Long, t4: List -> return@Function4 CombineResultHolder(t1, t2, t3, t4.firstOrNull()) } ) @@ -49,9 +58,9 @@ class FeedViewModel(applicationContext: Context, val groupId: Long = FeedGroupEn .subscribe { (event, listFromDB, notLoadedCount, oldestUpdate) -> mutableStateLiveData.postValue( when (event) { - is IdleEvent -> FeedState.LoadedState(listFromDB, oldestUpdate, notLoadedCount) + is IdleEvent -> FeedState.LoadedState(listFromDB.map { e -> StreamItem(e) }, oldestUpdate, notLoadedCount) is ProgressEvent -> FeedState.ProgressState(event.currentProgress, event.maxProgress, event.progressMessage) - is SuccessResultEvent -> FeedState.LoadedState(listFromDB, oldestUpdate, notLoadedCount, event.itemsErrors) + is SuccessResultEvent -> FeedState.LoadedState(listFromDB.map { e -> StreamItem(e) }, oldestUpdate, notLoadedCount, event.itemsErrors) is ErrorResultEvent -> FeedState.ErrorState(event.error) } ) @@ -66,5 +75,20 @@ class FeedViewModel(applicationContext: Context, val groupId: Long = FeedGroupEn combineDisposable.dispose() } - private data class CombineResultHolder(val t1: FeedEventManager.Event, val t2: List, val t3: Long, val t4: OffsetDateTime?) + private data class CombineResultHolder(val t1: FeedEventManager.Event, val t2: List, val t3: Long, val t4: OffsetDateTime?) + + fun togglePlayedItems(showPlayedItems: Boolean) { + toggleShowPlayedItems.onNext(showPlayedItems) + } + + class Factory( + private val context: Context, + private val groupId: Long = FeedGroupEntity.GROUP_ALL_ID, + private val showPlayedItems: Boolean + ) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T { + return FeedViewModel(context.applicationContext, groupId, showPlayedItems) as T + } + } } 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 new file mode 100644 index 000000000..88c5d809d --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/local/feed/item/StreamItem.kt @@ -0,0 +1,157 @@ +package org.schabi.newpipe.local.feed.item + +import android.content.Context +import android.text.TextUtils +import android.view.View +import androidx.core.content.ContextCompat +import androidx.preference.PreferenceManager +import com.nostra13.universalimageloader.core.ImageLoader +import com.xwray.groupie.viewbinding.BindableItem +import org.schabi.newpipe.MainActivity +import org.schabi.newpipe.R +import org.schabi.newpipe.database.stream.StreamWithState +import org.schabi.newpipe.database.stream.model.StreamEntity +import org.schabi.newpipe.databinding.ListStreamItemBinding +import org.schabi.newpipe.extractor.stream.StreamType.AUDIO_LIVE_STREAM +import org.schabi.newpipe.extractor.stream.StreamType.AUDIO_STREAM +import org.schabi.newpipe.extractor.stream.StreamType.LIVE_STREAM +import org.schabi.newpipe.extractor.stream.StreamType.VIDEO_STREAM +import org.schabi.newpipe.util.ImageDisplayConstants +import org.schabi.newpipe.util.Localization +import java.util.concurrent.TimeUnit + +data class StreamItem( + val streamWithState: StreamWithState, + var itemVersion: ItemVersion = ItemVersion.NORMAL +) : BindableItem() { + companion object { + const val UPDATE_RELATIVE_TIME = 1 + } + + private val stream: StreamEntity = streamWithState.stream + private val stateProgressTime: Long? = streamWithState.stateProgressTime + private val isInHistory: Boolean = streamWithState.isInHistory + + override fun getId(): Long = stream.uid + + enum class ItemVersion { NORMAL, MINI, GRID } + + override fun getLayout(): Int = when (itemVersion) { + ItemVersion.NORMAL -> R.layout.list_stream_item + ItemVersion.MINI -> R.layout.list_stream_mini_item + ItemVersion.GRID -> R.layout.list_stream_grid_item + } + + override fun initializeViewBinding(view: View) = ListStreamItemBinding.bind(view) + + override fun bind(viewBinding: ListStreamItemBinding, position: Int, payloads: MutableList) { + if (payloads.contains(UPDATE_RELATIVE_TIME)) { + if (itemVersion != ItemVersion.MINI) { + viewBinding.itemAdditionalDetails.text = + getStreamInfoDetailLine(viewBinding.itemAdditionalDetails.context) + } + return + } + + super.bind(viewBinding, position, payloads) + } + + override fun bind(viewBinding: ListStreamItemBinding, position: Int) { + viewBinding.itemVideoTitleView.text = stream.title + viewBinding.itemUploaderView.text = stream.uploader + + val isLiveStream = stream.streamType == LIVE_STREAM || stream.streamType == AUDIO_LIVE_STREAM + + if (stream.duration > 0) { + viewBinding.itemDurationView.text = Localization.getDurationString(stream.duration) + viewBinding.itemDurationView.setBackgroundColor( + ContextCompat.getColor( + viewBinding.itemDurationView.context, + R.color.duration_background_color + ) + ) + viewBinding.itemDurationView.visibility = View.VISIBLE + + if (stateProgressTime != null) { + viewBinding.itemProgressView.visibility = View.VISIBLE + viewBinding.itemProgressView.max = stream.duration.toInt() + viewBinding.itemProgressView.progress = TimeUnit.MILLISECONDS.toSeconds(stateProgressTime).toInt() + } else { + viewBinding.itemProgressView.visibility = View.GONE + } + } else if (isLiveStream) { + viewBinding.itemDurationView.setText(R.string.duration_live) + viewBinding.itemDurationView.setBackgroundColor( + ContextCompat.getColor( + viewBinding.itemDurationView.context, + R.color.live_duration_background_color + ) + ) + viewBinding.itemDurationView.visibility = View.VISIBLE + viewBinding.itemProgressView.visibility = View.GONE + } else { + viewBinding.itemDurationView.visibility = View.GONE + viewBinding.itemProgressView.visibility = View.GONE + } + + viewBinding.itemInHistoryIndicatorView.visibility = + if (isInHistory && !isLiveStream) View.VISIBLE else View.GONE + + ImageLoader.getInstance().displayImage( + stream.thumbnailUrl, viewBinding.itemThumbnailView, + ImageDisplayConstants.DISPLAY_THUMBNAIL_OPTIONS + ) + + if (itemVersion != ItemVersion.MINI) { + viewBinding.itemAdditionalDetails.text = + getStreamInfoDetailLine(viewBinding.itemAdditionalDetails.context) + } + } + + override fun isLongClickable() = when (stream.streamType) { + AUDIO_STREAM, VIDEO_STREAM, LIVE_STREAM, AUDIO_LIVE_STREAM -> true + else -> false + } + + private fun getStreamInfoDetailLine(context: Context): String { + var viewsAndDate = "" + val viewCount = stream.viewCount + if (viewCount != null && viewCount >= 0) { + viewsAndDate = when (stream.streamType) { + AUDIO_LIVE_STREAM -> Localization.listeningCount(context, viewCount) + LIVE_STREAM -> Localization.shortWatchingCount(context, viewCount) + else -> Localization.shortViewCount(context, viewCount) + } + } + val uploadDate = getFormattedRelativeUploadDate(context) + return when { + !TextUtils.isEmpty(uploadDate) -> when { + viewsAndDate.isEmpty() -> uploadDate!! + else -> Localization.concatenateStrings(viewsAndDate, uploadDate) + } + else -> viewsAndDate + } + } + + private fun getFormattedRelativeUploadDate(context: Context): String? { + val uploadDate = stream.uploadDate + return if (uploadDate != null) { + var formattedRelativeTime = Localization.relativeTime(uploadDate) + + if (MainActivity.DEBUG) { + val key = context.getString(R.string.show_original_time_ago_key) + if (PreferenceManager.getDefaultSharedPreferences(context).getBoolean(key, false)) { + formattedRelativeTime += " (" + stream.textualUploadDate + ")" + } + } + + formattedRelativeTime + } else { + stream.textualUploadDate + } + } + + override fun getSpanSize(spanCount: Int, position: Int): Int { + return if (itemVersion == ItemVersion.GRID) 1 else spanCount + } +} diff --git a/app/src/main/res/drawable-night/ic_visibility_off.xml b/app/src/main/res/drawable-night/ic_visibility_off.xml new file mode 100644 index 000000000..689f3f47c --- /dev/null +++ b/app/src/main/res/drawable-night/ic_visibility_off.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable-night/ic_visibility_on.xml b/app/src/main/res/drawable-night/ic_visibility_on.xml new file mode 100644 index 000000000..e02f1d191 --- /dev/null +++ b/app/src/main/res/drawable-night/ic_visibility_on.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_visibility_off.xml b/app/src/main/res/drawable/ic_visibility_off.xml new file mode 100644 index 000000000..e0b170300 --- /dev/null +++ b/app/src/main/res/drawable/ic_visibility_off.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_visibility_on.xml b/app/src/main/res/drawable/ic_visibility_on.xml new file mode 100644 index 000000000..6c95a5d29 --- /dev/null +++ b/app/src/main/res/drawable/ic_visibility_on.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/item_in_history_indicator_background.xml b/app/src/main/res/drawable/item_in_history_indicator_background.xml new file mode 100644 index 000000000..1c3a9a56b --- /dev/null +++ b/app/src/main/res/drawable/item_in_history_indicator_background.xml @@ -0,0 +1,7 @@ + + + + diff --git a/app/src/main/res/layout/list_stream_grid_item.xml b/app/src/main/res/layout/list_stream_grid_item.xml index 4e3d6edfb..1a65b41a1 100644 --- a/app/src/main/res/layout/list_stream_grid_item.xml +++ b/app/src/main/res/layout/list_stream_grid_item.xml @@ -20,6 +20,32 @@ android:src="@drawable/dummy_thumbnail" tools:ignore="RtlHardcoded" /> + + + + + + + + + + + + + app:showAsAction="never" /> diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 1a1b3a9bb..a35590b6a 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -56,6 +56,7 @@ #EEFFFFFF #ffffff #64000000 + #E6FFFFFF #323232 #ffffff diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml index b55ad781c..51da9b299 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -20,6 +20,7 @@ 11sp 12sp 16sp + 12sp 124dp @@ -52,6 +53,8 @@ 40dp 200dp + 2dp + 8dp 180dp 150dp diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 756e12eaf..f26320800 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -228,6 +228,7 @@ Delete entire search history? Search history deleted. Help + In History Error External storage unavailable @@ -702,7 +703,7 @@ Enable fast mode Disable fast mode Do you think feed loading is too slow? If so, try enabling fast loading (you can change it in settings or by pressing the button below).\n\nNewPipe offers two feed loading strategies:\n• Fetching the whole subscription channel, which is slow but complete.\n• Using a dedicated service endpoint, which is fast but usually not complete.\n\nThe difference between the two is that the fast one usually lacks some information, like the item\'s duration or type (can\'t distinguish between live videos and normal ones) and it may return less items.\n\nYouTube is an example of a service that offers this fast method with its RSS feed.\n\nSo the choice boils down to what you prefer: speed or precise information. - + Show played items This content is not yet supported by NewPipe.\n\nIt will hopefully be supported in a future version. Channel\'s avatar thumbnail Created by %s