diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ef57b17aa2..930cdf195e 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -198,6 +198,12 @@ + + diff --git a/app/src/main/java/ani/dantotsu/connections/anilist/AnilistMutations.kt b/app/src/main/java/ani/dantotsu/connections/anilist/AnilistMutations.kt index c2b8eb2418..e1e3b1d573 100644 --- a/app/src/main/java/ani/dantotsu/connections/anilist/AnilistMutations.kt +++ b/app/src/main/java/ani/dantotsu/connections/anilist/AnilistMutations.kt @@ -2,6 +2,7 @@ package ani.dantotsu.connections.anilist import ani.dantotsu.connections.anilist.Anilist.executeQuery import ani.dantotsu.connections.anilist.api.FuzzyDate +import ani.dantotsu.connections.anilist.api.Query import kotlinx.serialization.json.JsonObject class AnilistMutations { @@ -69,4 +70,10 @@ class AnilistMutations { val variables = """{"id":"$listId"}""" executeQuery(query, variables) } + + + suspend fun rateReview(reviewId: Int, rating: String): Query.RateReviewResponse? { + val query = "mutation{RateReview(reviewId:$reviewId,rating:$rating){id mediaId mediaType summary body(asHtml:true)rating ratingAmount userRating score private siteUrl createdAt updatedAt user{id name bannerImage avatar{medium large}}}}" + return executeQuery(query) + } } \ No newline at end of file diff --git a/app/src/main/java/ani/dantotsu/connections/anilist/AnilistQueries.kt b/app/src/main/java/ani/dantotsu/connections/anilist/AnilistQueries.kt index 3c4a898ab1..bd779e00e3 100644 --- a/app/src/main/java/ani/dantotsu/connections/anilist/AnilistQueries.kt +++ b/app/src/main/java/ani/dantotsu/connections/anilist/AnilistQueries.kt @@ -1504,6 +1504,13 @@ Page(page:$page,perPage:50) { return author } + suspend fun getReviews(mediaId: Int, page: Int = 1, sort: String = "UPDATED_AT_DESC"): Query.ReviewsResponse? { + return executeQuery( + """{Page(page:$page,perPage:10){pageInfo{currentPage,hasNextPage,total}reviews(mediaId:$mediaId,sort:$sort){id,mediaId,mediaType,summary,body(asHtml:true)rating,ratingAmount,userRating,score,private,siteUrl,createdAt,updatedAt,user{id,name,bannerImage avatar{medium,large}}}}}""", + force = true + ) + } + suspend fun toggleFollow(id: Int): Query.ToggleFollow? { return executeQuery( """mutation{ToggleFollow(userId:$id){id, isFollowing, isFollower}}""" diff --git a/app/src/main/java/ani/dantotsu/connections/anilist/api/Data.kt b/app/src/main/java/ani/dantotsu/connections/anilist/api/Data.kt index bb6ee79209..e6635b9a77 100644 --- a/app/src/main/java/ani/dantotsu/connections/anilist/api/Data.kt +++ b/app/src/main/java/ani/dantotsu/connections/anilist/api/Data.kt @@ -299,6 +299,70 @@ class Query { val following: List? ) : java.io.Serializable + @Serializable + data class ReviewsResponse( + @SerialName("data") + val data: Data + ) : java.io.Serializable { + @Serializable + data class Data( + @SerialName("Page") + val page: ReviewPage? + ) : java.io.Serializable + } + + @Serializable + data class ReviewPage( + @SerialName("pageInfo") + val pageInfo: PageInfo, + @SerialName("reviews") + val reviews: List? + ) : java.io.Serializable + + @Serializable + data class RateReviewResponse( + @SerialName("data") + val data: Data + ) : java.io.Serializable { + @Serializable + data class Data( + @SerialName("RateReview") + val rateReview: Review + ) : java.io.Serializable + } + + @Serializable + data class Review( + @SerialName("id") + val id: Int, + @SerialName("mediaId") + val mediaId: Int, + @SerialName("mediaType") + val mediaType: String, + @SerialName("summary") + val summary: String, + @SerialName("body") + val body: String, + @SerialName("rating") + var rating: Int, + @SerialName("ratingAmount") + var ratingAmount: Int, + @SerialName("userRating") + var userRating: String, + @SerialName("score") + val score: Int, + @SerialName("private") + val private: Boolean, + @SerialName("siteUrl") + val siteUrl: String, + @SerialName("createdAt") + val createdAt: Int, + @SerialName("updatedAt") + val updatedAt: Int?, + @SerialName("user") + val user: ani.dantotsu.connections.anilist.api.User?, + ) : java.io.Serializable + @Serializable data class UserProfile( @SerialName("id") diff --git a/app/src/main/java/ani/dantotsu/download/anime/AnimeDownloaderService.kt b/app/src/main/java/ani/dantotsu/download/anime/AnimeDownloaderService.kt index 1ad28a6e62..0c0e1fdd71 100644 --- a/app/src/main/java/ani/dantotsu/download/anime/AnimeDownloaderService.kt +++ b/app/src/main/java/ani/dantotsu/download/anime/AnimeDownloaderService.kt @@ -226,7 +226,7 @@ class AnimeDownloaderService : Service() { task.episode ) ?: throw Exception("Failed to create output directory") - outputDir.findFile("${task.getTaskName()}.mp4")?.delete() + outputDir.findFile("${task.getTaskName()}.mkv")?.delete() val outputFile = outputDir.createFile("video/x-matroska", "${task.getTaskName()}.mkv") ?: throw Exception("Failed to create output file") @@ -245,7 +245,7 @@ class AnimeDownloaderService : Service() { .append(defaultHeaders["User-Agent"]).append("\"\'\r\n\'") } val probeRequest = - "-headers $headersStringBuilder -i ${task.video.file.url} -show_entries format=duration -v quiet -of csv=\"p=0\"" + "-headers $headersStringBuilder -i \"${task.video.file.url}\" -show_entries format=duration -v quiet -of csv=\"p=0\"" ffExtension.executeFFProbe( probeRequest ) { @@ -256,7 +256,7 @@ class AnimeDownloaderService : Service() { val headers = headersStringBuilder.toString() var request = "-headers $headers " - request += "-i ${task.video.file.url} -c copy -map 0:v -map 0:a -map 0:s?" + + request += "-i \"${task.video.file.url}\" -c copy -map 0:v -map 0:a -map 0:s?" + " -f matroska -timeout 600 -reconnect 1" + " -reconnect_streamed 1 -allowed_extensions ALL " + "-tls_verify 0 $path -v trace" diff --git a/app/src/main/java/ani/dantotsu/media/MediaInfoFragment.kt b/app/src/main/java/ani/dantotsu/media/MediaInfoFragment.kt index 3863db98af..671c96cebb 100644 --- a/app/src/main/java/ani/dantotsu/media/MediaInfoFragment.kt +++ b/app/src/main/java/ani/dantotsu/media/MediaInfoFragment.kt @@ -517,26 +517,24 @@ class MediaInfoFragment : Fragment() { } parent.addView(root) } + } - ItemTitleSearchBinding.inflate( - LayoutInflater.from(context), - parent, - false - ).apply { - - titleSearchImage.loadImage(media.banner ?: media.cover) - titleSearchText.text = - getString(R.string.search_title, media.mainName()) - titleSearchCard.setSafeOnClickListener { - val query = Intent(requireContext(), SearchActivity::class.java) - .putExtra("type", "ANIME") - .putExtra("query", media.mainName()) - .putExtra("search", true) - ContextCompat.startActivity(requireContext(), query, null) - } + ItemTitleSearchBinding.inflate( + LayoutInflater.from(context), + parent, + false + ).apply { - parent.addView(root) + titleSearchImage.loadImage(media.banner ?: media.cover) + titleSearchText.text = + getString(R.string.reviews) + titleSearchCard.setSafeOnClickListener { + val query = Intent(requireContext(), ReviewActivity::class.java) + .putExtra("mediaId", media.id) + ContextCompat.startActivity(requireContext(), query, null) } + + parent.addView(root) } ItemTitleRecyclerBinding.inflate( diff --git a/app/src/main/java/ani/dantotsu/media/ReviewActivity.kt b/app/src/main/java/ani/dantotsu/media/ReviewActivity.kt new file mode 100644 index 0000000000..453617787d --- /dev/null +++ b/app/src/main/java/ani/dantotsu/media/ReviewActivity.kt @@ -0,0 +1,149 @@ +package ani.dantotsu.media + +import android.annotation.SuppressLint +import android.content.Intent +import android.os.Bundle +import android.text.SpannableString +import android.view.MotionEvent +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.isVisible +import androidx.core.view.updateLayoutParams +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.LinearLayoutManager +import ani.dantotsu.R +import ani.dantotsu.connections.anilist.Anilist +import ani.dantotsu.connections.anilist.api.Query +import ani.dantotsu.databinding.ActivityFollowBinding +import ani.dantotsu.initActivity +import ani.dantotsu.navBarHeight +import ani.dantotsu.profile.FollowerItem +import ani.dantotsu.statusBarHeight +import ani.dantotsu.themes.ThemeManager +import com.xwray.groupie.GroupieAdapter +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class ReviewActivity : AppCompatActivity() { + private lateinit var binding: ActivityFollowBinding + val adapter = GroupieAdapter() + private val reviews = mutableListOf() + var mediaId = 0 + private var currentPage: Int = 1 + private var hasNextPage: Boolean = true + + @SuppressLint("ClickableViewAccessibility") + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + ThemeManager(this).applyTheme() + initActivity(this) + binding = ActivityFollowBinding.inflate(layoutInflater) + binding.listToolbar.updateLayoutParams { + topMargin = statusBarHeight + } + binding.listFrameLayout.updateLayoutParams { + bottomMargin = navBarHeight + } + setContentView(binding.root) + mediaId = intent.getIntExtra("mediaId", -1) + if (mediaId == -1) { + finish() + return + } + binding.followerGrid.visibility = View.GONE + binding.followerList.visibility = View.GONE + binding.followFilterButton.visibility = View.GONE + binding.listTitle.text = getString(R.string.reviews) + binding.listRecyclerView.adapter = adapter + binding.listRecyclerView.layoutManager = LinearLayoutManager( + this, + LinearLayoutManager.VERTICAL, + false + ) + binding.listProgressBar.visibility = View.VISIBLE + binding.listBack.setOnClickListener { onBackPressedDispatcher.onBackPressed() } + + lifecycleScope.launch(Dispatchers.IO) { + val response = Anilist.query.getReviews(mediaId) + withContext(Dispatchers.Main) { + binding.listProgressBar.visibility = View.GONE + binding.listRecyclerView.setOnTouchListener { _, event -> + if (event?.action == MotionEvent.ACTION_UP) { + if (hasNextPage && !binding.listRecyclerView.canScrollVertically(1) && !binding.followRefresh.isVisible + && binding.listRecyclerView.adapter!!.itemCount != 0 && + (binding.listRecyclerView.layoutManager as LinearLayoutManager).findLastVisibleItemPosition() == (binding.listRecyclerView.adapter!!.itemCount - 1) + ) { + binding.followRefresh.visibility = ViewGroup.VISIBLE + loadPage(++currentPage) { + binding.followRefresh.visibility = ViewGroup.GONE + } + } + } + false + } + currentPage = response?.data?.page?.pageInfo?.currentPage ?: 1 + hasNextPage = response?.data?.page?.pageInfo?.hasNextPage ?: false + response?.data?.page?.reviews?.let { + reviews.addAll(it) + fillList() + } + } + } + } + + private fun loadPage(page: Int, callback: () -> Unit) { + lifecycleScope.launch(Dispatchers.IO) { + val response = Anilist.query.getReviews(mediaId, page) + currentPage = response?.data?.page?.pageInfo?.currentPage ?: 1 + hasNextPage = response?.data?.page?.pageInfo?.hasNextPage ?: false + withContext(Dispatchers.Main) { + response?.data?.page?.reviews?.let { + reviews.addAll(it) + fillList() + } + callback() + } + } + } + + private fun fillList() { + adapter.clear() + reviews.forEach { + val username = it.user?.name ?: "Unknown" + val name = SpannableString(username + " - " + it.score) + //change the size of the score + name.setSpan( + android.text.style.RelativeSizeSpan(0.9f), + 0, + name.length, + android.text.Spannable.SPAN_EXCLUSIVE_EXCLUSIVE + ) + //give the text an underline + name.setSpan( + android.text.style.UnderlineSpan(), + username.length + 3, + name.length, + android.text.Spannable.SPAN_EXCLUSIVE_EXCLUSIVE + ) + adapter.add( + FollowerItem( + it.id, + name, + it.user?.avatar?.medium, + it.user?.bannerImage, + it.summary, + this::onUserClick + ) + ) + } + } + + private fun onUserClick(userId: Int) { + val review = reviews.find { it.id == userId } + if (review != null) { + startActivity(Intent(this, ReviewViewActivity::class.java).putExtra("review", review)) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ani/dantotsu/media/ReviewViewActivity.kt b/app/src/main/java/ani/dantotsu/media/ReviewViewActivity.kt new file mode 100644 index 0000000000..7d33481b2e --- /dev/null +++ b/app/src/main/java/ani/dantotsu/media/ReviewViewActivity.kt @@ -0,0 +1,178 @@ +package ani.dantotsu.media + +import android.os.Bundle +import android.view.View +import android.view.ViewGroup +import android.webkit.WebResourceRequest +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.ContextCompat +import androidx.core.view.updateLayoutParams +import androidx.lifecycle.lifecycleScope +import ani.dantotsu.R +import ani.dantotsu.connections.anilist.Anilist +import ani.dantotsu.connections.anilist.api.Query +import ani.dantotsu.databinding.ActivityReviewViewBinding +import ani.dantotsu.getThemeColor +import ani.dantotsu.initActivity +import ani.dantotsu.loadImage +import ani.dantotsu.navBarHeight +import ani.dantotsu.profile.activity.ActivityItemBuilder +import ani.dantotsu.statusBarHeight +import ani.dantotsu.themes.ThemeManager +import ani.dantotsu.toast +import ani.dantotsu.util.AniMarkdown +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class ReviewViewActivity : AppCompatActivity() { + private lateinit var binding: ActivityReviewViewBinding + private lateinit var review: Query.Review + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + ThemeManager(this).applyTheme() + initActivity(this) + binding = ActivityReviewViewBinding.inflate(layoutInflater) + binding.userContainer.updateLayoutParams { + topMargin = statusBarHeight + } + binding.reviewContent.updateLayoutParams { + bottomMargin += navBarHeight + } + setContentView(binding.root) + review = intent.getSerializableExtra("review") as Query.Review + binding.userName.text = review.user?.name + binding.userAvatar.loadImage(review.user?.avatar?.medium) + binding.userTime.text = ActivityItemBuilder.getDateTime(review.createdAt) + binding.profileUserBio.settings.loadWithOverviewMode = true + binding.profileUserBio.settings.useWideViewPort = true + binding.profileUserBio.setInitialScale(1) + val styledHtml = AniMarkdown.getFullAniHTML( + review.body, + ContextCompat.getColor(this, R.color.bg_opp) + ) + binding.profileUserBio.loadDataWithBaseURL( + null, + styledHtml, + "text/html", + "utf-8", + null + ) + binding.profileUserBio.setBackgroundColor( + ContextCompat.getColor( + this, + android.R.color.transparent + ) + ) + binding.profileUserBio.setLayerType(View.LAYER_TYPE_HARDWARE, null) + binding.profileUserBio.webViewClient = object : WebViewClient() { + override fun onPageFinished(view: WebView?, url: String?) { + super.onPageFinished(view, url) + binding.profileUserBio.setBackgroundColor( + ContextCompat.getColor( + this@ReviewViewActivity, + android.R.color.transparent + ) + ) + } + + override fun shouldOverrideUrlLoading( + view: WebView?, + request: WebResourceRequest? + ): Boolean { + return true + } + } + userVote(review.userRating) + enableVote() + binding.voteCount.text = review.rating.toString() + binding.voteText.text = getString( + R.string.vote_out_of_total, + review.rating.toString(), + review.ratingAmount.toString() + ) + } + + private fun userVote(type: String) { + val selectedColor = getThemeColor(com.google.android.material.R.attr.colorPrimary) + val unselectedColor = getThemeColor(androidx.appcompat.R.attr.colorControlNormal) + when (type) { + "NO_VOTE" -> { + binding.upvote.setColorFilter(unselectedColor) + binding.downvote.setColorFilter(unselectedColor) + } + + "UP_VOTE" -> { + binding.upvote.setColorFilter(selectedColor) + binding.downvote.setColorFilter(unselectedColor) + } + + "DOWN_VOTE" -> { + binding.upvote.setColorFilter(unselectedColor) + binding.downvote.setColorFilter(selectedColor) + } + } + } + + private fun rateReview(rating: String) { + disableVote() + lifecycleScope.launch { + val result = Anilist.mutation.rateReview(review.id, rating) + if (result != null) { + withContext(Dispatchers.Main) { + val res = result.data.rateReview + review.rating = res.rating + review.ratingAmount = res.ratingAmount + review.userRating = res.userRating + userVote(review.userRating) + binding.voteCount.text = review.rating.toString() + binding.voteText.text = getString( + R.string.vote_out_of_total, + review.rating.toString(), + review.ratingAmount.toString() + ) + userVote(review.userRating) + enableVote() + } + } else { + withContext(Dispatchers.Main) { + toast( + getString(R.string.error_message, "response is null") + ) + enableVote() + } + } + } + } + + private fun disableVote() { + binding.upvote.setOnClickListener(null) + binding.downvote.setOnClickListener(null) + binding.upvote.isEnabled = false + binding.downvote.isEnabled = false + } + + private fun enableVote() { + binding.upvote.setOnClickListener { + if (review.userRating == "UP_VOTE") { + rateReview("NO_VOTE") + } else { + rateReview("UP_VOTE") + } + disableVote() + } + binding.downvote.setOnClickListener { + if (review.userRating == "DOWN_VOTE") { + rateReview("NO_VOTE") + } else { + rateReview("DOWN_VOTE") + } + disableVote() + } + binding.upvote.isEnabled = true + binding.downvote.isEnabled = true + } +} \ No newline at end of file diff --git a/app/src/main/java/ani/dantotsu/profile/FollowActivity.kt b/app/src/main/java/ani/dantotsu/profile/FollowActivity.kt index c5c606d1e8..6e2a92fcb6 100644 --- a/app/src/main/java/ani/dantotsu/profile/FollowActivity.kt +++ b/app/src/main/java/ani/dantotsu/profile/FollowActivity.kt @@ -2,6 +2,7 @@ package ani.dantotsu.profile import android.content.Intent import android.os.Bundle +import android.text.SpannableString import android.view.View import android.view.ViewGroup.MarginLayoutParams import android.widget.ImageButton @@ -54,7 +55,7 @@ class FollowActivity : AppCompatActivity() { ) binding.listRecyclerView.adapter = adapter binding.listProgressBar.visibility = View.VISIBLE - binding.listBack.setOnClickListener { finish() } + binding.listBack.setOnClickListener { onBackPressedDispatcher.onBackPressed() } val title = intent.getStringExtra("title") val userID = intent.getIntExtra("userId", 0) @@ -97,10 +98,11 @@ class FollowActivity : AppCompatActivity() { } users?.forEach { user -> if (getLayoutType(selected) == 0) { + val username = SpannableString(user.name ?: "Unknown") adapter.add( FollowerItem( user.id, - user.name ?: "Unknown", + username, user.avatar?.medium, user.bannerImage ?: user.avatar?.medium ) { onUserClick(it) }) diff --git a/app/src/main/java/ani/dantotsu/profile/FollowerItem.kt b/app/src/main/java/ani/dantotsu/profile/FollowerItem.kt index 9513c29e46..18c05a8951 100644 --- a/app/src/main/java/ani/dantotsu/profile/FollowerItem.kt +++ b/app/src/main/java/ani/dantotsu/profile/FollowerItem.kt @@ -1,6 +1,7 @@ package ani.dantotsu.profile +import android.text.SpannableString import android.view.View import ani.dantotsu.R import ani.dantotsu.blurImage @@ -10,9 +11,10 @@ import com.xwray.groupie.viewbinding.BindableItem class FollowerItem( private val id: Int, - private val name: String, + private val name: SpannableString, private val avatar: String?, private val banner: String?, + private val altText: String? = null, val clickCallback: (Int) -> Unit ) : BindableItem() { private lateinit var binding: ItemFollowerBinding @@ -21,6 +23,10 @@ class FollowerItem( binding = viewBinding binding.profileUserName.text = name avatar?.let { binding.profileUserAvatar.loadImage(it) } + altText?.let { + binding.altText.visibility = View.VISIBLE + binding.altText.text = it + } blurImage(binding.profileBannerImage, banner ?: avatar) binding.root.setOnClickListener { clickCallback(id) } } diff --git a/app/src/main/res/drawable/ic_thumbs.xml b/app/src/main/res/drawable/ic_thumbs.xml new file mode 100644 index 0000000000..8c5dd3cc8e --- /dev/null +++ b/app/src/main/res/drawable/ic_thumbs.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/app/src/main/res/drawable/surface_rounded_bg.xml b/app/src/main/res/drawable/surface_rounded_bg.xml new file mode 100644 index 0000000000..59ad61753d --- /dev/null +++ b/app/src/main/res/drawable/surface_rounded_bg.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_review_view.xml b/app/src/main/res/layout/activity_review_view.xml new file mode 100644 index 0000000000..6732d8f936 --- /dev/null +++ b/app/src/main/res/layout/activity_review_view.xml @@ -0,0 +1,155 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_follower.xml b/app/src/main/res/layout/item_follower.xml index 2339b8ce74..675e5451b2 100644 --- a/app/src/main/res/layout/item_follower.xml +++ b/app/src/main/res/layout/item_follower.xml @@ -52,14 +52,34 @@ - + android:layout_marginStart="100dp" + android:gravity="center_vertical" + android:orientation="vertical"> + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_title_search.xml b/app/src/main/res/layout/item_title_search.xml index 8f1c8f0e93..b1232b928d 100644 --- a/app/src/main/res/layout/item_title_search.xml +++ b/app/src/main/res/layout/item_title_search.xml @@ -50,7 +50,7 @@ android:gravity="center" android:maxLines="2" android:padding="4dp" - android:text="@string/search" + android:text="@string/reviews" android:textAllCaps="true" android:textColor="@color/bg_white" android:textSize="14sp" /> diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0c769dcbfa..3b030a0ac6 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -430,7 +430,7 @@ Error: %1$s Step: %1$s Review - + Reviews Display only the first button Display dantotsu in the second button Display your AniList profile instead @@ -850,8 +850,6 @@ Non quae tempore quo provident laudantium qui illo dolor vel quia dolor et exerc Do it! Password - Search %1$s - Track progress directly from your home screen Anime\nWatched Manga\nRead @@ -981,4 +979,5 @@ Non quae tempore quo provident laudantium qui illo dolor vel quia dolor et exerc Download Subtitle No video selected No subtitles available + (%1$s out of %2$s liked this review)