diff --git a/app/src/main/java/com/github/libretube/db/DatabaseHelper.kt b/app/src/main/java/com/github/libretube/db/DatabaseHelper.kt index 20704325a..59470c3f6 100644 --- a/app/src/main/java/com/github/libretube/db/DatabaseHelper.kt +++ b/app/src/main/java/com/github/libretube/db/DatabaseHelper.kt @@ -90,12 +90,10 @@ object DatabaseHelper { } suspend fun filterByWatchStatus( - streams: List, + watchHistoryItem: WatchHistoryItem, unfinished: Boolean = true - ): List { - return streams.filter { - unfinished xor isVideoWatched(it.videoId, it.duration ?: 0) - } + ): Boolean { + return unfinished xor isVideoWatched(watchHistoryItem.videoId, watchHistoryItem.duration ?: 0) } fun filterByStatusAndWatchPosition( diff --git a/app/src/main/java/com/github/libretube/ui/adapters/WatchHistoryAdapter.kt b/app/src/main/java/com/github/libretube/ui/adapters/WatchHistoryAdapter.kt index 34c7bc069..84c05dceb 100644 --- a/app/src/main/java/com/github/libretube/ui/adapters/WatchHistoryAdapter.kt +++ b/app/src/main/java/com/github/libretube/ui/adapters/WatchHistoryAdapter.kt @@ -22,30 +22,11 @@ import com.github.libretube.util.TextUtils import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext class WatchHistoryAdapter : ListAdapter(DiffUtilItemCallback()) { - fun removeFromWatchHistory(position: Int) { - val history = getItem(position) - runBlocking(Dispatchers.IO) { - DatabaseHolder.Database.watchHistoryDao().delete(history) - } - val updatedList = currentList.toMutableList().also { - it.removeAt(position) - } - submitList(updatedList) - } - - fun insertItems(items: List) { - val updatedList = currentList.toMutableList().also { - it.addAll(items) - } - submitList(updatedList) - } - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): WatchHistoryViewHolder { val layoutInflater = LayoutInflater.from(parent.context) val binding = VideoRowBinding.inflate(layoutInflater, parent, false) diff --git a/app/src/main/java/com/github/libretube/ui/fragments/WatchHistoryFragment.kt b/app/src/main/java/com/github/libretube/ui/fragments/WatchHistoryFragment.kt index ed7b9d081..4973b22ca 100644 --- a/app/src/main/java/com/github/libretube/ui/fragments/WatchHistoryFragment.kt +++ b/app/src/main/java/com/github/libretube/ui/fragments/WatchHistoryFragment.kt @@ -2,23 +2,19 @@ package com.github.libretube.ui.fragments import android.content.res.Configuration import android.os.Bundle -import android.os.Handler -import android.os.Looper import android.os.Parcelable import android.view.View -import androidx.core.os.postDelayed import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.core.view.updatePadding import androidx.fragment.app.activityViewModels +import androidx.fragment.app.viewModels import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.RecyclerView import androidx.room.withTransaction import com.github.libretube.R -import com.github.libretube.constants.PreferenceKeys import com.github.libretube.databinding.FragmentWatchHistoryBinding -import com.github.libretube.db.DatabaseHelper import com.github.libretube.db.DatabaseHolder.Database import com.github.libretube.db.obj.WatchHistoryItem import com.github.libretube.extensions.ceilHalf @@ -26,49 +22,28 @@ import com.github.libretube.extensions.dpToPx import com.github.libretube.extensions.setOnDismissListener import com.github.libretube.helpers.NavBarHelper import com.github.libretube.helpers.NavigationHelper -import com.github.libretube.helpers.PreferenceHelper import com.github.libretube.ui.adapters.WatchHistoryAdapter import com.github.libretube.ui.base.DynamicLayoutManagerFragment import com.github.libretube.ui.extensions.addOnBottomReachedListener import com.github.libretube.ui.extensions.setupFragmentAnimation import com.github.libretube.ui.models.CommonPlayerViewModel +import com.github.libretube.ui.models.WatchHistoryModel import com.github.libretube.ui.sheets.BaseBottomSheet import com.github.libretube.util.PlayingQueue import com.google.android.material.dialog.MaterialAlertDialogBuilder import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.withContext -import kotlin.math.ceil class WatchHistoryFragment : DynamicLayoutManagerFragment(R.layout.fragment_watch_history) { private var _binding: FragmentWatchHistoryBinding? = null private val binding get() = _binding!! - private val handler = Handler(Looper.getMainLooper()) private val commonPlayerViewModel: CommonPlayerViewModel by activityViewModels() - private var isLoading = false private var recyclerViewState: Parcelable? = null + private val viewModel: WatchHistoryModel by viewModels() private val watchHistoryAdapter = WatchHistoryAdapter() - private var selectedStatusFilter = PreferenceHelper.getInt( - PreferenceKeys.SELECTED_HISTORY_STATUS_FILTER, - 0 - ) - set(value) { - PreferenceHelper.putInt(PreferenceKeys.SELECTED_HISTORY_STATUS_FILTER, value) - field = value - } - private var selectedTypeFilter = PreferenceHelper.getInt( - PreferenceKeys.SELECTED_HISTORY_TYPE_FILTER, - 0 - ) - set(value) { - PreferenceHelper.putInt(PreferenceKeys.SELECTED_HISTORY_TYPE_FILTER, value) - field = value - } - override fun setLayoutManagers(gridItems: Int) { _binding?.watchHistoryRecView?.layoutManager = GridLayoutManager(context, gridItems.ceilHalf()) @@ -83,7 +58,8 @@ class WatchHistoryFragment : DynamicLayoutManagerFragment(R.layout.fragment_watc } binding.watchHistoryRecView.setOnDismissListener { position -> - watchHistoryAdapter.removeFromWatchHistory(position) + val item = viewModel.filteredWatchHistory.value?.getOrNull(position) ?: return@setOnDismissListener + viewModel.removeFromHistory(item) } // observe changes to indicate if the history is empty @@ -107,66 +83,81 @@ class WatchHistoryFragment : DynamicLayoutManagerFragment(R.layout.fragment_watc } }) - lifecycleScope.launch { - val history = withContext(Dispatchers.IO) { - DatabaseHelper.getWatchHistoryPage(1, HISTORY_PAGE_SIZE) - } + binding.filterTypeTV.text = + resources.getStringArray(R.array.filterOptions)[viewModel.selectedTypeFilter] + binding.filterStatusTV.text = + resources.getStringArray(R.array.filterStatusOptions)[viewModel.selectedStatusFilter] - if (history.isEmpty()) return@launch + val watchPositionItem = arrayOf(getString(R.string.also_clear_watch_positions)) + val selected = booleanArrayOf(false) - binding.filterTypeTV.text = - resources.getStringArray(R.array.filterOptions)[selectedTypeFilter] - binding.filterStatusTV.text = - resources.getStringArray(R.array.filterStatusOptions)[selectedStatusFilter] - - val watchPositionItem = arrayOf(getString(R.string.also_clear_watch_positions)) - val selected = booleanArrayOf(false) - - binding.clear.setOnClickListener { - MaterialAlertDialogBuilder(requireContext()) - .setTitle(R.string.clear_history) - .setMultiChoiceItems(watchPositionItem, selected) { _, index, newValue -> - selected[index] = newValue - } - .setPositiveButton(R.string.okay) { _, _ -> - binding.historyContainer.isGone = true - binding.historyEmpty.isVisible = true - lifecycleScope.launch(Dispatchers.IO) { - Database.withTransaction { - Database.watchHistoryDao().deleteAll() - if (selected[0]) Database.watchPositionDao().deleteAll() - } + binding.clear.setOnClickListener { + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.clear_history) + .setMultiChoiceItems(watchPositionItem, selected) { _, index, newValue -> + selected[index] = newValue + } + .setPositiveButton(R.string.okay) { _, _ -> + binding.historyContainer.isGone = true + binding.historyEmpty.isVisible = true + lifecycleScope.launch(Dispatchers.IO) { + Database.withTransaction { + Database.watchHistoryDao().deleteAll() + if (selected[0]) Database.watchPositionDao().deleteAll() } } - .setNegativeButton(R.string.cancel, null) - .show() - } + } + .setNegativeButton(R.string.cancel, null) + .show() + } - binding.filterTypeTV.setOnClickListener { - val filterOptions = resources.getStringArray(R.array.filterOptions) + binding.filterTypeTV.setOnClickListener { + val filterOptions = resources.getStringArray(R.array.filterOptions) - BaseBottomSheet().apply { - setSimpleItems(filterOptions.toList()) { index -> - binding.filterTypeTV.text = filterOptions[index] - selectedTypeFilter = index - showWatchHistory(history) - } - }.show(childFragmentManager) - } + BaseBottomSheet().apply { + setSimpleItems(filterOptions.toList()) { index -> + binding.filterTypeTV.text = filterOptions[index] + viewModel.selectedTypeFilter = index + } + }.show(childFragmentManager) + } - binding.filterStatusTV.setOnClickListener { - val filterOptions = resources.getStringArray(R.array.filterStatusOptions) + binding.filterStatusTV.setOnClickListener { + val filterOptions = resources.getStringArray(R.array.filterStatusOptions) - BaseBottomSheet().apply { - setSimpleItems(filterOptions.toList()) { index -> - binding.filterStatusTV.text = filterOptions[index] - selectedStatusFilter = index - showWatchHistory(history) - } - }.show(childFragmentManager) - } + BaseBottomSheet().apply { + setSimpleItems(filterOptions.toList()) { index -> + binding.filterStatusTV.text = filterOptions[index] + viewModel.selectedStatusFilter = index + } + }.show(childFragmentManager) + } - showWatchHistory(history) + binding.playAll.setOnClickListener { + val history = viewModel.filteredWatchHistory.value.orEmpty() + if (history.isEmpty()) return@setOnClickListener + + PlayingQueue.add( + *history.reversed().map(WatchHistoryItem::toStreamItem).toTypedArray() + ) + NavigationHelper.navigateVideo( + requireContext(), + history.last().videoId, + keepQueue = true + ) + } + + viewModel.filteredWatchHistory.observe(viewLifecycleOwner) { history -> + binding.historyEmpty.isGone = history.isNotEmpty() + binding.historyContainer.isVisible = history.isNotEmpty() + + watchHistoryAdapter.submitList(history) + } + + viewModel.fetchNextPage() + + binding.watchHistoryRecView.addOnBottomReachedListener { + viewModel.fetchNextPage() } if (NavBarHelper.getStartFragmentId(requireContext()) != R.id.watchHistoryFragment) { @@ -174,69 +165,6 @@ class WatchHistoryFragment : DynamicLayoutManagerFragment(R.layout.fragment_watc } } - private fun showWatchHistory(history: List) { - val watchHistory = history.filterByStatusAndWatchPosition() - - binding.playAll.setOnClickListener { - PlayingQueue.add( - *watchHistory.reversed().map(WatchHistoryItem::toStreamItem).toTypedArray() - ) - NavigationHelper.navigateVideo( - requireContext(), - watchHistory.last().videoId, - keepQueue = true - ) - } - watchHistoryAdapter.submitList(history) - binding.historyEmpty.isGone = true - binding.historyContainer.isVisible = true - - // add a listener for scroll end, delay needed to prevent loading new ones the first time - handler.postDelayed(200) { - if (_binding == null) return@postDelayed - - binding.watchHistoryRecView.addOnBottomReachedListener { - if (isLoading) return@addOnBottomReachedListener - isLoading = true - - lifecycleScope.launch { - val newHistory = withContext(Dispatchers.IO) { - val currentPage = ceil(watchHistoryAdapter.itemCount.toFloat() / HISTORY_PAGE_SIZE).toInt() - DatabaseHelper.getWatchHistoryPage( currentPage + 1, HISTORY_PAGE_SIZE) - }.filterByStatusAndWatchPosition() - - watchHistoryAdapter.insertItems(newHistory) - isLoading = false - } - } - } - } - - private fun List.filterByStatusAndWatchPosition(): List { - val watchHistoryItem = this.filter { - val isLive = (it.duration ?: -1L) < 0L - when (selectedTypeFilter) { - 0 -> true - 1 -> !it.isShort && !isLive - 2 -> it.isShort // where is the StreamItem converted to watchHistoryItem? - 3 -> isLive - else -> throw IllegalArgumentException() - } - } - - if (selectedStatusFilter == 0) { - return watchHistoryItem - } - - return runBlocking { - when (selectedStatusFilter) { - 1 -> DatabaseHelper.filterByWatchStatus(watchHistoryItem) - 2 -> DatabaseHelper.filterByWatchStatus(watchHistoryItem, false) - else -> throw IllegalArgumentException() - } - } - } - override fun onConfigurationChanged(newConfig: Configuration) { super.onConfigurationChanged(newConfig) // manually restore the recyclerview state due to https://github.com/material-components/material-components-android/issues/3473 @@ -247,8 +175,4 @@ class WatchHistoryFragment : DynamicLayoutManagerFragment(R.layout.fragment_watc super.onDestroyView() _binding = null } - - companion object { - private const val HISTORY_PAGE_SIZE = 10 - } } diff --git a/app/src/main/java/com/github/libretube/ui/models/WatchHistoryModel.kt b/app/src/main/java/com/github/libretube/ui/models/WatchHistoryModel.kt new file mode 100644 index 000000000..95cb3a8e4 --- /dev/null +++ b/app/src/main/java/com/github/libretube/ui/models/WatchHistoryModel.kt @@ -0,0 +1,105 @@ +package com.github.libretube.ui.models + +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.asFlow +import androidx.lifecycle.asLiveData +import androidx.lifecycle.viewModelScope +import com.github.libretube.constants.PreferenceKeys +import com.github.libretube.db.DatabaseHelper +import com.github.libretube.db.DatabaseHolder +import com.github.libretube.db.obj.WatchHistoryItem +import com.github.libretube.helpers.PreferenceHelper +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class WatchHistoryModel : ViewModel() { + private val watchHistory = MutableLiveData>() + + private var currentPage = 1 + private var isLoading = false + + private val selectedStatus = MutableStateFlow( + PreferenceHelper.getInt(PreferenceKeys.SELECTED_HISTORY_STATUS_FILTER, 0) + ) + private val selectedType = MutableStateFlow( + PreferenceHelper.getInt(PreferenceKeys.SELECTED_HISTORY_TYPE_FILTER, 0) + ) + + val filteredWatchHistory = + combine(watchHistory.asFlow(), selectedStatus, selectedType) { history, _, _ -> history } + .flowOn(Dispatchers.IO).map { history -> history.filter { it.shouldIncludeByFilters() } } + .asLiveData() + + var selectedStatusFilter + get() = selectedStatus.value + set(value) { + PreferenceHelper.putInt(PreferenceKeys.SELECTED_HISTORY_STATUS_FILTER, value) + selectedStatus.value = value + } + + var selectedTypeFilter + get() = selectedType.value + set(value) { + PreferenceHelper.putInt(PreferenceKeys.SELECTED_HISTORY_TYPE_FILTER, value) + selectedType.value = value + } + + private suspend fun WatchHistoryItem.shouldIncludeByFilters(): Boolean { + val isLive = (duration ?: -1L) < 0L + val matchesFilter = when (selectedTypeFilter) { + 0 -> true + 1 -> !isShort && !isLive + 2 -> isShort // where is the StreamItem converted to watchHistoryItem? + 3 -> isLive + else -> throw IllegalArgumentException() + } + + if (!matchesFilter) return false + + // no watch position filter + if (selectedStatusFilter == 0) return true + + return when (selectedStatusFilter) { + 1 -> DatabaseHelper.filterByWatchStatus(this) + 2 -> DatabaseHelper.filterByWatchStatus(this, false) + else -> throw IllegalArgumentException() + } + } + + fun fetchNextPage() = viewModelScope.launch(Dispatchers.IO) { + if (isLoading) return@launch + isLoading = true + + val newHistory = withContext(Dispatchers.IO) { + DatabaseHelper.getWatchHistoryPage(currentPage, HISTORY_PAGE_SIZE) + } + + isLoading = false + currentPage++ + + watchHistory.postValue( + watchHistory.value.orEmpty().toMutableList().apply { + addAll(newHistory) + } + ) + } + + fun removeFromHistory(watchHistoryItem: WatchHistoryItem) = + viewModelScope.launch(Dispatchers.IO) { + DatabaseHolder.Database.watchHistoryDao().delete(watchHistoryItem) + + watchHistory.postValue( + watchHistory.value.orEmpty().filter { it != watchHistoryItem } + ) + } + + companion object { + private const val HISTORY_PAGE_SIZE = 10 + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/libretube/ui/models/sources/WatchHistoryPagingSource.kt b/app/src/main/java/com/github/libretube/ui/models/sources/WatchHistoryPagingSource.kt new file mode 100644 index 000000000..dccb4237a --- /dev/null +++ b/app/src/main/java/com/github/libretube/ui/models/sources/WatchHistoryPagingSource.kt @@ -0,0 +1,22 @@ +package com.github.libretube.ui.models.sources + +import androidx.paging.PagingSource +import androidx.paging.PagingState +import com.github.libretube.db.DatabaseHelper +import com.github.libretube.db.obj.WatchHistoryItem +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +class WatchHistoryPagingSource( + private val shouldIncludeItemPredicate: suspend (WatchHistoryItem) -> Boolean +): PagingSource() { + override fun getRefreshKey(state: PagingState) = null + + override suspend fun load(params: LoadParams): LoadResult { + val newHistory = withContext(Dispatchers.IO) { + DatabaseHelper.getWatchHistoryPage( params.key ?: 0, params.loadSize) + }.filter { shouldIncludeItemPredicate(it) } + + return LoadResult.Page(newHistory, params.key ?: 0, params.key?.plus(1) ?: 0) + } +} \ No newline at end of file