Dantotsu/app/src/main/java/ani/dantotsu/media/manga/MangaReadFragment.kt
2025-04-02 10:39:33 +05:30

646 lines
No EOL
25 KiB
Kotlin

package ani.dantotsu.media.manga
import android.Manifest
import android.annotation.SuppressLint
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.os.Parcelable
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import android.widget.Toast
import androidx.cardview.widget.CardView
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.core.math.MathUtils.clamp
import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.core.view.updatePadding
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.ConcatAdapter
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.viewpager2.widget.ViewPager2
import ani.dantotsu.R
import ani.dantotsu.databinding.FragmentMediaSourceBinding
import ani.dantotsu.download.DownloadedType
import ani.dantotsu.download.DownloadsManager
import ani.dantotsu.download.DownloadsManager.Companion.compareName
import ani.dantotsu.download.manga.MangaDownloaderService
import ani.dantotsu.download.manga.MangaServiceDataSingleton
import ani.dantotsu.dp
import ani.dantotsu.isOnline
import ani.dantotsu.media.Media
import ani.dantotsu.media.MediaDetailsActivity
import ani.dantotsu.media.MediaDetailsViewModel
import ani.dantotsu.media.MediaNameAdapter
import ani.dantotsu.media.MediaType
import ani.dantotsu.media.manga.mangareader.ChapterLoaderDialog
import ani.dantotsu.navBarHeight
import ani.dantotsu.notifications.subscription.SubscriptionHelper
import ani.dantotsu.notifications.subscription.SubscriptionHelper.Companion.saveSubscription
import ani.dantotsu.others.LanguageMapper
import ani.dantotsu.parsers.DynamicMangaParser
import ani.dantotsu.parsers.HMangaSources
import ani.dantotsu.parsers.MangaParser
import ani.dantotsu.parsers.MangaSources
import ani.dantotsu.parsers.OfflineMangaParser
import ani.dantotsu.setNavigationTheme
import ani.dantotsu.settings.extensionprefs.MangaSourcePreferencesFragment
import ani.dantotsu.settings.saving.PrefManager
import ani.dantotsu.settings.saving.PrefName
import ani.dantotsu.snackString
import ani.dantotsu.util.StoragePermissions.Companion.accessAlertDialog
import ani.dantotsu.util.StoragePermissions.Companion.hasDirAccess
import ani.dantotsu.util.customAlertDialog
import com.google.android.material.appbar.AppBarLayout
import eu.kanade.tachiyomi.extension.manga.model.MangaExtension
import eu.kanade.tachiyomi.source.ConfigurableSource
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import kotlin.math.ceil
import kotlin.math.max
import kotlin.math.roundToInt
open class MangaReadFragment : Fragment(), ScanlatorSelectionListener {
private var _binding: FragmentMediaSourceBinding? = null
private val binding get() = _binding!!
private val model: MediaDetailsViewModel by activityViewModels()
private lateinit var media: Media
private var start = 0
private var end: Int? = null
private var style: Int? = null
private var reverse = false
private lateinit var headerAdapter: MangaReadAdapter
private lateinit var chapterAdapter: MangaChapterAdapter
val downloadManager = Injekt.get<DownloadsManager>()
var screenWidth = 0f
private var progress = View.VISIBLE
var continueEp: Boolean = false
var loaded = false
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
_binding = FragmentMediaSourceBinding.inflate(inflater, container, false)
return _binding?.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val intentFilter = IntentFilter().apply {
addAction(ACTION_DOWNLOAD_STARTED)
addAction(ACTION_DOWNLOAD_FINISHED)
addAction(ACTION_DOWNLOAD_FAILED)
addAction(ACTION_DOWNLOAD_PROGRESS)
}
ContextCompat.registerReceiver(
requireContext(),
downloadStatusReceiver,
intentFilter,
ContextCompat.RECEIVER_EXPORTED
)
binding.mediaSourceRecycler.updatePadding(bottom = binding.mediaSourceRecycler.paddingBottom + navBarHeight)
screenWidth = resources.displayMetrics.widthPixels.dp
var maxGridSize = (screenWidth / 100f).roundToInt()
maxGridSize = max(4, maxGridSize - (maxGridSize % 2))
val gridLayoutManager = GridLayoutManager(requireContext(), maxGridSize)
gridLayoutManager.spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() {
override fun getSpanSize(position: Int): Int {
val style = chapterAdapter.getItemViewType(position)
return when (position) {
0 -> maxGridSize
else -> when (style) {
0 -> maxGridSize
1 -> 1
else -> maxGridSize
}
}
}
}
binding.mediaSourceRecycler.layoutManager = gridLayoutManager
binding.ScrollTop.setOnClickListener {
binding.mediaSourceRecycler.scrollToPosition(10)
binding.mediaSourceRecycler.smoothScrollToPosition(0)
}
binding.mediaSourceRecycler.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
super.onScrolled(recyclerView, dx, dy)
val position = gridLayoutManager.findFirstVisibleItemPosition()
if (position > 2) {
binding.ScrollTop.translationY = -navBarHeight.toFloat()
binding.ScrollTop.visibility = View.VISIBLE
} else {
binding.ScrollTop.visibility = View.GONE
}
}
})
model.scrolledToTop.observe(viewLifecycleOwner) {
if (it) binding.mediaSourceRecycler.scrollToPosition(0)
}
continueEp = model.continueMedia ?: false
model.getMedia().observe(viewLifecycleOwner) {
if (it != null) {
media = it
progress = View.GONE
binding.mediaInfoProgressBar.visibility = progress
if (media.format == "MANGA" || media.format == "ONE SHOT") {
media.selected = model.loadSelected(media)
subscribed =
SubscriptionHelper.getSubscriptions().containsKey(media.id)
style = media.selected!!.recyclerStyle
reverse = media.selected!!.recyclerReversed
if (!loaded) {
model.mangaReadSources = if (media.isAdult) HMangaSources else MangaSources
headerAdapter = MangaReadAdapter(it, this, model.mangaReadSources!!)
headerAdapter.scanlatorSelectionListener = this
chapterAdapter =
MangaChapterAdapter(
style ?: PrefManager.getVal(PrefName.MangaDefaultView), media, this
)
for (download in downloadManager.mangaDownloadedTypes) {
if (media.compareName(download.titleName)) {
chapterAdapter.stopDownload(download.uniqueName)
}
}
binding.mediaSourceRecycler.adapter =
ConcatAdapter(headerAdapter, chapterAdapter)
lifecycleScope.launch(Dispatchers.IO) {
val offline =
!isOnline(binding.root.context) || PrefManager.getVal(PrefName.OfflineMode)
if (offline) media.selected!!.sourceIndex =
model.mangaReadSources!!.list.lastIndex
model.loadMangaChapters(media, media.selected!!.sourceIndex)
}
loaded = true
} else {
reload()
}
} else {
binding.mediaNotSupported.visibility = View.VISIBLE
binding.mediaNotSupported.text =
getString(R.string.not_supported, media.format ?: "")
}
}
}
model.getMangaChapters().observe(viewLifecycleOwner) { _ ->
updateChapters()
}
}
override fun onScanlatorsSelected() {
updateChapters()
}
fun multiDownload(n: Int) {
// Get last viewed chapter
val selected = media.userProgress
val chapters = media.manga?.chapters?.values?.toList()
// Filter by selected language
val progressChapterIndex = (chapters?.indexOfFirst {
MediaNameAdapter.findChapterNumber(it.number)?.toInt() == selected
} ?: 0) + 1
if (progressChapterIndex < 0 || n < 1 || chapters == null) return
// Calculate the end index
val endIndex = minOf(progressChapterIndex + n, chapters.size)
// Make sure there are enough chapters
val chaptersToDownload = chapters.subList(progressChapterIndex, endIndex)
for (chapter in chaptersToDownload) {
onMangaChapterDownloadClick(chapter)
}
}
private fun updateChapters() {
val loadedChapters = model.getMangaChapters().value
if (loadedChapters != null) {
val chapters = loadedChapters[media.selected!!.sourceIndex]
if (chapters != null) {
headerAdapter.options = getScanlators(chapters)
val filteredChapters =
if (model.mangaReadSources?.get(media.selected!!.sourceIndex) is OfflineMangaParser) {
chapters
} else {
chapters.filterNot { (_, chapter) ->
chapter.scanlator in headerAdapter.hiddenScanlators
}
}
media.manga?.chapters = filteredChapters.toMutableMap()
//CHIP GROUP
val total = filteredChapters.size
val divisions = total.toDouble() / 10
start = 0
end = null
val limit = when {
(divisions < 25) -> 25
(divisions < 50) -> 50
else -> 100
}
headerAdapter.clearChips()
if (total > limit) {
val arr = filteredChapters.keys.toTypedArray()
val stored = ceil((total).toDouble() / limit).toInt()
val position = clamp(media.selected!!.chip, 0, stored - 1)
val last = if (position + 1 == stored) total else (limit * (position + 1))
start = limit * (position)
end = last - 1
headerAdapter.updateChips(
limit,
arr,
(1..stored).toList().toTypedArray(),
position
)
}
headerAdapter.subscribeButton(true)
reload()
}
}
}
private fun getScanlators(chap: MutableMap<String, MangaChapter>?): List<String> {
val scanlators = mutableListOf<String>()
if (chap != null) {
val chapters = chap.values
for (chapter in chapters) {
scanlators.add(chapter.scanlator ?: "Unknown")
}
}
return scanlators.distinct()
}
fun onSourceChange(i: Int): MangaParser {
media.manga?.chapters = null
reload()
val selected = model.loadSelected(media)
model.mangaReadSources?.get(selected.sourceIndex)?.showUserTextListener = null
selected.sourceIndex = i
selected.server = null
model.saveSelected(media.id, selected)
media.selected = selected
return model.mangaReadSources?.get(i)!!
}
fun onLangChange(i: Int, saveName: String) {
val selected = model.loadSelected(media)
selected.langIndex = i
model.saveSelected(media.id, selected)
media.selected = selected
PrefManager.removeCustomVal("${saveName}_${media.id}")
}
fun onScanlatorChange(list: List<String>) {
val selected = model.loadSelected(media)
selected.scanlators = list
model.saveSelected(media.id, selected)
media.selected = selected
}
fun loadChapters(i: Int, invalidate: Boolean) {
lifecycleScope.launch(Dispatchers.IO) { model.loadMangaChapters(media, i, invalidate) }
}
fun onIconPressed(viewType: Int, rev: Boolean) {
style = viewType
reverse = rev
media.selected!!.recyclerStyle = style
media.selected!!.recyclerReversed = reverse
model.saveSelected(media.id, media.selected!!)
reload()
}
fun onChipClicked(i: Int, s: Int, e: Int) {
media.selected!!.chip = i
start = s
end = e
model.saveSelected(media.id, media.selected!!)
reload()
}
var subscribed = false
fun onNotificationPressed(subscribed: Boolean, source: String) {
this.subscribed = subscribed
saveSubscription(media, subscribed)
snackString(
if (subscribed) getString(R.string.subscribed_notification, source)
else getString(R.string.unsubscribed_notification)
)
}
fun openSettings(pkg: MangaExtension.Installed) {
val changeUIVisibility: (Boolean) -> Unit = { show ->
val activity = activity
if (activity is MediaDetailsActivity && isAdded) {
activity.findViewById<AppBarLayout>(R.id.mediaAppBar).isVisible = show
activity.findViewById<ViewPager2>(R.id.mediaViewPager).isVisible = show
activity.findViewById<CardView>(R.id.mediaCover).isVisible = show
activity.findViewById<CardView>(R.id.mediaClose).isVisible = show
activity.navBar.isVisible = show
activity.findViewById<FrameLayout>(R.id.fragmentExtensionsContainer).isGone = show
}
}
var itemSelected = false
val allSettings = pkg.sources.filterIsInstance<ConfigurableSource>()
if (allSettings.isNotEmpty()) {
var selectedSetting = allSettings[0]
if (allSettings.size > 1) {
val names =
allSettings.map { LanguageMapper.getLanguageName(it.lang) }.toTypedArray()
requireContext().customAlertDialog().apply {
setTitle("Select a Source")
singleChoiceItems(names) { which ->
selectedSetting = allSettings[which]
itemSelected = true
val fragment =
MangaSourcePreferencesFragment().getInstance(selectedSetting.id) {
changeUIVisibility(true)
loadChapters(media.selected!!.sourceIndex, true)
}
parentFragmentManager.beginTransaction()
.setCustomAnimations(R.anim.slide_up, R.anim.slide_down)
.replace(R.id.fragmentExtensionsContainer, fragment)
.addToBackStack(null)
.commit()
}
onDismiss {
if (!itemSelected) {
changeUIVisibility(true)
}
}
show()
}
} else {
// If there's only one setting, proceed with the fragment transaction
val fragment = MangaSourcePreferencesFragment().getInstance(selectedSetting.id) {
changeUIVisibility(true)
loadChapters(media.selected!!.sourceIndex, true)
}
parentFragmentManager.beginTransaction()
.setCustomAnimations(R.anim.slide_up, R.anim.slide_down)
.replace(R.id.fragmentExtensionsContainer, fragment)
.addToBackStack(null)
.commit()
}
changeUIVisibility(false)
} else {
Toast.makeText(requireContext(), "Source is not configurable", Toast.LENGTH_SHORT)
.show()
}
}
fun onMangaChapterClick(i: MangaChapter) {
model.continueMedia = false
media.manga?.chapters?.get(i.uniqueNumber())?.let {
media.manga?.selectedChapter = i
model.saveSelected(media.id, media.selected!!)
ChapterLoaderDialog.newInstance(it, true)
.show(requireActivity().supportFragmentManager, "dialog")
}
}
fun onMangaChapterDownloadClick(i: MangaChapter) {
activity?.let {
if (!isNotificationPermissionGranted()) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
ActivityCompat.requestPermissions(
it,
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
1
)
}
}
fun continueDownload() {
model.continueMedia = false
media.manga?.chapters?.get(i.uniqueNumber())?.let { chapter ->
val parser =
model.mangaReadSources?.get(media.selected!!.sourceIndex) as? DynamicMangaParser
parser?.let {
CoroutineScope(Dispatchers.IO).launch {
val images = parser.imageList(chapter.sChapter)
// Create a download task
val downloadTask = MangaDownloaderService.DownloadTask(
title = media.mainName(),
chapter = chapter.title!!,
scanlator = chapter.scanlator ?: "Unknown",
imageData = images,
sourceMedia = media,
retries = 25,
simultaneousDownloads = 2
)
MangaServiceDataSingleton.downloadQueue.offer(downloadTask)
// If the service is not already running, start it
if (!MangaServiceDataSingleton.isServiceRunning) {
val intent = Intent(context, MangaDownloaderService::class.java)
withContext(Dispatchers.Main) {
ContextCompat.startForegroundService(requireContext(), intent)
}
MangaServiceDataSingleton.isServiceRunning = true
}
// Inform the adapter that the download has started
withContext(Dispatchers.Main) {
chapterAdapter.startDownload(i.uniqueNumber())
}
}
}
}
}
if (!hasDirAccess(it)) {
(it as MediaDetailsActivity).accessAlertDialog(it.launcher) { success ->
if (success) {
continueDownload()
} else {
snackString(getString(R.string.download_permission_required))
}
}
} else {
continueDownload()
}
}
}
private fun isNotificationPermissionGranted(): Boolean {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
return ActivityCompat.checkSelfPermission(
requireContext(),
Manifest.permission.POST_NOTIFICATIONS
) == PackageManager.PERMISSION_GRANTED
}
return true
}
fun onMangaChapterRemoveDownloadClick(i: MangaChapter) {
downloadManager.removeDownload(
DownloadedType(
media.mainName(),
i.number,
MediaType.MANGA
)
) {
chapterAdapter.deleteDownload(i)
}
}
fun onMangaChapterStopDownloadClick(i: MangaChapter) {
val cancelIntent = Intent().apply {
action = MangaDownloaderService.ACTION_CANCEL_DOWNLOAD
putExtra(MangaDownloaderService.EXTRA_CHAPTER, i)
}
requireContext().sendBroadcast(cancelIntent)
// Remove the download from the manager and update the UI
downloadManager.removeDownload(
DownloadedType(
media.mainName(),
i.number,
MediaType.MANGA
)
) {
chapterAdapter.purgeDownload(i.uniqueNumber())
}
}
private val downloadStatusReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (!this@MangaReadFragment::chapterAdapter.isInitialized) return
when (intent.action) {
ACTION_DOWNLOAD_STARTED -> {
val chapterNumber = intent.getStringExtra(EXTRA_CHAPTER_NUMBER)
chapterNumber?.let { chapterAdapter.startDownload(it) }
}
ACTION_DOWNLOAD_FINISHED -> {
val chapterNumber = intent.getStringExtra(EXTRA_CHAPTER_NUMBER)
chapterNumber?.let { chapterAdapter.stopDownload(it) }
}
ACTION_DOWNLOAD_FAILED -> {
val chapterNumber = intent.getStringExtra(EXTRA_CHAPTER_NUMBER)
chapterNumber?.let {
chapterAdapter.purgeDownload(it)
}
}
ACTION_DOWNLOAD_PROGRESS -> {
val chapterNumber = intent.getStringExtra(EXTRA_CHAPTER_NUMBER)
val progress = intent.getIntExtra("progress", 0)
chapterNumber?.let {
chapterAdapter.updateDownloadProgress(it, progress)
}
}
}
}
}
@SuppressLint("NotifyDataSetChanged")
private fun reload() {
val selected = model.loadSelected(media)
// Find latest chapter for subscription
selected.latest =
media.manga?.chapters?.values?.maxOfOrNull {
MediaNameAdapter.findChapterNumber(it.number) ?: 0f
} ?: 0f
selected.latest =
media.userProgress?.toFloat()?.takeIf { selected.latest < it } ?: selected.latest
model.saveSelected(media.id, selected)
headerAdapter.handleChapters()
chapterAdapter.notifyItemRangeRemoved(0, chapterAdapter.arr.size)
var arr: ArrayList<MangaChapter> = arrayListOf()
if (media.manga!!.chapters != null) {
val end = if (end != null && end!! < media.manga!!.chapters!!.size) end else null
arr.addAll(
media.manga!!.chapters!!.values.toList()
.slice(start..(end ?: (media.manga!!.chapters!!.size - 1)))
)
if (reverse)
arr = (arr.reversed() as? ArrayList<MangaChapter>) ?: arr
}
chapterAdapter.arr = arr
chapterAdapter.updateType(style ?: PrefManager.getVal(PrefName.MangaDefaultView))
chapterAdapter.notifyItemRangeInserted(0, arr.size)
}
override fun onDestroy() {
model.mangaReadSources?.flushText()
super.onDestroy()
requireContext().unregisterReceiver(downloadStatusReceiver)
}
private var state: Parcelable? = null
override fun onResume() {
super.onResume()
binding.mediaInfoProgressBar.visibility = progress
binding.mediaSourceRecycler.layoutManager?.onRestoreInstanceState(state)
requireActivity().setNavigationTheme()
}
override fun onPause() {
super.onPause()
state = binding.mediaSourceRecycler.layoutManager?.onSaveInstanceState()
}
companion object {
const val ACTION_DOWNLOAD_STARTED = "ani.dantotsu.ACTION_DOWNLOAD_STARTED"
const val ACTION_DOWNLOAD_FINISHED = "ani.dantotsu.ACTION_DOWNLOAD_FINISHED"
const val ACTION_DOWNLOAD_FAILED = "ani.dantotsu.ACTION_DOWNLOAD_FAILED"
const val ACTION_DOWNLOAD_PROGRESS = "ani.dantotsu.ACTION_DOWNLOAD_PROGRESS"
const val EXTRA_CHAPTER_NUMBER = "extra_chapter_number"
}
}