From ae054ef4ae8d363d3f6a79e4fbd2fecf4c41cc50 Mon Sep 17 00:00:00 2001 From: Luna712 <142361265+Luna712@users.noreply.github.com> Date: Fri, 31 Jan 2025 17:49:55 -0700 Subject: [PATCH 01/37] Add reviews support to API --- .../cloudstream3/ui/APIRepository.kt | 11 ++++++++ .../com/lagradost/cloudstream3/MainAPI.kt | 26 +++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/APIRepository.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/APIRepository.kt index 9bbc5341a6..7b5e74e81f 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/APIRepository.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/APIRepository.kt @@ -12,6 +12,7 @@ import com.lagradost.cloudstream3.MainPageRequest import com.lagradost.cloudstream3.SearchResponse import com.lagradost.cloudstream3.SubtitleFile import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.UserReview import com.lagradost.cloudstream3.fixUrl import com.lagradost.cloudstream3.mvvm.Resource import com.lagradost.cloudstream3.mvvm.logError @@ -194,4 +195,14 @@ class APIRepository(val api: MainAPI) { return false } } + + suspend fun loadReviews( + url: String, + page: Int, + showSpoilers: Boolean = false + ): Resource> { + return safeApiCall { + api.loadReviews(url, page, showSpoilers) + } + } } \ No newline at end of file diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt index 97c1a6d87b..aa4fa9b1a5 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt @@ -519,6 +519,15 @@ abstract class MainAPI { throw NotImplementedError() } + open val hasReviews: Boolean = true + open suspend fun loadReviews( + url: String, + page: Int, + showSpoilers: Boolean = false + ): List { + throw NotImplementedError() + } + // @WorkerThread open suspend fun search(query: String): List? { throw NotImplementedError() @@ -945,6 +954,23 @@ fun MainAPI.updateUrl(url: String): String { } } +@Prerelease +@ConsistentCopyVisibility +data class UserReview internal constructor( + val review: String? = null, + val reviewTitle: String? = null, + val username: String? = null, + val reviewDate: String? = null, + val avatarUrl: String? = null, + val rating: Int? = null, + val ratings: List>? = null, +) + +@Prerelease +fun MainAPI.newUserReview( + initializer: UserReview.() -> Unit = {} +): UserReview = UserReview().apply(initializer) + /** Abstract interface of SearchResponse. */ interface SearchResponse { val name: String From 50f977417f838305b5ef27bc5c49205d69f86aa3 Mon Sep 17 00:00:00 2001 From: Luna712 <142361265+Luna712@users.noreply.github.com> Date: Fri, 31 Jan 2025 18:39:38 -0700 Subject: [PATCH 02/37] Fix --- .../com/lagradost/cloudstream3/MainAPI.kt | 48 +++++++++++++++---- 1 file changed, 39 insertions(+), 9 deletions(-) diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt index aa4fa9b1a5..89b8dbb8e7 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt @@ -519,7 +519,7 @@ abstract class MainAPI { throw NotImplementedError() } - open val hasReviews: Boolean = true + open val hasReviews: Boolean = false open suspend fun loadReviews( url: String, page: Int, @@ -528,6 +528,23 @@ abstract class MainAPI { throw NotImplementedError() } + /* open val hasReviews: Boolean = true + open suspend fun loadReviews( + url: String, + page: Int, + showSpoilers: Boolean = false + ): List { + return listOf(UserReview( + "test", + "test", + "test", + "test", + "https://avatars.githubusercontent.com/u/142361265?v=4", + 10, + null + )) + } */ + // @WorkerThread open suspend fun search(query: String): List? { throw NotImplementedError() @@ -957,14 +974,27 @@ fun MainAPI.updateUrl(url: String): String { @Prerelease @ConsistentCopyVisibility data class UserReview internal constructor( - val review: String? = null, - val reviewTitle: String? = null, - val username: String? = null, - val reviewDate: String? = null, - val avatarUrl: String? = null, - val rating: Int? = null, - val ratings: List>? = null, -) + var review: String? = null, + var reviewTitle: String? = null, + var username: String? = null, + var reviewDate: String? = null, + var avatarUrl: String? = null, + var rating: Int? = null, + var ratings: List>? = null, +) { + fun new(initializer: UserReview.() -> Unit = {}): UserReview { + return UserReview().apply { + review = this@UserReview.review + reviewTitle = this@UserReview.reviewTitle + username = this@UserReview.username + reviewDate = this@UserReview.reviewDate + avatarUrl = this@UserReview.avatarUrl + rating = this@UserReview.rating + ratings = this@UserReview.ratings + initializer() + } + } +} @Prerelease fun MainAPI.newUserReview( From c455f4481ac57517ea13b63353b533970ae67e45 Mon Sep 17 00:00:00 2001 From: Luna712 <142361265+Luna712@users.noreply.github.com> Date: Fri, 31 Jan 2025 19:05:33 -0700 Subject: [PATCH 03/37] Push initial UI --- .../cloudstream3/ui/CustomRecyclerViews.kt | 34 +++++ .../ui/result/ResultFragmentPhone.kt | 72 +++++++++++ .../ui/result/ResultViewModel2.kt | 66 ++++++++++ .../cloudstream3/ui/result/ReviewAdapter.kt | 119 ++++++++++++++++++ app/src/main/res/drawable/white_card.xml | 5 + app/src/main/res/layout/fragment_result.xml | 76 ++++++++++- app/src/main/res/layout/loading_review.xml | 22 ++++ app/src/main/res/layout/result_review.xml | 97 ++++++++++++++ app/src/main/res/values/strings.xml | 4 + app/src/main/res/values/styles.xml | 16 +++ 10 files changed, 510 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/com/lagradost/cloudstream3/ui/result/ReviewAdapter.kt create mode 100644 app/src/main/res/drawable/white_card.xml create mode 100644 app/src/main/res/layout/loading_review.xml create mode 100644 app/src/main/res/layout/result_review.xml diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/CustomRecyclerViews.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/CustomRecyclerViews.kt index 78ad2a6bfc..7eb2751e1f 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/CustomRecyclerViews.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/CustomRecyclerViews.kt @@ -5,6 +5,7 @@ import android.util.AttributeSet import android.view.View import androidx.core.view.children import androidx.recyclerview.widget.GridLayoutManager +import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import kotlin.math.abs @@ -190,4 +191,37 @@ class MaxRecyclerView(ctx: Context, attrs: AttributeSet) : RecyclerView(ctx, att } super.onChildAttachedToWindow(child) } +} + +class ScrollableRecyclerView(context: Context, attrs: AttributeSet?) : + RecyclerView(context, attrs) { + + var loadMoreListener: () -> Unit = {} + + override fun onScrollStateChanged(state: Int) { + super.onScrollStateChanged(state) + + val lm = layoutManager as LinearLayoutManager + val totalItemCount = adapter?.itemCount + val lastVisibleItemPosition = lm.findLastVisibleItemPosition() + val visibleItemCount = childCount + + if (totalItemCount != null) { + if (state == SCROLL_STATE_IDLE + && lastVisibleItemPosition == totalItemCount - 1 + && visibleItemCount > 0) { + loadMoreListener() + } + } + + } + + override fun onScrolled(dx: Int, dy: Int) { + super.onScrolled(dx, dy) + if (dy > 0 && !canScrollVertically(1)) { + if (layoutManager is LinearLayoutManager && (layoutManager as LinearLayoutManager).findLastVisibleItemPosition() == (adapter?.itemCount ?: 0) - 1) { + loadMoreListener() + } + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentPhone.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentPhone.kt index 9f7e3b6e30..907f039325 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentPhone.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentPhone.kt @@ -22,6 +22,7 @@ import androidx.core.view.isVisible import androidx.core.widget.NestedScrollView import androidx.core.widget.doOnTextChanged import androidx.lifecycle.ViewModelProvider +import androidx.recyclerview.widget.GridLayoutManager import com.discord.panels.OverlappingPanelsLayout import com.discord.panels.PanelState import com.discord.panels.PanelsChildGestureRegionObserver @@ -29,6 +30,8 @@ import com.google.android.gms.cast.framework.CastButtonFactory import com.google.android.gms.cast.framework.CastContext import com.google.android.gms.cast.framework.CastState import com.google.android.material.bottomsheet.BottomSheetDialog +import com.google.android.material.tabs.TabLayout +import com.lagradost.api.Log import com.lagradost.cloudstream3.APIHolder import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.DubStatus @@ -41,6 +44,7 @@ import com.lagradost.cloudstream3.databinding.FragmentResultSwipeBinding import com.lagradost.cloudstream3.databinding.ResultRecommendationsBinding import com.lagradost.cloudstream3.databinding.ResultSyncBinding import com.lagradost.cloudstream3.mvvm.Resource +import com.lagradost.cloudstream3.mvvm.debugException import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.normalSafeApiCall import com.lagradost.cloudstream3.mvvm.observe @@ -732,6 +736,74 @@ open class ResultFragmentPhone : FullScreenPlayer() { populateChips(resultTag, d.tags) + resultTabs.removeAllTabs() + resultTabs.isVisible = false + if (api?.hasReviews == true ) { + resultTabs.isVisible = true + resultTabs.addTab(resultTabs.newTab().setText(R.string.details).setId(0)) + resultTabs.addTab( + resultTabs.newTab().setText(R.string.reviews).setId(1) + ) + } + + val target = viewModel.currentTabIndex.value + if (target != null) { + resultTabs.getTabAt(target)?.let { new -> + resultTabs.selectTab(new) + } + } + + val reviewAdapter = ReviewAdapter() + + resultReviews.adapter = reviewAdapter + resultReviews.layoutManager = GridLayoutManager(context, 1) + resultReviews.loadMoreListener = { viewModel.loadMoreReviews() } + + observe(viewModel.reviews) { reviews -> + when (reviews) { + is Resource.Success -> { + resultviewReviewsLoading.isVisible = false + resultviewReviewsLoadingShimmer.startShimmer() + resultReviews.isVisible = true + reviewAdapter.submitList(reviews.value.map { it.new() } ) + } + + is Resource.Loading -> { + resultviewReviewsLoadingShimmer.stopShimmer() + resultviewReviewsLoading.isVisible = true + resultReviews.isVisible = false + } + + is Resource.Failure -> { + debugException { "This should never happened" } + } + } + } + + resultTabs.addOnTabSelectedListener(object : TabLayout.OnTabSelectedListener { + override fun onTabSelected(tab: TabLayout.Tab?) { + Log.i("ResultFragment", "addOnTabSelectedListener ${resultTabs.selectedTabPosition}") + viewModel.switchTab(tab?.id, resultTabs.selectedTabPosition) + } + + override fun onTabUnselected(tab: TabLayout.Tab?) {} + + override fun onTabReselected(tab: TabLayout.Tab?) {} + }) + + observe(viewModel.currentTabIndex) { pos -> + binding.apply { + resultCastItems.isVisible = 0 == pos + resultDataHolder.isVisible = 0 == pos + resultTagHolder.isVisible = 0 == pos + resultTag.isVisible = 0 == pos + resultDescription.isVisible = 0 == pos + resultInfo.isVisible = 0 == pos + binding?.resultBookmarkFab?.isVisible = 0 == pos + resultReviewsholder.isVisible = 1 == pos + } + } + resultComingSoon.isVisible = d.comingSoon resultDataHolder.isGone = d.comingSoon diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt index 46f4ef6527..0f639b1d8a 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt @@ -83,6 +83,8 @@ import com.lagradost.cloudstream3.utils.DataStoreHelper.setVideoWatchState import com.lagradost.cloudstream3.utils.DataStoreHelper.updateSubscribedData import com.lagradost.cloudstream3.utils.UIHelper.navigate import kotlinx.coroutines.* +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import java.util.concurrent.TimeUnit /** This starts at 1 */ @@ -486,6 +488,70 @@ class ResultViewModel2 : ViewModel() { private val _favoriteStatus: MutableLiveData = MutableLiveData(null) val favoriteStatus: LiveData = _favoriteStatus + val currentTabIndex: MutableLiveData by lazy { + MutableLiveData(0) + } + + val currentTabPosition: MutableLiveData by lazy { + MutableLiveData(0) + } + + val reviews: MutableLiveData>> by lazy { + MutableLiveData>>() + } + private var currentReviews: ArrayList = arrayListOf() + + private val reviewPage: MutableLiveData by lazy { + MutableLiveData(0) + } + + private val loadMoreReviewsMutex = Mutex() + private fun loadMoreReviews(url: String) { + viewModelScope.launch { + if (loadMoreReviewsMutex.isLocked) return@launch + loadMoreReviewsMutex.withLock { + val loadPage = (reviewPage.value ?: 0) + 1 + if (loadPage == 1) { + reviews.postValue(Resource.Loading()) + } + val response = currentResponse ?: return@launch + val api = + getApiFromNameNull(response.apiName) ?: APIHolder.getApiFromUrlNull( + response.url + ) ?: APIRepository.noneApi + val repo = APIRepository(api) + when (val data = repo.loadReviews(url, loadPage, false)) { + is Resource.Success -> { + val moreReviews = data.value + currentReviews.addAll(moreReviews) + + reviews.postValue(Resource.Success(currentReviews)) + reviewPage.postValue(loadPage) + } + + else -> {} + } + } + } + } + + private val loadMutex = Mutex() + fun loadMoreReviews(verify: Boolean = true) = viewModelScope.launch { + loadMutex.withLock { + if (verify && currentTabIndex.value == 0) return@launch + loadMoreReviews(currentResponse?.url ?: return@launch) + } + } + + fun switchTab(index: Int?, position: Int?) { + val newPos = index ?: return + currentTabPosition.postValue(position ?: return) + currentTabIndex.postValue(newPos) + if (newPos == 1 && currentReviews.isEmpty()) { + loadMoreReviews(verify = false) + } + } + companion object { const val TAG = "RVM2" //private const val EPISODE_RANGE_SIZE = 20 diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ReviewAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ReviewAdapter.kt new file mode 100644 index 0000000000..401ac86719 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ReviewAdapter.kt @@ -0,0 +1,119 @@ +package com.lagradost.cloudstream3.ui.result + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.app.AlertDialog +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.chip.Chip +import com.google.android.material.chip.ChipDrawable +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.UserReview +import com.lagradost.cloudstream3.databinding.ResultReviewBinding +import com.lagradost.cloudstream3.utils.ImageLoader.loadImage +import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute + +class ReviewAdapter : + ListAdapter(DiffCallback()) { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ReviewAdapterHolder { + val binding = + ResultReviewBinding.inflate(LayoutInflater.from(parent.context), parent, false) + return ReviewAdapterHolder(binding) + } + + override fun onBindViewHolder(holder: ReviewAdapterHolder, position: Int) { + val currentItem = getItem(position) + holder.bind(currentItem) + } + + class ReviewAdapterHolder(private val binding: ResultReviewBinding) : + RecyclerView.ViewHolder(binding.root) { + + fun bind(card: UserReview) { + binding.apply { + val localContext = this.root.context ?: return + + var reviewText = card.review ?: "" + if (reviewText.length > 300) { + reviewText = reviewText.substring(0, 300) + "..." + } + + reviewBody.setOnClickListener { + val builder: AlertDialog.Builder = AlertDialog.Builder(localContext) + builder.setMessage(card.review) + val title = card.reviewTitle ?: card.username + ?: if (card.rating != null) localContext.getString(R.string.overall_rating_format) + .format("Overall ${(card.rating ?: 1) / 200}★") else null + if (title != null) + builder.setTitle(title) + builder.show() + } + + reviewBody.text = reviewText + reviewTitle.text = card.reviewTitle ?: "" + reviewTitle.visibility = if (reviewTitle.text == "") View.GONE else View.VISIBLE + + reviewTime.text = card.reviewDate + reviewAuthor.text = card.username + + reviewImage.loadImage(card.avatarUrl) + + reviewTags.apply { + removeAllViews() + + val context = reviewTags.context + + card.rating?.let { rating -> + val chip = Chip(context) + val chipDrawable = ChipDrawable.createFromAttributes( + context, + null, + 0, + R.style.ChipReviewAlt + ) + chip.setChipDrawable(chipDrawable) + chip.text = "Overall ${rating / 200}★" + chip.isChecked = false + chip.isCheckable = false + chip.isFocusable = false + chip.isClickable = false + + // we set the color in code as it cant be set in style + chip.setTextColor(context.colorFromAttribute(R.attr.primaryGrayBackground)) + addView(chip) + } + + card.ratings?.forEach { (a, b) -> + val chip = Chip(context) + val chipDrawable = ChipDrawable.createFromAttributes( + context, + null, + 0, + R.style.ChipReview + ) + chip.setChipDrawable(chipDrawable) + chip.text = "$b ${a / 200}★" + chip.isChecked = false + chip.isCheckable = false + chip.isFocusable = false + chip.isClickable = false + + // we set the color in code as it cant be set in style + chip.setTextColor(context.colorFromAttribute(R.attr.textColor)) + addView(chip) + } + } + } + } + } + + class DiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: UserReview, newItem: UserReview): Boolean = + oldItem == newItem + + override fun areContentsTheSame(oldItem: UserReview, newItem: UserReview): Boolean = + oldItem == newItem + } +} \ No newline at end of file diff --git a/app/src/main/res/drawable/white_card.xml b/app/src/main/res/drawable/white_card.xml new file mode 100644 index 0000000000..a70b8d7927 --- /dev/null +++ b/app/src/main/res/drawable/white_card.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_result.xml b/app/src/main/res/layout/fragment_result.xml index 22d2e52f19..1143978412 100644 --- a/app/src/main/res/layout/fragment_result.xml +++ b/app/src/main/res/layout/fragment_result.xml @@ -396,6 +396,80 @@ tools:text="121min" /> + + + + + + + + + + + + + + + + + + + + + + + + - + \ No newline at end of file diff --git a/app/src/main/res/layout/loading_review.xml b/app/src/main/res/layout/loading_review.xml new file mode 100644 index 0000000000..6d2f8e5631 --- /dev/null +++ b/app/src/main/res/layout/loading_review.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/result_review.xml b/app/src/main/res/layout/result_review.xml new file mode 100644 index 0000000000..c9a3cfabc6 --- /dev/null +++ b/app/src/main/res/layout/result_review.xml @@ -0,0 +1,97 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ecdffd5332..6e3f9aaed0 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -832,4 +832,8 @@ software_decoding_key Software decoding Software decoding enables the player to play video files not supported by your phone, but may cause laggy or unstable playback on high resolution + Details + Reviews + Avatar + Overall %s diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index d7bf78cdf3..8bf62422ad 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -87,6 +87,22 @@ @color/amoledModeLight + + + +