Merge pull request #7169 from Bnyro/master

refactor: move watch history logic to view model
This commit is contained in:
Bnyro 2025-03-05 15:06:22 +01:00 committed by GitHub
commit 70bd87a002
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 201 additions and 171 deletions

View File

@ -90,12 +90,10 @@ object DatabaseHelper {
}
suspend fun filterByWatchStatus(
streams: List<WatchHistoryItem>,
watchHistoryItem: WatchHistoryItem,
unfinished: Boolean = true
): List<WatchHistoryItem> {
return streams.filter {
unfinished xor isVideoWatched(it.videoId, it.duration ?: 0)
}
): Boolean {
return unfinished xor isVideoWatched(watchHistoryItem.videoId, watchHistoryItem.duration ?: 0)
}
fun filterByStatusAndWatchPosition(

View File

@ -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<WatchHistoryItem, WatchHistoryViewHolder>(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<WatchHistoryItem>) {
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)

View File

@ -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<WatchHistoryItem>) {
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<WatchHistoryItem>.filterByStatusAndWatchPosition(): List<WatchHistoryItem> {
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
}
}

View File

@ -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<List<WatchHistoryItem>>()
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
}
}

View File

@ -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<Int, WatchHistoryItem>() {
override fun getRefreshKey(state: PagingState<Int, WatchHistoryItem>) = null
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, WatchHistoryItem> {
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)
}
}