diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/BaseAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/BaseAdapter.kt index 825087c5..7439bfdf 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/BaseAdapter.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/BaseAdapter.kt @@ -12,6 +12,7 @@ import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView.ViewHolder import androidx.viewbinding.ViewBinding +import java.util.concurrent.CopyOnWriteArrayList open class ViewHolderState(val view: ViewBinding) : ViewHolder(view.root) { open fun save(): T? = null @@ -27,6 +28,8 @@ class StateViewModel : ViewModel() { val layoutManagerStates = hashMapOf>() } +abstract class NoStateAdapter(fragment: Fragment) : BaseAdapter(fragment, 0) + /** * BaseAdapter is a persistent state stored adapter that supports headers and footers. * This should be used for restoring eg scroll or focus related to a view when it is recreated. @@ -83,7 +86,8 @@ abstract class BaseAdapter< ) fun submitList(list: List?) { - mDiffer.submitList(list) + // deep copy at least the top list, because otherwise adapter can go crazy + mDiffer.submitList(list?.let { CopyOnWriteArrayList(it) }) } override fun getItemCount(): Int { diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeParentItemAdapterPreview.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeParentItemAdapterPreview.kt index b3980cd9..52ec06db 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeParentItemAdapterPreview.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeParentItemAdapterPreview.kt @@ -388,7 +388,7 @@ class HomeParentItemAdapterPreview( viewModel.loadMoreHomeScrollResponses() } } - val item = previewAdapter.getItem(position) ?: return + val item = previewAdapter.getItemOrNull(position) ?: return onSelect(item, position) } } @@ -516,6 +516,7 @@ class HomeParentItemAdapterPreview( when (preview) { is Resource.Success -> { previewAdapter.submitList(preview.value.second) + previewAdapter.hasMoreItems = preview.value.first /*if (!.setItems( preview.value.second, preview.value.first diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeScrollAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeScrollAdapter.kt index e50ea954..29186e83 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeScrollAdapter.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeScrollAdapter.kt @@ -8,7 +8,7 @@ import androidx.fragment.app.Fragment import com.lagradost.cloudstream3.LoadResponse import com.lagradost.cloudstream3.databinding.HomeScrollViewBinding import com.lagradost.cloudstream3.databinding.HomeScrollViewTvBinding -import com.lagradost.cloudstream3.ui.BaseAdapter +import com.lagradost.cloudstream3.ui.NoStateAdapter import com.lagradost.cloudstream3.ui.ViewHolderState import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.TV @@ -17,7 +17,7 @@ import com.lagradost.cloudstream3.utils.UIHelper.setImage class HomeScrollAdapter( fragment: Fragment -) : BaseAdapter(fragment, "HomeScrollAdapter".hashCode()) { +) : NoStateAdapter(fragment) { var hasMoreItems: Boolean = false override fun onCreateContent(parent: ViewGroup): ViewHolderState { diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeViewModel.kt index b0d4bdf8..a2c7583f 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeViewModel.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeViewModel.kt @@ -53,6 +53,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.withContext import java.util.EnumSet +import java.util.concurrent.CopyOnWriteArrayList import kotlin.collections.set class HomeViewModel : ViewModel() { @@ -125,7 +126,7 @@ class HomeViewModel : ViewModel() { private val _resumeWatching = MutableLiveData>() private val _preview = MutableLiveData>>>() - private val previewResponses = mutableListOf() + private val previewResponses = CopyOnWriteArrayList() private val previewResponsesAdded = mutableSetOf() val resumeWatching: LiveData> = _resumeWatching @@ -327,7 +328,13 @@ class HomeViewModel : ViewModel() { val filteredList = context?.filterHomePageListByFilmQuality(list) ?: list expandable[list.name] = - ExpandableHomepageList(filteredList, 1, home.hasNext) + ExpandableHomepageList( + filteredList.copy( + list = CopyOnWriteArrayList( + filteredList.list + ) + ), 1, home.hasNext + ) } } @@ -342,8 +349,7 @@ class HomeViewModel : ViewModel() { val currentList = items.shuffled().filter { it.list.isNotEmpty() } .flatMap { it.list } - .distinctBy { it.url } - .toList() + .distinctBy { it.url }.toList() if (currentList.isNotEmpty()) { val randomItems = diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/library/LibraryFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/library/LibraryFragment.kt index 664946b1..90e57ef4 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/library/LibraryFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/library/LibraryFragment.kt @@ -49,12 +49,10 @@ import com.lagradost.cloudstream3.ui.quicksearch.QuickSearchFragment import com.lagradost.cloudstream3.ui.result.txt import com.lagradost.cloudstream3.ui.search.SEARCH_ACTION_LOAD import com.lagradost.cloudstream3.ui.search.SEARCH_ACTION_SHOW_METADATA -import com.lagradost.cloudstream3.ui.settings.Globals import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.PHONE import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.isLayout -import com.lagradost.cloudstream3.ui.settings.SettingsFragment import com.lagradost.cloudstream3.utils.AppUtils.loadResult import com.lagradost.cloudstream3.utils.AppUtils.loadSearchResult import com.lagradost.cloudstream3.utils.AppUtils.reduceDragSensitivity @@ -62,6 +60,7 @@ import com.lagradost.cloudstream3.utils.DataStoreHelper.currentAccount import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar import com.lagradost.cloudstream3.utils.UIHelper.getSpanCount +import java.util.concurrent.CopyOnWriteArrayList import kotlin.math.abs const val LIBRARY_FOLDER = "library_folder" @@ -165,7 +164,8 @@ class LibraryFragment : Fragment() { } // Set the color for the search exit icon to the correct theme text color - val searchExitIcon = binding?.mainSearch?.findViewById(androidx.appcompat.R.id.search_close_btn) + val searchExitIcon = + binding?.mainSearch?.findViewById(androidx.appcompat.R.id.search_close_btn) val searchExitIconColor = TypedValue() activity?.theme?.resolveAttribute(android.R.attr.textColor, searchExitIconColor, true) @@ -233,7 +233,7 @@ class LibraryFragment : Fragment() { if (listLibraryItems.isNotEmpty()) { val listLibraryItem = listLibraryItems.random() libraryViewModel.currentSyncApi?.syncIdName?.let { - loadLibraryItem(it, listLibraryItem.syncId,listLibraryItem) + loadLibraryItem(it, listLibraryItem.syncId, listLibraryItem) } } } @@ -312,44 +312,46 @@ class LibraryFragment : Fragment() { binding?.viewpager?.setPageTransformer(LibraryScrollTransformer()) - binding?.viewpager?.adapter = - binding?.viewpager?.adapter ?: ViewpagerAdapter( - mutableListOf(), - { isScrollingDown: Boolean -> - if (isScrollingDown) { - binding?.sortFab?.shrink() - binding?.libraryRandom?.shrink() - } else { - binding?.sortFab?.extend() - binding?.libraryRandom?.extend() - } - }) callback@{ searchClickCallback -> - // To prevent future accidents - debugAssert({ - searchClickCallback.card !is SyncAPI.LibraryItem - }, { - "searchClickCallback ${searchClickCallback.card} is not a LibraryItem" - }) + binding?.viewpager?.adapter = ViewpagerAdapter( + fragment = this, + { isScrollingDown: Boolean -> + if (isScrollingDown) { + binding?.sortFab?.shrink() + binding?.libraryRandom?.shrink() + } else { + binding?.sortFab?.extend() + binding?.libraryRandom?.extend() + } + }) callback@{ searchClickCallback -> + // To prevent future accidents + debugAssert({ + searchClickCallback.card !is SyncAPI.LibraryItem + }, { + "searchClickCallback ${searchClickCallback.card} is not a LibraryItem" + }) - val syncId = (searchClickCallback.card as SyncAPI.LibraryItem).syncId - val syncName = - libraryViewModel.currentSyncApi?.syncIdName ?: return@callback + val syncId = (searchClickCallback.card as SyncAPI.LibraryItem).syncId + val syncName = + libraryViewModel.currentSyncApi?.syncIdName ?: return@callback - when (searchClickCallback.action) { - SEARCH_ACTION_SHOW_METADATA -> { - (activity as? MainActivity)?.loadPopup(searchClickCallback.card, load = false) + when (searchClickCallback.action) { + SEARCH_ACTION_SHOW_METADATA -> { + (activity as? MainActivity)?.loadPopup( + searchClickCallback.card, + load = false + ) /*activity?.showPluginSelectionDialog( syncId, syncName, searchClickCallback.card.apiName )*/ - } + } - SEARCH_ACTION_LOAD -> { - loadLibraryItem(syncName, syncId, searchClickCallback.card) - } + SEARCH_ACTION_LOAD -> { + loadLibraryItem(syncName, syncId, searchClickCallback.card) } } + } binding?.apply { viewpager.offscreenPageLimit = 2 @@ -395,7 +397,11 @@ class LibraryFragment : Fragment() { } } - (viewpager.adapter as? ViewpagerAdapter)?.pages = pages + (viewpager.adapter as? ViewpagerAdapter)?.submitList(pages.map { + it.copy( + items = CopyOnWriteArrayList(it.items) + ) + }) //fix focus on the viewpager itself (viewpager.getChildAt(0) as RecyclerView).apply { tag = "tv_no_focus_tag" @@ -403,10 +409,10 @@ class LibraryFragment : Fragment() { } // Using notifyItemRangeChanged keeps the animations when sorting - viewpager.adapter?.notifyItemRangeChanged( + /*viewpager.adapter?.notifyItemRangeChanged( 0, viewpager.adapter?.itemCount ?: 0 - ) + )*/ libraryViewModel.currentPage.value?.let { page -> binding?.viewpager?.setCurrentItem(page, false) @@ -464,12 +470,14 @@ class LibraryFragment : Fragment() { } }.attach() - binding?.libraryTabLayout?.addOnTabSelectedListener(object: TabLayout.OnTabSelectedListener { + binding?.libraryTabLayout?.addOnTabSelectedListener(object : + TabLayout.OnTabSelectedListener { override fun onTabSelected(tab: TabLayout.Tab?) { binding?.libraryTabLayout?.selectedTabPosition?.let { page -> libraryViewModel.switchPage(page) } } + override fun onTabUnselected(tab: TabLayout.Tab?) = Unit override fun onTabReselected(tab: TabLayout.Tab?) = Unit }) @@ -569,8 +577,9 @@ class LibraryFragment : Fragment() { } + @SuppressLint("NotifyDataSetChanged") override fun onConfigurationChanged(newConfig: Configuration) { - (binding?.viewpager?.adapter as? ViewpagerAdapter)?.rebind() + binding?.viewpager?.adapter?.notifyDataSetChanged() super.onConfigurationChanged(newConfig) } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/library/LibraryViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/library/LibraryViewModel.kt index c983ea2f..1bd01c86 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/library/LibraryViewModel.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/library/LibraryViewModel.kt @@ -113,7 +113,7 @@ class LibraryViewModel : ViewModel() { } val desiredSortingMethod = - ListSorting.values().getOrNull(DataStoreHelper.librarySortingMode) + ListSorting.entries.getOrNull(DataStoreHelper.librarySortingMode) if (desiredSortingMethod != null && library.supportedListSorting.contains(desiredSortingMethod)) { sort(desiredSortingMethod, null, pages) } else { diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/library/ViewpagerAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/library/ViewpagerAdapter.kt index c41ec681..cfd22220 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/library/ViewpagerAdapter.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/library/ViewpagerAdapter.kt @@ -1,105 +1,123 @@ package com.lagradost.cloudstream3.ui.library import android.os.Build +import android.os.Bundle +import android.os.Parcelable import android.view.LayoutInflater import android.view.ViewGroup import androidx.core.view.doOnAttach -import androidx.recyclerview.widget.RecyclerView +import androidx.fragment.app.Fragment import androidx.recyclerview.widget.RecyclerView.OnFlingListener import com.google.android.material.appbar.AppBarLayout import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.databinding.LibraryViewpagerPageBinding import com.lagradost.cloudstream3.syncproviders.SyncAPI +import com.lagradost.cloudstream3.ui.BaseAdapter +import com.lagradost.cloudstream3.ui.BaseDiffCallback +import com.lagradost.cloudstream3.ui.ViewHolderState import com.lagradost.cloudstream3.ui.search.SearchClickCallback import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.utils.UIHelper.getSpanCount +class ViewpagerAdapterViewHolderState(val binding: LibraryViewpagerPageBinding) : + ViewHolderState(binding) { + override fun save(): Bundle = + Bundle().apply { + putParcelable( + "pageRecyclerview", + binding.pageRecyclerview.layoutManager?.onSaveInstanceState() + ) + } + + override fun restore(state: Bundle) { + state.getParcelable("pageRecyclerview")?.let { recycle -> + binding.pageRecyclerview.layoutManager?.onRestoreInstanceState(recycle) + } + } +} + class ViewpagerAdapter( - var pages: List, + fragment: Fragment, val scrollCallback: (isScrollingDown: Boolean) -> Unit, val clickCallback: (SearchClickCallback) -> Unit -) : RecyclerView.Adapter() { - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { - return PageViewHolder( +) : BaseAdapter(fragment, + id = "ViewpagerAdapter".hashCode(), + diffCallback = BaseDiffCallback( + itemSame = { a, b -> + a.title == b.title + }, + contentSame = { a, b -> + a.items == b.items && a.title == b.title + } +)) { + override fun onCreateContent(parent: ViewGroup): ViewHolderState { + return ViewpagerAdapterViewHolderState( LibraryViewpagerPageBinding.inflate(LayoutInflater.from(parent.context), parent, false) ) } - override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { - when (holder) { - is PageViewHolder -> { - holder.bind(pages[position], position, unbound.remove(position)) - } - } + override fun onUpdateContent( + holder: ViewHolderState, + item: SyncAPI.Page, + position: Int + ) { + val binding = holder.view + if (binding !is LibraryViewpagerPageBinding) return + (binding.pageRecyclerview.adapter as? PageAdapter)?.updateList(item.items) } - private val unbound = mutableSetOf() + override fun onBindContent(holder: ViewHolderState, item: SyncAPI.Page, position: Int) { + val binding = holder.view + if (binding !is LibraryViewpagerPageBinding) return - /** - * Used to mark all pages for re-binding and forces all items to be refreshed - * Without this the pages will still use the same adapters - **/ - fun rebind() { - unbound.addAll(0..pages.size) - this.notifyItemRangeChanged(0, pages.size) - } - - inner class PageViewHolder(private val binding: LibraryViewpagerPageBinding) : - RecyclerView.ViewHolder(binding.root) { - fun bind(page: SyncAPI.Page, position: Int, rebind: Boolean) { - binding.pageRecyclerview.tag = position - binding.pageRecyclerview.apply { - spanCount = - this@PageViewHolder.itemView.context.getSpanCount() ?: 3 - if (adapter == null || rebind) { - // Only add the items after it has been attached since the items rely on ItemWidth - // Which is only determined after the recyclerview is attached. - // If this fails then item height becomes 0 when there is only one item - doOnAttach { - adapter = PageAdapter( - page.items.toMutableList(), - this, - clickCallback - ) - } - } else { - (adapter as? PageAdapter)?.updateList(page.items) - scrollToPosition(0) + binding.pageRecyclerview.tag = position + binding.pageRecyclerview.apply { + spanCount = + binding.root.context.getSpanCount() ?: 3 + if (adapter == null) { // || rebind + // Only add the items after it has been attached since the items rely on ItemWidth + // Which is only determined after the recyclerview is attached. + // If this fails then item height becomes 0 when there is only one item + doOnAttach { + adapter = PageAdapter( + item.items.toMutableList(), + this, + clickCallback + ) } + } else { + (adapter as? PageAdapter)?.updateList(item.items) + // scrollToPosition(0) + } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - setOnScrollChangeListener { _, _, scrollY, _, oldScrollY -> - val diff = scrollY - oldScrollY + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + setOnScrollChangeListener { _, _, scrollY, _, oldScrollY -> + val diff = scrollY - oldScrollY - //Expand the top Appbar based on scroll direction up/down, simulate phone behavior - if (isLayout(TV or EMULATOR)) { - binding.root.rootView.findViewById(R.id.search_bar) - .apply { - if (diff <= 0) - setExpanded(true) - else - setExpanded(false) - } - } - if (diff == 0) return@setOnScrollChangeListener - - scrollCallback.invoke(diff > 0) + //Expand the top Appbar based on scroll direction up/down, simulate phone behavior + if (isLayout(TV or EMULATOR)) { + binding.root.rootView.findViewById(R.id.search_bar) + .apply { + if (diff <= 0) + setExpanded(true) + else + setExpanded(false) + } } - } else { - onFlingListener = object : OnFlingListener() { - override fun onFling(velocityX: Int, velocityY: Int): Boolean { - scrollCallback.invoke(velocityY > 0) - return false - } + if (diff == 0) return@setOnScrollChangeListener + + scrollCallback.invoke(diff > 0) + } + } else { + onFlingListener = object : OnFlingListener() { + override fun onFling(velocityX: Int, velocityY: Int): Boolean { + scrollCallback.invoke(velocityY > 0) + return false } } } } } - - override fun getItemCount(): Int { - return pages.size - } } \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/UiText.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/UiText.kt index 24d56897..0e8160db 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/UiText.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/UiText.kt @@ -19,6 +19,13 @@ sealed class UiText { data class DynamicString(val value: String) : UiText() { override fun toString(): String = value + + override fun equals(other: Any?): Boolean { + if (other !is DynamicString) return false + return this.value == other.value + } + + override fun hashCode(): Int = value.hashCode() } class StringResource( @@ -27,6 +34,16 @@ sealed class UiText { ) : UiText() { override fun toString(): String = "resId = $resId\nargs = ${args.toList().map { "(${it::class} = $it)" }}" + override fun equals(other: Any?): Boolean { + if (other !is StringResource) return false + return this.resId == other.resId && this.args == other.args + } + + override fun hashCode(): Int { + var result = resId + result = 31 * result + args.hashCode() + return result + } } fun asStringNull(context: Context?): String? {