Move sources from java to kotlin dir
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
package org.koitharu.kotatsu.reader.data
|
||||
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||
|
||||
fun Manga.filterChapters(branch: String?): Manga {
|
||||
if (chapters.isNullOrEmpty()) return this
|
||||
return withChapters(chapters = chapters?.filter { it.branch == branch })
|
||||
}
|
||||
|
||||
private fun Manga.withChapters(chapters: List<MangaChapter>?) = Manga(
|
||||
id = id,
|
||||
title = title,
|
||||
altTitle = altTitle,
|
||||
url = url,
|
||||
publicUrl = publicUrl,
|
||||
rating = rating,
|
||||
isNsfw = isNsfw,
|
||||
coverUrl = coverUrl,
|
||||
tags = tags,
|
||||
state = state,
|
||||
author = author,
|
||||
largeCoverUrl = largeCoverUrl,
|
||||
description = description,
|
||||
chapters = chapters,
|
||||
source = source,
|
||||
)
|
||||
@@ -0,0 +1,70 @@
|
||||
package org.koitharu.kotatsu.reader.domain
|
||||
|
||||
import androidx.collection.LongSparseArray
|
||||
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
|
||||
|
||||
class ChapterPages private constructor(private val pages: ArrayDeque<ReaderPage>) : List<ReaderPage> by pages {
|
||||
|
||||
// map chapterId to index in pages deque
|
||||
private val indices = LongSparseArray<IntRange>()
|
||||
|
||||
constructor() : this(ArrayDeque())
|
||||
|
||||
val chaptersSize: Int
|
||||
get() = indices.size()
|
||||
|
||||
fun removeFirst() {
|
||||
val chapterId = pages.first().chapterId
|
||||
indices.remove(chapterId)
|
||||
var delta = 0
|
||||
while (pages.first().chapterId == chapterId) {
|
||||
pages.removeFirst()
|
||||
delta--
|
||||
}
|
||||
shiftIndices(delta)
|
||||
}
|
||||
|
||||
fun removeLast() {
|
||||
val chapterId = pages.last().chapterId
|
||||
indices.remove(chapterId)
|
||||
while (pages.last().chapterId == chapterId) {
|
||||
pages.removeLast()
|
||||
}
|
||||
}
|
||||
|
||||
fun addLast(id: Long, newPages: List<ReaderPage>) {
|
||||
indices.put(id, pages.size until (pages.size + newPages.size))
|
||||
pages.addAll(newPages)
|
||||
}
|
||||
|
||||
fun addFirst(id: Long, newPages: List<ReaderPage>) {
|
||||
shiftIndices(newPages.size)
|
||||
indices.put(id, newPages.indices)
|
||||
pages.addAll(0, newPages)
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
indices.clear()
|
||||
pages.clear()
|
||||
}
|
||||
|
||||
fun size(id: Long) = indices[id]?.run {
|
||||
endInclusive - start + 1
|
||||
} ?: 0
|
||||
|
||||
fun subList(id: Long): List<ReaderPage> {
|
||||
val range = indices[id] ?: return emptyList()
|
||||
return pages.subList(range.first, range.last + 1)
|
||||
}
|
||||
|
||||
private fun shiftIndices(delta: Int) {
|
||||
for (i in 0 until indices.size()) {
|
||||
val range = indices.valueAt(i)
|
||||
indices.setValueAt(i, range + delta)
|
||||
}
|
||||
}
|
||||
|
||||
private operator fun IntRange.plus(delta: Int): IntRange {
|
||||
return IntRange(start + delta, endInclusive + delta)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package org.koitharu.kotatsu.reader.domain
|
||||
|
||||
import android.util.LongSparseArray
|
||||
import dagger.hilt.android.scopes.ViewModelScoped
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
|
||||
import javax.inject.Inject
|
||||
|
||||
private const val PAGES_TRIM_THRESHOLD = 120
|
||||
|
||||
@ViewModelScoped
|
||||
class ChaptersLoader @Inject constructor(
|
||||
private val mangaRepositoryFactory: MangaRepository.Factory,
|
||||
) {
|
||||
|
||||
val chapters = LongSparseArray<MangaChapter>()
|
||||
private val chapterPages = ChapterPages()
|
||||
private val mutex = Mutex()
|
||||
|
||||
suspend fun loadPrevNextChapter(manga: Manga, currentId: Long, isNext: Boolean) {
|
||||
val chapters = manga.chapters ?: return
|
||||
val predicate: (MangaChapter) -> Boolean = { it.id == currentId }
|
||||
val index = if (isNext) chapters.indexOfFirst(predicate) else chapters.indexOfLast(predicate)
|
||||
if (index == -1) return
|
||||
val newChapter = chapters.getOrNull(if (isNext) index + 1 else index - 1) ?: return
|
||||
val newPages = loadChapter(manga, newChapter.id)
|
||||
mutex.withLock {
|
||||
if (chapterPages.chaptersSize > 1) {
|
||||
// trim pages
|
||||
if (chapterPages.size > PAGES_TRIM_THRESHOLD) {
|
||||
if (isNext) {
|
||||
chapterPages.removeFirst()
|
||||
} else {
|
||||
chapterPages.removeLast()
|
||||
}
|
||||
}
|
||||
}
|
||||
if (isNext) {
|
||||
chapterPages.addLast(newChapter.id, newPages)
|
||||
} else {
|
||||
chapterPages.addFirst(newChapter.id, newPages)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun loadSingleChapter(manga: Manga, chapterId: Long) {
|
||||
val pages = loadChapter(manga, chapterId)
|
||||
mutex.withLock {
|
||||
chapterPages.clear()
|
||||
chapterPages.addLast(chapterId, pages)
|
||||
}
|
||||
}
|
||||
|
||||
fun getPages(chapterId: Long): List<ReaderPage> {
|
||||
return chapterPages.subList(chapterId)
|
||||
}
|
||||
|
||||
fun getPagesCount(chapterId: Long): Int {
|
||||
return chapterPages.size(chapterId)
|
||||
}
|
||||
|
||||
fun last() = chapterPages.last()
|
||||
|
||||
fun first() = chapterPages.first()
|
||||
|
||||
fun snapshot() = chapterPages.toList()
|
||||
|
||||
private suspend fun loadChapter(manga: Manga, chapterId: Long): List<ReaderPage> {
|
||||
val chapter = checkNotNull(chapters[chapterId]) { "Requested chapter not found" }
|
||||
val repo = mangaRepositoryFactory.create(manga.source)
|
||||
return repo.getPages(chapter).mapIndexed { index, page ->
|
||||
ReaderPage(page, index, chapterId)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
package org.koitharu.kotatsu.reader.domain
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.net.Uri
|
||||
import androidx.annotation.AnyThread
|
||||
import androidx.collection.LongSparseArray
|
||||
import androidx.collection.set
|
||||
import dagger.hilt.android.ActivityRetainedLifecycle
|
||||
import dagger.hilt.android.lifecycle.RetainedLifecycle
|
||||
import dagger.hilt.android.scopes.ActivityRetainedScoped
|
||||
import kotlinx.coroutines.CoroutineExceptionHandler
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.plus
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okio.source
|
||||
import org.koitharu.kotatsu.core.network.CommonHeaders
|
||||
import org.koitharu.kotatsu.core.network.MangaHttpClient
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.util.RetainedLifecycleCoroutineScope
|
||||
import org.koitharu.kotatsu.core.util.ext.withProgress
|
||||
import org.koitharu.kotatsu.core.util.progress.ProgressDeferred
|
||||
import org.koitharu.kotatsu.local.data.CbzFilter
|
||||
import org.koitharu.kotatsu.local.data.PagesCache
|
||||
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.util.await
|
||||
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
|
||||
import org.koitharu.kotatsu.util.ext.printStackTraceDebug
|
||||
import java.io.File
|
||||
import java.util.LinkedList
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import java.util.zip.ZipFile
|
||||
import javax.inject.Inject
|
||||
import kotlin.coroutines.AbstractCoroutineContextElement
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
|
||||
@ActivityRetainedScoped
|
||||
class PageLoader @Inject constructor(
|
||||
lifecycle: ActivityRetainedLifecycle,
|
||||
@MangaHttpClient private val okHttp: OkHttpClient,
|
||||
private val cache: PagesCache,
|
||||
private val settings: AppSettings,
|
||||
private val mangaRepositoryFactory: MangaRepository.Factory,
|
||||
) : RetainedLifecycle.OnClearedListener {
|
||||
|
||||
init {
|
||||
lifecycle.addOnClearedListener(this)
|
||||
}
|
||||
|
||||
val loaderScope = RetainedLifecycleCoroutineScope(lifecycle) + InternalErrorHandler() + Dispatchers.Default
|
||||
|
||||
private val tasks = LongSparseArray<ProgressDeferred<File, Float>>()
|
||||
private val convertLock = Mutex()
|
||||
private val prefetchLock = Mutex()
|
||||
private var repository: MangaRepository? = null
|
||||
private val prefetchQueue = LinkedList<MangaPage>()
|
||||
private val counter = AtomicInteger(0)
|
||||
private var prefetchQueueLimit = PREFETCH_LIMIT_DEFAULT // TODO adaptive
|
||||
|
||||
override fun onCleared() {
|
||||
synchronized(tasks) {
|
||||
tasks.clear()
|
||||
}
|
||||
}
|
||||
|
||||
fun isPrefetchApplicable(): Boolean {
|
||||
return repository is RemoteMangaRepository && settings.isPagesPreloadEnabled()
|
||||
}
|
||||
|
||||
@AnyThread
|
||||
fun prefetch(pages: List<ReaderPage>) = loaderScope.launch {
|
||||
prefetchLock.withLock {
|
||||
for (page in pages.asReversed()) {
|
||||
if (tasks.containsKey(page.id)) {
|
||||
continue
|
||||
}
|
||||
prefetchQueue.offerFirst(page.toMangaPage())
|
||||
if (prefetchQueue.size > prefetchQueueLimit) {
|
||||
prefetchQueue.pollLast()
|
||||
}
|
||||
}
|
||||
}
|
||||
if (counter.get() == 0) {
|
||||
onIdle()
|
||||
}
|
||||
}
|
||||
|
||||
fun loadPageAsync(page: MangaPage, force: Boolean): ProgressDeferred<File, Float> {
|
||||
var task = tasks[page.id]
|
||||
if (force) {
|
||||
task?.cancel()
|
||||
} else if (task?.isCancelled == false) {
|
||||
return task
|
||||
}
|
||||
task = loadPageAsyncImpl(page, force)
|
||||
synchronized(tasks) {
|
||||
tasks[page.id] = task
|
||||
}
|
||||
return task
|
||||
}
|
||||
|
||||
suspend fun loadPage(page: MangaPage, force: Boolean): File {
|
||||
return loadPageAsync(page, force).await()
|
||||
}
|
||||
|
||||
suspend fun convertInPlace(file: File) {
|
||||
convertLock.withLock {
|
||||
runInterruptible(Dispatchers.Default) {
|
||||
val image = BitmapFactory.decodeFile(file.absolutePath)
|
||||
try {
|
||||
file.outputStream().use { out ->
|
||||
image.compress(Bitmap.CompressFormat.PNG, 100, out)
|
||||
}
|
||||
} finally {
|
||||
image.recycle()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getPageUrl(page: MangaPage): String {
|
||||
return getRepository(page.source).getPageUrl(page)
|
||||
}
|
||||
|
||||
private fun onIdle() = loaderScope.launch {
|
||||
prefetchLock.withLock {
|
||||
while (prefetchQueue.isNotEmpty()) {
|
||||
val page = prefetchQueue.pollFirst() ?: return@launch
|
||||
if (cache.get(page.url) == null) {
|
||||
synchronized(tasks) {
|
||||
tasks[page.id] = loadPageAsyncImpl(page, false)
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadPageAsyncImpl(page: MangaPage, skipCache: Boolean): ProgressDeferred<File, Float> {
|
||||
val progress = MutableStateFlow(PROGRESS_UNDEFINED)
|
||||
val deferred = loaderScope.async {
|
||||
if (!skipCache) {
|
||||
cache.get(page.url)?.let { return@async it }
|
||||
}
|
||||
counter.incrementAndGet()
|
||||
try {
|
||||
loadPageImpl(page, progress)
|
||||
} finally {
|
||||
if (counter.decrementAndGet() == 0) {
|
||||
onIdle()
|
||||
}
|
||||
}
|
||||
}
|
||||
return ProgressDeferred(deferred, progress)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun getRepository(source: MangaSource): MangaRepository {
|
||||
val result = repository
|
||||
return if (result != null && result.source == source) {
|
||||
result
|
||||
} else {
|
||||
mangaRepositoryFactory.create(source).also { repository = it }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadPageImpl(page: MangaPage, progress: MutableStateFlow<Float>): File {
|
||||
val pageUrl = getPageUrl(page)
|
||||
check(pageUrl.isNotBlank()) { "Cannot obtain full image url" }
|
||||
val uri = Uri.parse(pageUrl)
|
||||
return if (CbzFilter.isUriSupported(uri)) {
|
||||
runInterruptible(Dispatchers.IO) {
|
||||
ZipFile(uri.schemeSpecificPart)
|
||||
}.use { zip ->
|
||||
runInterruptible(Dispatchers.IO) {
|
||||
val entry = zip.getEntry(uri.fragment)
|
||||
zip.getInputStream(entry)
|
||||
}.use {
|
||||
cache.put(pageUrl, it.source())
|
||||
}
|
||||
}
|
||||
} else {
|
||||
val request = createPageRequest(page, pageUrl)
|
||||
okHttp.newCall(request).await().use { response ->
|
||||
check(response.isSuccessful) {
|
||||
"Invalid response: ${response.code} ${response.message} at $pageUrl"
|
||||
}
|
||||
val body = checkNotNull(response.body) {
|
||||
"Null response"
|
||||
}
|
||||
body.withProgress(progress).use {
|
||||
cache.put(pageUrl, it.source())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class InternalErrorHandler : AbstractCoroutineContextElement(CoroutineExceptionHandler),
|
||||
CoroutineExceptionHandler {
|
||||
|
||||
override fun handleException(context: CoroutineContext, exception: Throwable) {
|
||||
exception.printStackTraceDebug()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
private const val PROGRESS_UNDEFINED = -1f
|
||||
private const val PREFETCH_LIMIT_DEFAULT = 10
|
||||
|
||||
fun createPageRequest(page: MangaPage, pageUrl: String) = Request.Builder()
|
||||
.url(pageUrl)
|
||||
.get()
|
||||
.header(CommonHeaders.ACCEPT, "image/webp,image/png;q=0.9,image/jpeg,*/*;q=0.8")
|
||||
.cacheControl(CommonHeaders.CACHE_CONTROL_NO_STORE)
|
||||
.tag(MangaSource::class.java, page.source)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package org.koitharu.kotatsu.reader.domain
|
||||
|
||||
import android.graphics.ColorMatrix
|
||||
import android.graphics.ColorMatrixColorFilter
|
||||
|
||||
class ReaderColorFilter(
|
||||
val brightness: Float,
|
||||
val contrast: Float,
|
||||
) {
|
||||
|
||||
val isEmpty: Boolean
|
||||
get() = brightness == 0f && contrast == 0f
|
||||
|
||||
fun toColorFilter(): ColorMatrixColorFilter {
|
||||
val cm = ColorMatrix()
|
||||
val scale = brightness + 1f
|
||||
cm.setScale(scale, scale, scale, 1f)
|
||||
cm.setContrast(contrast)
|
||||
return ColorMatrixColorFilter(cm)
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
other as ReaderColorFilter
|
||||
|
||||
if (brightness != other.brightness) return false
|
||||
if (contrast != other.contrast) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = brightness.hashCode()
|
||||
result = 31 * result + contrast.hashCode()
|
||||
return result
|
||||
}
|
||||
|
||||
private fun ColorMatrix.setContrast(contrast: Float) {
|
||||
val scale = contrast + 1f
|
||||
val translate = (-.5f * scale + .5f) * 255f
|
||||
val array = floatArrayOf(
|
||||
scale, 0f, 0f, 0f, translate,
|
||||
0f, scale, 0f, 0f, translate,
|
||||
0f, 0f, scale, 0f, translate,
|
||||
0f, 0f, 0f, 1f, 0f,
|
||||
)
|
||||
val matrix = ColorMatrix(array)
|
||||
postConcat(matrix)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package org.koitharu.kotatsu.reader.ui
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.model.parcelable.ParcelableMangaChapters
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.ui.BaseBottomSheet
|
||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||
import org.koitharu.kotatsu.core.util.RecyclerViewScrollCallback
|
||||
import org.koitharu.kotatsu.core.util.ext.getParcelableCompat
|
||||
import org.koitharu.kotatsu.core.util.ext.withArgs
|
||||
import org.koitharu.kotatsu.databinding.SheetChaptersBinding
|
||||
import org.koitharu.kotatsu.details.ui.adapter.ChaptersAdapter
|
||||
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
|
||||
import org.koitharu.kotatsu.details.ui.model.toListItem
|
||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||
import javax.inject.Inject
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@AndroidEntryPoint
|
||||
class ChaptersBottomSheet : BaseBottomSheet<SheetChaptersBinding>(), OnListItemClickListener<ChapterListItem> {
|
||||
|
||||
@Inject
|
||||
lateinit var settings: AppSettings
|
||||
|
||||
override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): SheetChaptersBinding {
|
||||
return SheetChaptersBinding.inflate(inflater, container, false)
|
||||
}
|
||||
|
||||
override fun onViewBindingCreated(binding: SheetChaptersBinding, savedInstanceState: Bundle?) {
|
||||
super.onViewBindingCreated(binding, savedInstanceState)
|
||||
val chapters = arguments?.getParcelableCompat<ParcelableMangaChapters>(ARG_CHAPTERS)?.chapters
|
||||
if (chapters.isNullOrEmpty()) {
|
||||
dismissAllowingStateLoss()
|
||||
return
|
||||
}
|
||||
val currentId = requireArguments().getLong(ARG_CURRENT_ID, 0L)
|
||||
val currentPosition = chapters.indexOfFirst { it.id == currentId }
|
||||
val items = chapters.mapIndexed { index, chapter ->
|
||||
chapter.toListItem(
|
||||
isCurrent = index == currentPosition,
|
||||
isUnread = index > currentPosition,
|
||||
isNew = false,
|
||||
isDownloaded = false,
|
||||
)
|
||||
}
|
||||
binding.recyclerView.adapter = ChaptersAdapter(this).also { adapter ->
|
||||
if (currentPosition >= 0) {
|
||||
val targetPosition = (currentPosition - 1).coerceAtLeast(0)
|
||||
val offset = (resources.getDimensionPixelSize(R.dimen.chapter_list_item_height) * 0.6).roundToInt()
|
||||
adapter.setItems(items, RecyclerViewScrollCallback(binding.recyclerView, targetPosition, offset))
|
||||
} else {
|
||||
adapter.items = items
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onItemClick(item: ChapterListItem, view: View) {
|
||||
((parentFragment as? OnChapterChangeListener) ?: (activity as? OnChapterChangeListener))?.let {
|
||||
dismiss()
|
||||
it.onChapterChanged(item.chapter)
|
||||
}
|
||||
}
|
||||
|
||||
fun interface OnChapterChangeListener {
|
||||
|
||||
fun onChapterChanged(chapter: MangaChapter)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
private const val ARG_CHAPTERS = "chapters"
|
||||
private const val ARG_CURRENT_ID = "current_id"
|
||||
|
||||
private const val TAG = "ChaptersBottomSheet"
|
||||
|
||||
fun show(
|
||||
fm: FragmentManager,
|
||||
chapters: List<MangaChapter>,
|
||||
currentId: Long,
|
||||
) = ChaptersBottomSheet().withArgs(2) {
|
||||
putParcelable(ARG_CHAPTERS, ParcelableMangaChapters(chapters))
|
||||
putLong(ARG_CURRENT_ID, currentId)
|
||||
}.show(fm, TAG)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package org.koitharu.kotatsu.reader.ui
|
||||
|
||||
import com.google.android.material.slider.LabelFormatter
|
||||
import org.koitharu.kotatsu.parsers.util.format
|
||||
|
||||
class PageLabelFormatter : LabelFormatter {
|
||||
|
||||
override fun getFormattedValue(value: Float): String {
|
||||
return (value + 1).format(0)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package org.koitharu.kotatsu.reader.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.provider.DocumentsContract
|
||||
import android.webkit.MimeTypeMap
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.core.net.toUri
|
||||
|
||||
class PageSaveContract : ActivityResultContracts.CreateDocument("image/*") {
|
||||
|
||||
override fun createIntent(context: Context, input: String): Intent {
|
||||
val intent = super.createIntent(context, input)
|
||||
intent.type = MimeTypeMap.getSingleton()
|
||||
.getMimeTypeFromExtension(input.substringAfterLast('.')) ?: "image/*"
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
intent.putExtra(
|
||||
DocumentsContract.EXTRA_INITIAL_URI,
|
||||
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES).toUri(),
|
||||
)
|
||||
}
|
||||
return intent
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package org.koitharu.kotatsu.reader.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.webkit.MimeTypeMap
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.core.net.toUri
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okio.IOException
|
||||
import okio.buffer
|
||||
import okio.sink
|
||||
import okio.source
|
||||
import org.koitharu.kotatsu.core.parser.MangaDataRepository
|
||||
import org.koitharu.kotatsu.core.util.ext.writeAllCancellable
|
||||
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||
import org.koitharu.kotatsu.parsers.util.toFileNameSafe
|
||||
import org.koitharu.kotatsu.reader.domain.PageLoader
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
import kotlin.coroutines.Continuation
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
private const val MAX_FILENAME_LENGTH = 10
|
||||
private const val EXTENSION_FALLBACK = "png"
|
||||
|
||||
class PageSaveHelper @Inject constructor(
|
||||
@ApplicationContext context: Context,
|
||||
) {
|
||||
|
||||
private var continuation: Continuation<Uri>? = null
|
||||
private val contentResolver = context.contentResolver
|
||||
|
||||
suspend fun savePage(
|
||||
pageLoader: PageLoader,
|
||||
page: MangaPage,
|
||||
saveLauncher: ActivityResultLauncher<String>,
|
||||
): Uri {
|
||||
val pageUrl = pageLoader.getPageUrl(page)
|
||||
val pageFile = pageLoader.loadPage(page, force = false)
|
||||
val proposedName = getProposedFileName(pageUrl, pageFile)
|
||||
val destination = withContext(Dispatchers.Main) {
|
||||
suspendCancellableCoroutine<Uri> { cont ->
|
||||
continuation = cont
|
||||
saveLauncher.launch(proposedName)
|
||||
}.also {
|
||||
continuation = null
|
||||
}
|
||||
}
|
||||
runInterruptible(Dispatchers.IO) {
|
||||
contentResolver.openOutputStream(destination)?.sink()?.buffer()
|
||||
}?.use { output ->
|
||||
pageFile.source().use { input ->
|
||||
output.writeAllCancellable(input)
|
||||
}
|
||||
} ?: throw IOException("Output stream is null")
|
||||
return destination
|
||||
}
|
||||
|
||||
fun onActivityResult(uri: Uri): Boolean = continuation?.apply {
|
||||
resume(uri)
|
||||
} != null
|
||||
|
||||
private suspend fun getProposedFileName(url: String, file: File): String {
|
||||
var name = if (url.startsWith("cbz://")) {
|
||||
requireNotNull(url.toUri().fragment)
|
||||
} else {
|
||||
url.toHttpUrl().pathSegments.last()
|
||||
}
|
||||
var extension = name.substringAfterLast('.', "")
|
||||
name = name.substringBeforeLast('.')
|
||||
if (extension.length !in 2..4) {
|
||||
val mimeType = MangaDataRepository.getImageMimeType(file)
|
||||
extension = if (mimeType != null) {
|
||||
MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType) ?: EXTENSION_FALLBACK
|
||||
} else {
|
||||
EXTENSION_FALLBACK
|
||||
}
|
||||
}
|
||||
return name.toFileNameSafe().take(MAX_FILENAME_LENGTH) + "." + extension
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,438 @@
|
||||
package org.koitharu.kotatsu.reader.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.transition.Fade
|
||||
import android.transition.Slide
|
||||
import android.transition.TransitionManager
|
||||
import android.transition.TransitionSet
|
||||
import android.view.Gravity
|
||||
import android.view.KeyEvent
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.view.WindowManager
|
||||
import androidx.activity.viewModels
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.core.view.OnApplyWindowInsetsListener
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.isGone
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.view.updatePadding
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
||||
import org.koitharu.kotatsu.core.exceptions.resolve.DialogErrorObserver
|
||||
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
|
||||
import org.koitharu.kotatsu.core.parser.MangaIntent
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.prefs.ReaderMode
|
||||
import org.koitharu.kotatsu.core.ui.BaseFullscreenActivity
|
||||
import org.koitharu.kotatsu.core.util.GridTouchHelper
|
||||
import org.koitharu.kotatsu.core.util.IdlingDetector
|
||||
import org.koitharu.kotatsu.core.util.ShareHelper
|
||||
import org.koitharu.kotatsu.core.util.ext.hasGlobalPoint
|
||||
import org.koitharu.kotatsu.core.util.ext.isRtl
|
||||
import org.koitharu.kotatsu.core.util.ext.observeWithPrevious
|
||||
import org.koitharu.kotatsu.core.util.ext.postDelayed
|
||||
import org.koitharu.kotatsu.core.util.ext.setValueRounded
|
||||
import org.koitharu.kotatsu.databinding.ActivityReaderBinding
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||
import org.koitharu.kotatsu.reader.ui.config.ReaderConfigBottomSheet
|
||||
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
|
||||
import org.koitharu.kotatsu.reader.ui.pager.ReaderUiState
|
||||
import org.koitharu.kotatsu.reader.ui.thumbnails.OnPageSelectListener
|
||||
import org.koitharu.kotatsu.reader.ui.thumbnails.PagesThumbnailsSheet
|
||||
import org.koitharu.kotatsu.settings.SettingsActivity
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class ReaderActivity :
|
||||
BaseFullscreenActivity<ActivityReaderBinding>(),
|
||||
ChaptersBottomSheet.OnChapterChangeListener,
|
||||
GridTouchHelper.OnGridTouchListener,
|
||||
OnPageSelectListener,
|
||||
ReaderConfigBottomSheet.Callback,
|
||||
ReaderControlDelegate.OnInteractionListener,
|
||||
OnApplyWindowInsetsListener,
|
||||
IdlingDetector.Callback {
|
||||
|
||||
@Inject
|
||||
lateinit var settings: AppSettings
|
||||
|
||||
private val idlingDetector = IdlingDetector(TimeUnit.SECONDS.toMillis(10), this)
|
||||
|
||||
private val viewModel: ReaderViewModel by viewModels()
|
||||
|
||||
override val readerMode: ReaderMode?
|
||||
get() = readerManager.currentMode
|
||||
|
||||
override var isAutoScrollEnabled: Boolean
|
||||
get() = scrollTimer.isEnabled
|
||||
set(value) {
|
||||
scrollTimer.isEnabled = value
|
||||
}
|
||||
|
||||
@Inject
|
||||
lateinit var scrollTimerFactory: ScrollTimer.Factory
|
||||
|
||||
private lateinit var scrollTimer: ScrollTimer
|
||||
private lateinit var touchHelper: GridTouchHelper
|
||||
private lateinit var controlDelegate: ReaderControlDelegate
|
||||
private var gestureInsets: Insets = Insets.NONE
|
||||
private lateinit var readerManager: ReaderManager
|
||||
private val hideUiRunnable = Runnable { setUiIsVisible(false) }
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(ActivityReaderBinding.inflate(layoutInflater))
|
||||
readerManager = ReaderManager(supportFragmentManager, R.id.container)
|
||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||
touchHelper = GridTouchHelper(this, this)
|
||||
scrollTimer = scrollTimerFactory.create(this, this)
|
||||
controlDelegate = ReaderControlDelegate(settings, this, this)
|
||||
viewBinding.toolbarBottom.setOnMenuItemClickListener(::onOptionsItemSelected)
|
||||
viewBinding.slider.setLabelFormatter(PageLabelFormatter())
|
||||
ReaderSliderListener(this, viewModel).attachToSlider(viewBinding.slider)
|
||||
insetsDelegate.interceptingWindowInsetsListener = this
|
||||
idlingDetector.bindToLifecycle(this)
|
||||
|
||||
viewModel.onError.observe(
|
||||
this,
|
||||
DialogErrorObserver(
|
||||
host = viewBinding.container,
|
||||
fragment = null,
|
||||
resolver = exceptionResolver,
|
||||
onResolved = { isResolved ->
|
||||
if (isResolved) {
|
||||
viewModel.reload()
|
||||
} else if (viewModel.content.value?.pages.isNullOrEmpty()) {
|
||||
finishAfterTransition()
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
viewModel.readerMode.observe(this, this::onInitReader)
|
||||
viewModel.onPageSaved.observe(this, this::onPageSaved)
|
||||
viewModel.uiState.observeWithPrevious(this, this::onUiStateChanged)
|
||||
viewModel.isLoading.observe(this, this::onLoadingStateChanged)
|
||||
viewModel.content.observe(this) {
|
||||
onLoadingStateChanged(viewModel.isLoading.value == true)
|
||||
}
|
||||
viewModel.isScreenshotsBlockEnabled.observe(this, this::setWindowSecure)
|
||||
viewModel.isInfoBarEnabled.observe(this, ::onReaderBarChanged)
|
||||
viewModel.isBookmarkAdded.observe(this, this::onBookmarkStateChanged)
|
||||
viewModel.onShowToast.observe(this) { msgId ->
|
||||
Snackbar.make(viewBinding.container, msgId, Snackbar.LENGTH_SHORT)
|
||||
.setAnchorView(viewBinding.appbarBottom)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onUserInteraction() {
|
||||
super.onUserInteraction()
|
||||
scrollTimer.onUserInteraction()
|
||||
idlingDetector.onUserInteraction()
|
||||
}
|
||||
|
||||
override fun onIdle() {
|
||||
viewModel.saveCurrentState(readerManager.currentReader?.getCurrentState())
|
||||
}
|
||||
|
||||
private fun onInitReader(mode: ReaderMode) {
|
||||
if (readerManager.currentMode != mode) {
|
||||
readerManager.replace(mode)
|
||||
}
|
||||
if (viewBinding.appbarTop.isVisible) {
|
||||
lifecycle.postDelayed(hideUiRunnable, TimeUnit.SECONDS.toMillis(1))
|
||||
}
|
||||
viewBinding.slider.isRtl = mode == ReaderMode.REVERSED
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||
menuInflater.inflate(R.menu.opt_reader_top, menu)
|
||||
return super.onCreateOptionsMenu(menu)
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
when (item.itemId) {
|
||||
R.id.action_settings -> {
|
||||
startActivity(SettingsActivity.newReaderSettingsIntent(this))
|
||||
}
|
||||
|
||||
R.id.action_chapters -> {
|
||||
ChaptersBottomSheet.show(
|
||||
supportFragmentManager,
|
||||
viewModel.manga?.chapters.orEmpty(),
|
||||
viewModel.getCurrentState()?.chapterId ?: 0L,
|
||||
)
|
||||
}
|
||||
|
||||
R.id.action_pages_thumbs -> {
|
||||
val state = viewModel.getCurrentState() ?: return false
|
||||
PagesThumbnailsSheet.show(
|
||||
supportFragmentManager,
|
||||
viewModel.manga ?: return false,
|
||||
state.chapterId,
|
||||
state.page,
|
||||
)
|
||||
}
|
||||
|
||||
R.id.action_bookmark -> {
|
||||
if (viewModel.isBookmarkAdded.value == true) {
|
||||
viewModel.removeBookmark()
|
||||
} else {
|
||||
viewModel.addBookmark()
|
||||
}
|
||||
}
|
||||
|
||||
R.id.action_options -> {
|
||||
viewModel.saveCurrentState(readerManager.currentReader?.getCurrentState())
|
||||
val currentMode = readerManager.currentMode ?: return false
|
||||
ReaderConfigBottomSheet.show(supportFragmentManager, currentMode)
|
||||
}
|
||||
|
||||
else -> return super.onOptionsItemSelected(item)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun onLoadingStateChanged(isLoading: Boolean) {
|
||||
val hasPages = !viewModel.content.value?.pages.isNullOrEmpty()
|
||||
viewBinding.layoutLoading.isVisible = isLoading && !hasPages
|
||||
if (isLoading && hasPages) {
|
||||
viewBinding.toastView.show(R.string.loading_)
|
||||
} else {
|
||||
viewBinding.toastView.hide()
|
||||
}
|
||||
val menu = viewBinding.toolbarBottom.menu
|
||||
menu.findItem(R.id.action_bookmark).isVisible = hasPages
|
||||
menu.findItem(R.id.action_pages_thumbs).isVisible = hasPages
|
||||
}
|
||||
|
||||
override fun onGridTouch(area: Int) {
|
||||
controlDelegate.onGridTouch(area, viewBinding.container)
|
||||
}
|
||||
|
||||
override fun onProcessTouch(rawX: Int, rawY: Int): Boolean {
|
||||
return if (
|
||||
rawX <= gestureInsets.left ||
|
||||
rawY <= gestureInsets.top ||
|
||||
rawX >= viewBinding.root.width - gestureInsets.right ||
|
||||
rawY >= viewBinding.root.height - gestureInsets.bottom ||
|
||||
viewBinding.appbarTop.hasGlobalPoint(rawX, rawY) ||
|
||||
viewBinding.appbarBottom?.hasGlobalPoint(rawX, rawY) == true
|
||||
) {
|
||||
false
|
||||
} else {
|
||||
val touchables = window.peekDecorView()?.touchables
|
||||
touchables?.none { it.hasGlobalPoint(rawX, rawY) } ?: true
|
||||
}
|
||||
}
|
||||
|
||||
override fun dispatchTouchEvent(ev: MotionEvent): Boolean {
|
||||
touchHelper.dispatchTouchEvent(ev)
|
||||
return super.dispatchTouchEvent(ev)
|
||||
}
|
||||
|
||||
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
|
||||
return controlDelegate.onKeyDown(keyCode, event) || super.onKeyDown(keyCode, event)
|
||||
}
|
||||
|
||||
override fun onKeyUp(keyCode: Int, event: KeyEvent?): Boolean {
|
||||
return controlDelegate.onKeyUp(keyCode, event) || super.onKeyUp(keyCode, event)
|
||||
}
|
||||
|
||||
override fun onChapterChanged(chapter: MangaChapter) {
|
||||
viewModel.switchChapter(chapter.id, 0)
|
||||
}
|
||||
|
||||
override fun onPageSelected(page: ReaderPage) {
|
||||
lifecycleScope.launch(Dispatchers.Default) {
|
||||
val pages = viewModel.content.value?.pages ?: return@launch
|
||||
val index = pages.indexOfFirst { it.chapterId == page.chapterId && it.id == page.id }
|
||||
if (index != -1) {
|
||||
withContext(Dispatchers.Main) {
|
||||
readerManager.currentReader?.switchPageTo(index, true)
|
||||
}
|
||||
} else {
|
||||
viewModel.switchChapter(page.chapterId, page.index)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onReaderModeChanged(mode: ReaderMode) {
|
||||
viewModel.saveCurrentState(readerManager.currentReader?.getCurrentState())
|
||||
viewModel.switchMode(mode)
|
||||
}
|
||||
|
||||
private fun onPageSaved(uri: Uri?) {
|
||||
if (uri != null) {
|
||||
Snackbar.make(viewBinding.container, R.string.page_saved, Snackbar.LENGTH_LONG)
|
||||
.setAnchorView(viewBinding.appbarBottom)
|
||||
.setAction(R.string.share) {
|
||||
ShareHelper(this).shareImage(uri)
|
||||
}.show()
|
||||
} else {
|
||||
Snackbar.make(viewBinding.container, R.string.error_occurred, Snackbar.LENGTH_SHORT)
|
||||
.setAnchorView(viewBinding.appbarBottom)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
|
||||
private fun setWindowSecure(isSecure: Boolean) {
|
||||
if (isSecure) {
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
|
||||
} else {
|
||||
window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setUiIsVisible(isUiVisible: Boolean) {
|
||||
if (viewBinding.appbarTop.isVisible != isUiVisible) {
|
||||
val transition = TransitionSet()
|
||||
.setOrdering(TransitionSet.ORDERING_TOGETHER)
|
||||
.addTransition(Slide(Gravity.TOP).addTarget(viewBinding.appbarTop))
|
||||
.addTransition(Fade().addTarget(viewBinding.infoBar))
|
||||
viewBinding.appbarBottom?.let { bottomBar ->
|
||||
transition.addTransition(Slide(Gravity.BOTTOM).addTarget(bottomBar))
|
||||
}
|
||||
TransitionManager.beginDelayedTransition(viewBinding.root, transition)
|
||||
viewBinding.appbarTop.isVisible = isUiVisible
|
||||
viewBinding.appbarBottom?.isVisible = isUiVisible
|
||||
viewBinding.infoBar.isGone = isUiVisible || (viewModel.isInfoBarEnabled.value == false)
|
||||
if (isUiVisible) {
|
||||
showSystemUI()
|
||||
} else {
|
||||
hideSystemUI()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat {
|
||||
gestureInsets = insets.getInsets(WindowInsetsCompat.Type.systemGestures())
|
||||
val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||
viewBinding.appbarTop.updatePadding(
|
||||
top = systemBars.top,
|
||||
right = systemBars.right,
|
||||
left = systemBars.left,
|
||||
)
|
||||
viewBinding.appbarBottom?.updatePadding(
|
||||
bottom = systemBars.bottom,
|
||||
right = systemBars.right,
|
||||
left = systemBars.left,
|
||||
)
|
||||
return WindowInsetsCompat.Builder(insets)
|
||||
.setInsets(WindowInsetsCompat.Type.systemBars(), Insets.NONE)
|
||||
.build()
|
||||
}
|
||||
|
||||
override fun onWindowInsetsChanged(insets: Insets) = Unit
|
||||
|
||||
override fun switchPageBy(delta: Int) {
|
||||
readerManager.currentReader?.switchPageBy(delta)
|
||||
}
|
||||
|
||||
override fun scrollBy(delta: Int): Boolean {
|
||||
return readerManager.currentReader?.scrollBy(delta) ?: false
|
||||
}
|
||||
|
||||
override fun toggleUiVisibility() {
|
||||
setUiIsVisible(!viewBinding.appbarTop.isVisible)
|
||||
}
|
||||
|
||||
override fun isReaderResumed(): Boolean {
|
||||
val reader = readerManager.currentReader ?: return false
|
||||
return reader.isResumed && supportFragmentManager.fragments.lastOrNull() === reader
|
||||
}
|
||||
|
||||
private fun onReaderBarChanged(isBarEnabled: Boolean) {
|
||||
viewBinding.infoBar.isVisible = isBarEnabled && viewBinding.appbarTop.isGone
|
||||
}
|
||||
|
||||
private fun onBookmarkStateChanged(isAdded: Boolean) {
|
||||
val menuItem = viewBinding.toolbarBottom.menu.findItem(R.id.action_bookmark) ?: return
|
||||
menuItem.setTitle(if (isAdded) R.string.bookmark_remove else R.string.bookmark_add)
|
||||
menuItem.setIcon(if (isAdded) R.drawable.ic_bookmark_added else R.drawable.ic_bookmark)
|
||||
}
|
||||
|
||||
private fun onUiStateChanged(uiState: ReaderUiState?, previous: ReaderUiState?) {
|
||||
title = uiState?.chapterName ?: uiState?.mangaName ?: getString(R.string.loading_)
|
||||
viewBinding.infoBar.update(uiState)
|
||||
if (uiState == null) {
|
||||
supportActionBar?.subtitle = null
|
||||
viewBinding.slider.isVisible = false
|
||||
return
|
||||
}
|
||||
supportActionBar?.subtitle = if (uiState.chapterNumber in 1..uiState.chaptersTotal) {
|
||||
getString(R.string.chapter_d_of_d, uiState.chapterNumber, uiState.chaptersTotal)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
if (previous?.chapterName != null && uiState.chapterName != previous.chapterName) {
|
||||
if (!uiState.chapterName.isNullOrEmpty()) {
|
||||
viewBinding.toastView.showTemporary(uiState.chapterName, TOAST_DURATION)
|
||||
}
|
||||
}
|
||||
if (uiState.isSliderAvailable()) {
|
||||
viewBinding.slider.valueTo = uiState.totalPages.toFloat() - 1
|
||||
viewBinding.slider.setValueRounded(uiState.currentPage.toFloat())
|
||||
viewBinding.slider.isVisible = true
|
||||
} else {
|
||||
viewBinding.slider.isVisible = false
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
const val ACTION_MANGA_READ = "${BuildConfig.APPLICATION_ID}.action.READ_MANGA"
|
||||
const val EXTRA_STATE = "state"
|
||||
const val EXTRA_BRANCH = "branch"
|
||||
const val EXTRA_INCOGNITO = "incognito"
|
||||
private const val TOAST_DURATION = 1500L
|
||||
|
||||
fun newIntent(context: Context, manga: Manga): Intent {
|
||||
return Intent(context, ReaderActivity::class.java)
|
||||
.putExtra(MangaIntent.KEY_MANGA, ParcelableManga(manga, withChapters = true))
|
||||
}
|
||||
|
||||
fun newIntent(context: Context, manga: Manga, branch: String?, isIncognitoMode: Boolean): Intent {
|
||||
return Intent(context, ReaderActivity::class.java)
|
||||
.putExtra(MangaIntent.KEY_MANGA, ParcelableManga(manga, withChapters = true))
|
||||
.putExtra(EXTRA_BRANCH, branch)
|
||||
.putExtra(EXTRA_INCOGNITO, isIncognitoMode)
|
||||
}
|
||||
|
||||
fun newIntent(context: Context, manga: Manga, state: ReaderState?): Intent {
|
||||
return Intent(context, ReaderActivity::class.java)
|
||||
.putExtra(MangaIntent.KEY_MANGA, ParcelableManga(manga, withChapters = true))
|
||||
.putExtra(EXTRA_STATE, state)
|
||||
}
|
||||
|
||||
fun newIntent(context: Context, bookmark: Bookmark): Intent {
|
||||
val state = ReaderState(
|
||||
chapterId = bookmark.chapterId,
|
||||
page = bookmark.page,
|
||||
scroll = bookmark.scroll,
|
||||
)
|
||||
return newIntent(context, bookmark.manga, state)
|
||||
.putExtra(EXTRA_INCOGNITO, true)
|
||||
}
|
||||
|
||||
fun newIntent(context: Context, mangaId: Long): Intent {
|
||||
return Intent(context, ReaderActivity::class.java)
|
||||
.putExtra(MangaIntent.KEY_ID, mangaId)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package org.koitharu.kotatsu.reader.ui
|
||||
|
||||
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
|
||||
|
||||
data class ReaderContent(
|
||||
val pages: List<ReaderPage>,
|
||||
val state: ReaderState?
|
||||
)
|
||||
@@ -0,0 +1,148 @@
|
||||
package org.koitharu.kotatsu.reader.ui
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import android.view.KeyEvent
|
||||
import android.view.SoundEffectConstants
|
||||
import android.view.View
|
||||
import androidx.lifecycle.DefaultLifecycleObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.prefs.ReaderMode
|
||||
import org.koitharu.kotatsu.core.util.GridTouchHelper
|
||||
|
||||
class ReaderControlDelegate(
|
||||
private val settings: AppSettings,
|
||||
private val listener: OnInteractionListener,
|
||||
owner: LifecycleOwner,
|
||||
) : DefaultLifecycleObserver, SharedPreferences.OnSharedPreferenceChangeListener {
|
||||
|
||||
private var isTapSwitchEnabled: Boolean = true
|
||||
private var isVolumeKeysSwitchEnabled: Boolean = false
|
||||
private var isReaderTapsAdaptive: Boolean = true
|
||||
|
||||
init {
|
||||
owner.lifecycle.addObserver(this)
|
||||
settings.subscribe(this)
|
||||
updateSettings()
|
||||
}
|
||||
|
||||
override fun onDestroy(owner: LifecycleOwner) {
|
||||
settings.unsubscribe(this)
|
||||
owner.lifecycle.removeObserver(this)
|
||||
super.onDestroy(owner)
|
||||
}
|
||||
|
||||
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) {
|
||||
updateSettings()
|
||||
}
|
||||
|
||||
fun onGridTouch(area: Int, view: View) {
|
||||
when (area) {
|
||||
GridTouchHelper.AREA_CENTER -> {
|
||||
listener.toggleUiVisibility()
|
||||
view.playSoundEffect(SoundEffectConstants.CLICK)
|
||||
}
|
||||
|
||||
GridTouchHelper.AREA_TOP -> if (isTapSwitchEnabled) {
|
||||
listener.switchPageBy(-1)
|
||||
view.playSoundEffect(SoundEffectConstants.NAVIGATION_UP)
|
||||
}
|
||||
|
||||
GridTouchHelper.AREA_LEFT -> if (isTapSwitchEnabled) {
|
||||
listener.switchPageBy(if (isReaderTapsReversed()) 1 else -1)
|
||||
view.playSoundEffect(SoundEffectConstants.NAVIGATION_LEFT)
|
||||
}
|
||||
|
||||
GridTouchHelper.AREA_BOTTOM -> if (isTapSwitchEnabled) {
|
||||
listener.switchPageBy(1)
|
||||
view.playSoundEffect(SoundEffectConstants.NAVIGATION_DOWN)
|
||||
}
|
||||
|
||||
GridTouchHelper.AREA_RIGHT -> if (isTapSwitchEnabled) {
|
||||
listener.switchPageBy(if (isReaderTapsReversed()) -1 else 1)
|
||||
view.playSoundEffect(SoundEffectConstants.NAVIGATION_RIGHT)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onKeyDown(keyCode: Int, @Suppress("UNUSED_PARAMETER") event: KeyEvent?): Boolean = when (keyCode) {
|
||||
KeyEvent.KEYCODE_VOLUME_UP -> if (isVolumeKeysSwitchEnabled) {
|
||||
listener.switchPageBy(-1)
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
|
||||
KeyEvent.KEYCODE_VOLUME_DOWN -> if (isVolumeKeysSwitchEnabled) {
|
||||
listener.switchPageBy(1)
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
|
||||
KeyEvent.KEYCODE_SPACE,
|
||||
KeyEvent.KEYCODE_PAGE_DOWN,
|
||||
KeyEvent.KEYCODE_SYSTEM_NAVIGATION_DOWN,
|
||||
KeyEvent.KEYCODE_DPAD_DOWN,
|
||||
-> {
|
||||
listener.switchPageBy(1)
|
||||
true
|
||||
}
|
||||
|
||||
KeyEvent.KEYCODE_DPAD_RIGHT -> {
|
||||
listener.switchPageBy(if (isReaderTapsReversed()) -1 else 1)
|
||||
true
|
||||
}
|
||||
|
||||
KeyEvent.KEYCODE_PAGE_UP,
|
||||
KeyEvent.KEYCODE_SYSTEM_NAVIGATION_UP,
|
||||
KeyEvent.KEYCODE_DPAD_UP,
|
||||
-> {
|
||||
listener.switchPageBy(-1)
|
||||
true
|
||||
}
|
||||
|
||||
KeyEvent.KEYCODE_DPAD_LEFT -> {
|
||||
listener.switchPageBy(if (isReaderTapsReversed()) 1 else -1)
|
||||
true
|
||||
}
|
||||
|
||||
KeyEvent.KEYCODE_DPAD_CENTER -> {
|
||||
listener.toggleUiVisibility()
|
||||
true
|
||||
}
|
||||
|
||||
else -> false
|
||||
}
|
||||
|
||||
fun onKeyUp(keyCode: Int, @Suppress("UNUSED_PARAMETER") event: KeyEvent?): Boolean {
|
||||
return (
|
||||
isVolumeKeysSwitchEnabled &&
|
||||
(keyCode == KeyEvent.KEYCODE_VOLUME_DOWN || keyCode == KeyEvent.KEYCODE_VOLUME_UP)
|
||||
)
|
||||
}
|
||||
|
||||
private fun updateSettings() {
|
||||
val switch = settings.readerPageSwitch
|
||||
isTapSwitchEnabled = AppSettings.PAGE_SWITCH_TAPS in switch
|
||||
isVolumeKeysSwitchEnabled = AppSettings.PAGE_SWITCH_VOLUME_KEYS in switch
|
||||
isReaderTapsAdaptive = settings.isReaderTapsAdaptive
|
||||
}
|
||||
|
||||
private fun isReaderTapsReversed(): Boolean {
|
||||
return isReaderTapsAdaptive && listener.readerMode == ReaderMode.REVERSED
|
||||
}
|
||||
|
||||
interface OnInteractionListener {
|
||||
|
||||
val readerMode: ReaderMode?
|
||||
|
||||
fun switchPageBy(delta: Int)
|
||||
|
||||
fun scrollBy(delta: Int): Boolean
|
||||
|
||||
fun toggleUiVisibility()
|
||||
|
||||
fun isReaderResumed(): Boolean
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
package org.koitharu.kotatsu.reader.ui
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Rect
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import android.view.WindowInsets
|
||||
import androidx.annotation.AttrRes
|
||||
import androidx.core.graphics.ColorUtils
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.util.ext.getThemeColor
|
||||
import org.koitharu.kotatsu.core.util.ext.measureDimension
|
||||
import org.koitharu.kotatsu.core.util.ext.resolveDp
|
||||
import org.koitharu.kotatsu.parsers.util.format
|
||||
import org.koitharu.kotatsu.reader.ui.pager.ReaderUiState
|
||||
import org.koitharu.kotatsu.util.ext.printStackTraceDebug
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import com.google.android.material.R as materialR
|
||||
|
||||
class ReaderInfoBarView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
@AttrRes defStyleAttr: Int = 0,
|
||||
) : View(context, attrs, defStyleAttr) {
|
||||
|
||||
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||
private val textBounds = Rect()
|
||||
private val timeFormat = SimpleDateFormat.getTimeInstance(SimpleDateFormat.SHORT)
|
||||
private val timeReceiver = TimeReceiver()
|
||||
private var insetLeft: Int = 0
|
||||
private var insetRight: Int = 0
|
||||
private var insetTop: Int = 0
|
||||
private var cutoutInsetLeft = 0
|
||||
private var cutoutInsetRight = 0
|
||||
private val colorText = ColorUtils.setAlphaComponent(
|
||||
context.getThemeColor(materialR.attr.colorOnSurface, Color.BLACK),
|
||||
200,
|
||||
)
|
||||
private val colorOutline = ColorUtils.setAlphaComponent(
|
||||
context.getThemeColor(materialR.attr.colorSurface, Color.WHITE),
|
||||
200,
|
||||
)
|
||||
|
||||
private var timeText = timeFormat.format(Date())
|
||||
private var text: String = ""
|
||||
|
||||
private val innerHeight
|
||||
get() = height - paddingTop - paddingBottom - insetTop
|
||||
|
||||
private val innerWidth
|
||||
get() = width - paddingLeft - paddingRight - insetLeft - insetRight
|
||||
|
||||
init {
|
||||
paint.strokeWidth = context.resources.resolveDp(2f)
|
||||
val insetCorner = getSystemUiDimensionOffset("rounded_corner_content_padding")
|
||||
val fallbackInset = resources.getDimensionPixelOffset(R.dimen.reader_bar_inset_fallback)
|
||||
val insetStart = getSystemUiDimensionOffset("status_bar_padding_start", fallbackInset) + insetCorner
|
||||
val insetEnd = getSystemUiDimensionOffset("status_bar_padding_end", fallbackInset) + insetCorner
|
||||
val isRtl = layoutDirection == LAYOUT_DIRECTION_RTL
|
||||
insetLeft = if (isRtl) insetEnd else insetStart
|
||||
insetRight = if (isRtl) insetStart else insetEnd
|
||||
insetTop = minOf(insetLeft, insetRight)
|
||||
}
|
||||
|
||||
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
|
||||
val desiredWidth = suggestedMinimumWidth + paddingLeft + paddingRight + insetLeft + insetRight
|
||||
val desiredHeight = suggestedMinimumHeight + paddingTop + paddingBottom + insetTop
|
||||
setMeasuredDimension(
|
||||
measureDimension(desiredWidth, widthMeasureSpec),
|
||||
measureDimension(desiredHeight, heightMeasureSpec),
|
||||
)
|
||||
}
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
super.onDraw(canvas)
|
||||
val ty = innerHeight / 2f + textBounds.height() / 2f - textBounds.bottom
|
||||
paint.textAlign = Paint.Align.LEFT
|
||||
canvas.drawTextOutline(
|
||||
text,
|
||||
(paddingLeft + insetLeft + cutoutInsetLeft).toFloat(),
|
||||
paddingTop + insetTop + ty,
|
||||
)
|
||||
paint.textAlign = Paint.Align.RIGHT
|
||||
canvas.drawTextOutline(
|
||||
timeText,
|
||||
(width - paddingRight - insetRight - cutoutInsetRight).toFloat(),
|
||||
paddingTop + insetTop + ty,
|
||||
)
|
||||
}
|
||||
|
||||
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
|
||||
super.onSizeChanged(w, h, oldw, oldh)
|
||||
updateCutoutInsets(ViewCompat.getRootWindowInsets(this))
|
||||
updateTextSize()
|
||||
}
|
||||
|
||||
override fun onApplyWindowInsets(insets: WindowInsets): WindowInsets {
|
||||
updateCutoutInsets(WindowInsetsCompat.toWindowInsetsCompat(insets))
|
||||
return super.onApplyWindowInsets(insets)
|
||||
}
|
||||
|
||||
override fun onAttachedToWindow() {
|
||||
super.onAttachedToWindow()
|
||||
context.registerReceiver(timeReceiver, IntentFilter(Intent.ACTION_TIME_TICK))
|
||||
updateCutoutInsets(ViewCompat.getRootWindowInsets(this))
|
||||
}
|
||||
|
||||
override fun onDetachedFromWindow() {
|
||||
super.onDetachedFromWindow()
|
||||
context.unregisterReceiver(timeReceiver)
|
||||
}
|
||||
|
||||
fun update(state: ReaderUiState?) {
|
||||
text = if (state != null) {
|
||||
context.getString(
|
||||
R.string.reader_info_pattern,
|
||||
state.chapterNumber,
|
||||
state.chaptersTotal,
|
||||
state.currentPage + 1,
|
||||
state.totalPages,
|
||||
) + if (state.percent in 0f..1f) {
|
||||
" " + context.getString(R.string.percent_string_pattern, (state.percent * 100).format())
|
||||
} else {
|
||||
""
|
||||
}
|
||||
} else {
|
||||
""
|
||||
}
|
||||
updateTextSize()
|
||||
invalidate()
|
||||
}
|
||||
|
||||
private fun updateTextSize() {
|
||||
val str = text + timeText
|
||||
val testTextSize = 48f
|
||||
paint.textSize = testTextSize
|
||||
paint.getTextBounds(str, 0, str.length, textBounds)
|
||||
paint.textSize = testTextSize * innerHeight / textBounds.height()
|
||||
paint.getTextBounds(str, 0, str.length, textBounds)
|
||||
}
|
||||
|
||||
private fun Canvas.drawTextOutline(text: String, x: Float, y: Float) {
|
||||
paint.color = colorOutline
|
||||
paint.style = Paint.Style.STROKE
|
||||
drawText(text, x, y, paint)
|
||||
paint.color = colorText
|
||||
paint.style = Paint.Style.FILL
|
||||
drawText(text, x, y, paint)
|
||||
}
|
||||
|
||||
private fun updateCutoutInsets(insetsCompat: WindowInsetsCompat?) {
|
||||
val cutouts = (insetsCompat ?: return).displayCutout?.boundingRects.orEmpty()
|
||||
cutoutInsetLeft = 0
|
||||
cutoutInsetRight = 0
|
||||
for (rect in cutouts) {
|
||||
if (rect.left <= paddingLeft) {
|
||||
cutoutInsetLeft += rect.width()
|
||||
}
|
||||
if (rect.right >= width - paddingRight) {
|
||||
cutoutInsetRight += rect.width()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private inner class TimeReceiver : BroadcastReceiver() {
|
||||
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
timeText = timeFormat.format(Date())
|
||||
invalidate()
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("DiscouragedApi")
|
||||
private fun getSystemUiDimensionOffset(name: String, fallback: Int = 0): Int = runCatching {
|
||||
val manager = context.packageManager
|
||||
val resources = manager.getResourcesForApplication("com.android.systemui")
|
||||
val resId = resources.getIdentifier(name, "dimen", "com.android.systemui")
|
||||
resources.getDimensionPixelOffset(resId)
|
||||
}.onFailure {
|
||||
it.printStackTraceDebug()
|
||||
}.getOrDefault(fallback)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package org.koitharu.kotatsu.reader.ui
|
||||
|
||||
import androidx.annotation.IdRes
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import androidx.fragment.app.commit
|
||||
import org.koitharu.kotatsu.core.prefs.ReaderMode
|
||||
import org.koitharu.kotatsu.reader.ui.pager.BaseReaderFragment
|
||||
import org.koitharu.kotatsu.reader.ui.pager.reversed.ReversedReaderFragment
|
||||
import org.koitharu.kotatsu.reader.ui.pager.standard.PagerReaderFragment
|
||||
import org.koitharu.kotatsu.reader.ui.pager.webtoon.WebtoonReaderFragment
|
||||
import java.util.EnumMap
|
||||
|
||||
class ReaderManager(
|
||||
private val fragmentManager: FragmentManager,
|
||||
@IdRes private val containerResId: Int,
|
||||
) {
|
||||
|
||||
private val modeMap = EnumMap<ReaderMode, Class<out BaseReaderFragment<*>>>(ReaderMode::class.java)
|
||||
|
||||
init {
|
||||
modeMap[ReaderMode.STANDARD] = PagerReaderFragment::class.java
|
||||
modeMap[ReaderMode.REVERSED] = ReversedReaderFragment::class.java
|
||||
modeMap[ReaderMode.WEBTOON] = WebtoonReaderFragment::class.java
|
||||
}
|
||||
|
||||
val currentReader: BaseReaderFragment<*>?
|
||||
get() = fragmentManager.findFragmentById(containerResId) as? BaseReaderFragment<*>
|
||||
|
||||
val currentMode: ReaderMode?
|
||||
get() {
|
||||
val readerClass = currentReader?.javaClass ?: return null
|
||||
return modeMap.entries.find { it.value == readerClass }?.key
|
||||
}
|
||||
|
||||
fun replace(newMode: ReaderMode) {
|
||||
val readerClass = requireNotNull(modeMap[newMode])
|
||||
fragmentManager.commit {
|
||||
setReorderingAllowed(true)
|
||||
replace(containerResId, readerClass, null, null)
|
||||
}
|
||||
}
|
||||
|
||||
fun replace(reader: BaseReaderFragment<*>) {
|
||||
fragmentManager.commit {
|
||||
setReorderingAllowed(true)
|
||||
replace(containerResId, reader)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package org.koitharu.kotatsu.reader.ui
|
||||
|
||||
import com.google.android.material.slider.Slider
|
||||
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
|
||||
import org.koitharu.kotatsu.reader.ui.thumbnails.OnPageSelectListener
|
||||
|
||||
class ReaderSliderListener(
|
||||
private val pageSelectListener: OnPageSelectListener,
|
||||
private val viewModel: ReaderViewModel,
|
||||
) : Slider.OnChangeListener, Slider.OnSliderTouchListener {
|
||||
|
||||
private var isChanged = false
|
||||
private var isTracking = false
|
||||
|
||||
override fun onValueChange(slider: Slider, value: Float, fromUser: Boolean) {
|
||||
if (fromUser) {
|
||||
if (isTracking) {
|
||||
isChanged = true
|
||||
} else {
|
||||
switchPageToIndex(value.toInt())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStartTrackingTouch(slider: Slider) {
|
||||
isChanged = false
|
||||
isTracking = true
|
||||
}
|
||||
|
||||
override fun onStopTrackingTouch(slider: Slider) {
|
||||
isTracking = false
|
||||
if (isChanged) {
|
||||
switchPageToIndex(slider.value.toInt())
|
||||
}
|
||||
}
|
||||
|
||||
fun attachToSlider(slider: Slider) {
|
||||
slider.addOnChangeListener(this)
|
||||
slider.addOnSliderTouchListener(this)
|
||||
}
|
||||
|
||||
private fun switchPageToIndex(index: Int) {
|
||||
val pages = viewModel.getCurrentChapterPages()
|
||||
val page = pages?.getOrNull(index) ?: return
|
||||
val chapterId = viewModel.getCurrentState()?.chapterId ?: return
|
||||
pageSelectListener.onPageSelected(ReaderPage(page, index, chapterId))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package org.koitharu.kotatsu.reader.ui
|
||||
|
||||
import android.os.Parcelable
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import org.koitharu.kotatsu.core.model.MangaHistory
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
|
||||
@Parcelize
|
||||
data class ReaderState(
|
||||
val chapterId: Long,
|
||||
val page: Int,
|
||||
val scroll: Int,
|
||||
) : Parcelable {
|
||||
|
||||
constructor(history: MangaHistory) : this(
|
||||
chapterId = history.chapterId,
|
||||
page = history.page,
|
||||
scroll = history.scroll,
|
||||
)
|
||||
|
||||
constructor(manga: Manga, branch: String?) : this(
|
||||
chapterId = manga.chapters?.firstOrNull {
|
||||
it.branch == branch
|
||||
}?.id ?: error("Cannot find first chapter"),
|
||||
page = 0,
|
||||
scroll = 0,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package org.koitharu.kotatsu.reader.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.view.Gravity
|
||||
import android.view.ViewGroup
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.transition.Fade
|
||||
import androidx.transition.Slide
|
||||
import androidx.transition.TransitionManager
|
||||
import androidx.transition.TransitionSet
|
||||
import com.google.android.material.textview.MaterialTextView
|
||||
|
||||
class ReaderToastView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = 0,
|
||||
) : MaterialTextView(context, attrs, defStyleAttr) {
|
||||
|
||||
private var hideRunnable = Runnable {
|
||||
hide()
|
||||
}
|
||||
|
||||
fun show(message: CharSequence) {
|
||||
removeCallbacks(hideRunnable)
|
||||
text = message
|
||||
setupTransition()
|
||||
isVisible = true
|
||||
}
|
||||
|
||||
fun show(@StringRes messageId: Int) {
|
||||
show(context.getString(messageId))
|
||||
}
|
||||
|
||||
fun showTemporary(message: CharSequence, duration: Long) {
|
||||
show(message)
|
||||
postDelayed(hideRunnable, duration)
|
||||
}
|
||||
|
||||
fun hide() {
|
||||
removeCallbacks(hideRunnable)
|
||||
setupTransition()
|
||||
isVisible = false
|
||||
}
|
||||
|
||||
override fun onDetachedFromWindow() {
|
||||
removeCallbacks(hideRunnable)
|
||||
super.onDetachedFromWindow()
|
||||
}
|
||||
|
||||
private fun setupTransition() {
|
||||
val parentView = parent as? ViewGroup ?: return
|
||||
val transition = TransitionSet()
|
||||
.setOrdering(TransitionSet.ORDERING_TOGETHER)
|
||||
.addTarget(this)
|
||||
.addTransition(Slide(Gravity.BOTTOM))
|
||||
.addTransition(Fade())
|
||||
TransitionManager.beginDelayedTransition(parentView, transition)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,440 @@
|
||||
package org.koitharu.kotatsu.reader.ui
|
||||
|
||||
import android.net.Uri
|
||||
import android.util.LongSparseArray
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.annotation.AnyThread
|
||||
import androidx.annotation.MainThread
|
||||
import androidx.annotation.WorkerThread
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.cancelAndJoin
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.plus
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
||||
import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository
|
||||
import org.koitharu.kotatsu.core.os.ShortcutsUpdater
|
||||
import org.koitharu.kotatsu.core.parser.MangaDataRepository
|
||||
import org.koitharu.kotatsu.core.parser.MangaIntent
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.prefs.ReaderMode
|
||||
import org.koitharu.kotatsu.core.prefs.ScreenshotsPolicy
|
||||
import org.koitharu.kotatsu.core.prefs.observeAsFlow
|
||||
import org.koitharu.kotatsu.core.prefs.observeAsLiveData
|
||||
import org.koitharu.kotatsu.core.ui.BaseViewModel
|
||||
import org.koitharu.kotatsu.core.util.SingleLiveEvent
|
||||
import org.koitharu.kotatsu.core.util.asFlowLiveData
|
||||
import org.koitharu.kotatsu.core.util.ext.emitValue
|
||||
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
|
||||
import org.koitharu.kotatsu.core.util.ext.requireValue
|
||||
import org.koitharu.kotatsu.history.domain.HistoryRepository
|
||||
import org.koitharu.kotatsu.history.domain.PROGRESS_NONE
|
||||
import org.koitharu.kotatsu.parsers.exception.NotFoundException
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import org.koitharu.kotatsu.reader.data.filterChapters
|
||||
import org.koitharu.kotatsu.reader.domain.ChaptersLoader
|
||||
import org.koitharu.kotatsu.reader.domain.PageLoader
|
||||
import org.koitharu.kotatsu.reader.ui.config.ReaderSettings
|
||||
import org.koitharu.kotatsu.reader.ui.pager.ReaderUiState
|
||||
import org.koitharu.kotatsu.util.ext.printStackTraceDebug
|
||||
import java.util.Date
|
||||
import javax.inject.Inject
|
||||
|
||||
private const val BOUNDS_PAGE_OFFSET = 2
|
||||
private const val PREFETCH_LIMIT = 10
|
||||
|
||||
@HiltViewModel
|
||||
class ReaderViewModel @Inject constructor(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
private val mangaRepositoryFactory: MangaRepository.Factory,
|
||||
private val dataRepository: MangaDataRepository,
|
||||
private val historyRepository: HistoryRepository,
|
||||
private val bookmarksRepository: BookmarksRepository,
|
||||
private val settings: AppSettings,
|
||||
private val pageSaveHelper: PageSaveHelper,
|
||||
private val pageLoader: PageLoader,
|
||||
private val chaptersLoader: ChaptersLoader,
|
||||
private val shortcutsUpdater: ShortcutsUpdater,
|
||||
) : BaseViewModel() {
|
||||
|
||||
private val intent = MangaIntent(savedStateHandle)
|
||||
private val preselectedBranch = savedStateHandle.get<String>(ReaderActivity.EXTRA_BRANCH)
|
||||
private val isIncognito = savedStateHandle.get<Boolean>(ReaderActivity.EXTRA_INCOGNITO) ?: false
|
||||
|
||||
private var loadingJob: Job? = null
|
||||
private var pageSaveJob: Job? = null
|
||||
private var bookmarkJob: Job? = null
|
||||
private var stateChangeJob: Job? = null
|
||||
private val currentState = MutableStateFlow<ReaderState?>(savedStateHandle[ReaderActivity.EXTRA_STATE])
|
||||
private val mangaData = MutableStateFlow(intent.manga)
|
||||
private val chapters: LongSparseArray<MangaChapter>
|
||||
get() = chaptersLoader.chapters
|
||||
|
||||
val readerMode = MutableLiveData<ReaderMode>()
|
||||
val onPageSaved = SingleLiveEvent<Uri?>()
|
||||
val onShowToast = SingleLiveEvent<Int>()
|
||||
val uiState = MutableLiveData<ReaderUiState?>(null)
|
||||
|
||||
val content = MutableLiveData(ReaderContent(emptyList(), null))
|
||||
val manga: Manga?
|
||||
get() = mangaData.value
|
||||
|
||||
val readerAnimation = settings.observeAsLiveData(
|
||||
context = viewModelScope.coroutineContext + Dispatchers.Default,
|
||||
key = AppSettings.KEY_READER_ANIMATION,
|
||||
valueProducer = { readerAnimation },
|
||||
)
|
||||
|
||||
val isInfoBarEnabled = settings.observeAsLiveData(
|
||||
context = viewModelScope.coroutineContext + Dispatchers.Default,
|
||||
key = AppSettings.KEY_READER_BAR,
|
||||
valueProducer = { isReaderBarEnabled },
|
||||
)
|
||||
|
||||
val isWebtoonZoomEnabled = settings.observeAsLiveData(
|
||||
context = viewModelScope.coroutineContext + Dispatchers.Default,
|
||||
key = AppSettings.KEY_WEBTOON_ZOOM,
|
||||
valueProducer = { isWebtoonZoomEnable },
|
||||
)
|
||||
|
||||
val readerSettings = ReaderSettings(
|
||||
parentScope = viewModelScope,
|
||||
settings = settings,
|
||||
colorFilterFlow = mangaData.flatMapLatest {
|
||||
if (it == null) flowOf(null) else dataRepository.observeColorFilter(it.id)
|
||||
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null),
|
||||
)
|
||||
|
||||
val isScreenshotsBlockEnabled = combine(
|
||||
mangaData,
|
||||
settings.observeAsFlow(AppSettings.KEY_SCREENSHOTS_POLICY) { screenshotsPolicy },
|
||||
) { manga, policy ->
|
||||
policy == ScreenshotsPolicy.BLOCK_ALL ||
|
||||
(policy == ScreenshotsPolicy.BLOCK_NSFW && manga != null && manga.isNsfw)
|
||||
}.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, false)
|
||||
|
||||
val isBookmarkAdded: LiveData<Boolean> = currentState.flatMapLatest { state ->
|
||||
val manga = mangaData.value
|
||||
if (state == null || manga == null) {
|
||||
flowOf(false)
|
||||
} else {
|
||||
bookmarksRepository.observeBookmark(manga, state.chapterId, state.page)
|
||||
.map { it != null }
|
||||
}
|
||||
}.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, false)
|
||||
|
||||
init {
|
||||
loadImpl()
|
||||
settings.observe()
|
||||
.onEach { key ->
|
||||
if (key == AppSettings.KEY_READER_SLIDER) notifyStateChanged()
|
||||
}.launchIn(viewModelScope + Dispatchers.Default)
|
||||
launchJob(Dispatchers.Default) {
|
||||
val mangaId = mangaData.filterNotNull().first().id
|
||||
shortcutsUpdater.notifyMangaOpened(mangaId)
|
||||
}
|
||||
}
|
||||
|
||||
fun reload() {
|
||||
loadingJob?.cancel()
|
||||
loadImpl()
|
||||
}
|
||||
|
||||
fun switchMode(newMode: ReaderMode) {
|
||||
launchJob {
|
||||
val manga = checkNotNull(mangaData.value)
|
||||
dataRepository.saveReaderMode(
|
||||
manga = manga,
|
||||
mode = newMode,
|
||||
)
|
||||
readerMode.value = newMode
|
||||
content.value?.run {
|
||||
content.value = copy(
|
||||
state = getCurrentState(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun saveCurrentState(state: ReaderState? = null) {
|
||||
if (state != null) {
|
||||
currentState.value = state
|
||||
}
|
||||
if (isIncognito) {
|
||||
return
|
||||
}
|
||||
val readerState = state ?: currentState.value ?: return
|
||||
historyRepository.saveStateAsync(
|
||||
manga = mangaData.value ?: return,
|
||||
state = readerState,
|
||||
percent = computePercent(readerState.chapterId, readerState.page),
|
||||
)
|
||||
}
|
||||
|
||||
fun getCurrentState() = currentState.value
|
||||
|
||||
fun getCurrentChapterPages(): List<MangaPage>? {
|
||||
val chapterId = currentState.value?.chapterId ?: return null
|
||||
return chaptersLoader.getPages(chapterId).map { it.toMangaPage() }
|
||||
}
|
||||
|
||||
fun saveCurrentPage(
|
||||
page: MangaPage,
|
||||
saveLauncher: ActivityResultLauncher<String>,
|
||||
) {
|
||||
val prevJob = pageSaveJob
|
||||
pageSaveJob = launchLoadingJob(Dispatchers.Default) {
|
||||
prevJob?.cancelAndJoin()
|
||||
try {
|
||||
val dest = pageSaveHelper.savePage(pageLoader, page, saveLauncher)
|
||||
onPageSaved.emitCall(dest)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
e.printStackTraceDebug()
|
||||
onPageSaved.emitCall(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onActivityResult(uri: Uri?) {
|
||||
if (uri != null) {
|
||||
pageSaveHelper.onActivityResult(uri)
|
||||
} else {
|
||||
pageSaveJob?.cancel()
|
||||
pageSaveJob = null
|
||||
}
|
||||
}
|
||||
|
||||
fun getCurrentPage(): MangaPage? {
|
||||
val state = currentState.value ?: return null
|
||||
return content.value?.pages?.find {
|
||||
it.chapterId == state.chapterId && it.index == state.page
|
||||
}?.toMangaPage()
|
||||
}
|
||||
|
||||
fun switchChapter(id: Long, page: Int) {
|
||||
val prevJob = loadingJob
|
||||
loadingJob = launchLoadingJob(Dispatchers.Default) {
|
||||
prevJob?.cancelAndJoin()
|
||||
content.postValue(ReaderContent(emptyList(), null))
|
||||
chaptersLoader.loadSingleChapter(mangaData.requireValue(), id)
|
||||
content.postValue(ReaderContent(chaptersLoader.snapshot(), ReaderState(id, page, 0)))
|
||||
}
|
||||
}
|
||||
|
||||
@MainThread
|
||||
fun onCurrentPageChanged(position: Int) {
|
||||
val prevJob = stateChangeJob
|
||||
stateChangeJob = launchJob(Dispatchers.Default) {
|
||||
prevJob?.cancelAndJoin()
|
||||
loadingJob?.join()
|
||||
val pages = content.value?.pages ?: return@launchJob
|
||||
pages.getOrNull(position)?.let { page ->
|
||||
currentState.update { cs ->
|
||||
cs?.copy(chapterId = page.chapterId, page = page.index)
|
||||
}
|
||||
}
|
||||
notifyStateChanged()
|
||||
if (pages.isEmpty() || loadingJob?.isActive == true) {
|
||||
return@launchJob
|
||||
}
|
||||
ensureActive()
|
||||
if (position >= pages.lastIndex - BOUNDS_PAGE_OFFSET) {
|
||||
loadPrevNextChapter(pages.last().chapterId, isNext = true)
|
||||
}
|
||||
if (position <= BOUNDS_PAGE_OFFSET) {
|
||||
loadPrevNextChapter(pages.first().chapterId, isNext = false)
|
||||
}
|
||||
if (pageLoader.isPrefetchApplicable()) {
|
||||
pageLoader.prefetch(pages.trySublist(position + 1, position + PREFETCH_LIMIT))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun addBookmark() {
|
||||
if (bookmarkJob?.isActive == true) {
|
||||
return
|
||||
}
|
||||
bookmarkJob = launchJob(Dispatchers.Default) {
|
||||
loadingJob?.join()
|
||||
val state = checkNotNull(currentState.value)
|
||||
val page = checkNotNull(getCurrentPage()) { "Page not found" }
|
||||
val bookmark = Bookmark(
|
||||
manga = checkNotNull(mangaData.value),
|
||||
pageId = page.id,
|
||||
chapterId = state.chapterId,
|
||||
page = state.page,
|
||||
scroll = state.scroll,
|
||||
imageUrl = page.preview ?: pageLoader.getPageUrl(page),
|
||||
createdAt = Date(),
|
||||
percent = computePercent(state.chapterId, state.page),
|
||||
)
|
||||
bookmarksRepository.addBookmark(bookmark)
|
||||
onShowToast.emitCall(R.string.bookmark_added)
|
||||
}
|
||||
}
|
||||
|
||||
fun removeBookmark() {
|
||||
if (bookmarkJob?.isActive == true) {
|
||||
return
|
||||
}
|
||||
bookmarkJob = launchJob {
|
||||
loadingJob?.join()
|
||||
val manga = checkNotNull(mangaData.value)
|
||||
val page = checkNotNull(getCurrentPage()) { "Page not found" }
|
||||
bookmarksRepository.removeBookmark(manga.id, page.id)
|
||||
onShowToast.call(R.string.bookmark_removed)
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadImpl() {
|
||||
loadingJob = launchLoadingJob(Dispatchers.Default) {
|
||||
var manga = dataRepository.resolveIntent(intent) ?: throw NotFoundException("Cannot find manga", "")
|
||||
mangaData.value = manga
|
||||
val repo = mangaRepositoryFactory.create(manga.source)
|
||||
manga = repo.getDetails(manga)
|
||||
manga.chapters?.forEach {
|
||||
chapters.put(it.id, it)
|
||||
}
|
||||
// determine mode
|
||||
val mode = detectReaderMode(manga, repo)
|
||||
// obtain state
|
||||
if (currentState.value == null) {
|
||||
currentState.value = historyRepository.getOne(manga)?.let {
|
||||
ReaderState(it)
|
||||
} ?: ReaderState(manga, preselectedBranch)
|
||||
}
|
||||
|
||||
val branch = chapters[currentState.value?.chapterId ?: 0L]?.branch
|
||||
mangaData.value = manga.filterChapters(branch)
|
||||
readerMode.emitValue(mode)
|
||||
|
||||
chaptersLoader.loadSingleChapter(manga, requireNotNull(currentState.value).chapterId)
|
||||
// save state
|
||||
if (!isIncognito) {
|
||||
currentState.value?.let {
|
||||
val percent = computePercent(it.chapterId, it.page)
|
||||
historyRepository.addOrUpdate(manga, it.chapterId, it.page, it.scroll, percent)
|
||||
}
|
||||
}
|
||||
notifyStateChanged()
|
||||
content.emitValue(ReaderContent(chaptersLoader.snapshot(), currentState.value))
|
||||
}
|
||||
}
|
||||
|
||||
@AnyThread
|
||||
private fun loadPrevNextChapter(currentId: Long, isNext: Boolean) {
|
||||
val prevJob = loadingJob
|
||||
loadingJob = launchLoadingJob(Dispatchers.Default) {
|
||||
prevJob?.join()
|
||||
chaptersLoader.loadPrevNextChapter(mangaData.requireValue(), currentId, isNext)
|
||||
content.emitValue(ReaderContent(chaptersLoader.snapshot(), null))
|
||||
}
|
||||
}
|
||||
|
||||
private fun <T> List<T>.trySublist(fromIndex: Int, toIndex: Int): List<T> {
|
||||
val fromIndexBounded = fromIndex.coerceAtMost(lastIndex)
|
||||
val toIndexBounded = toIndex.coerceIn(fromIndexBounded, lastIndex)
|
||||
return if (fromIndexBounded == toIndexBounded) {
|
||||
emptyList()
|
||||
} else {
|
||||
subList(fromIndexBounded, toIndexBounded)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun detectReaderMode(manga: Manga, repo: MangaRepository): ReaderMode {
|
||||
dataRepository.getReaderMode(manga.id)?.let { return it }
|
||||
val defaultMode = settings.defaultReaderMode
|
||||
if (!settings.isReaderModeDetectionEnabled || defaultMode == ReaderMode.WEBTOON) {
|
||||
return defaultMode
|
||||
}
|
||||
val chapter = currentState.value?.chapterId?.let(chapters::get)
|
||||
?: manga.chapters?.randomOrNull()
|
||||
?: error("There are no chapters in this manga")
|
||||
val pages = repo.getPages(chapter)
|
||||
return runCatchingCancellable {
|
||||
val isWebtoon = dataRepository.determineMangaIsWebtoon(repo, pages)
|
||||
if (isWebtoon) ReaderMode.WEBTOON else defaultMode
|
||||
}.onSuccess {
|
||||
dataRepository.saveReaderMode(manga, it)
|
||||
}.onFailure {
|
||||
it.printStackTraceDebug()
|
||||
}.getOrDefault(defaultMode)
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private fun notifyStateChanged() {
|
||||
val state = getCurrentState()
|
||||
val chapter = state?.chapterId?.let(chapters::get)
|
||||
val newState = ReaderUiState(
|
||||
mangaName = manga?.title,
|
||||
chapterName = chapter?.name,
|
||||
chapterNumber = chapter?.number ?: 0,
|
||||
chaptersTotal = manga?.getChapters(chapter?.branch)?.size ?: 0,
|
||||
totalPages = if (chapter != null) chaptersLoader.getPagesCount(chapter.id) else 0,
|
||||
currentPage = state?.page ?: 0,
|
||||
isSliderEnabled = settings.isReaderSliderEnabled,
|
||||
percent = if (state != null) computePercent(state.chapterId, state.page) else PROGRESS_NONE,
|
||||
)
|
||||
uiState.postValue(newState)
|
||||
}
|
||||
|
||||
private fun computePercent(chapterId: Long, pageIndex: Int): Float {
|
||||
val branch = chapters[chapterId]?.branch
|
||||
val chapters = manga?.getChapters(branch) ?: return PROGRESS_NONE
|
||||
val chaptersCount = chapters.size
|
||||
val chapterIndex = chapters.indexOfFirst { x -> x.id == chapterId }
|
||||
val pagesCount = chaptersLoader.getPagesCount(chapterId)
|
||||
if (chaptersCount == 0 || pagesCount == 0) {
|
||||
return PROGRESS_NONE
|
||||
}
|
||||
val pagePercent = (pageIndex + 1) / pagesCount.toFloat()
|
||||
val ppc = 1f / chaptersCount
|
||||
return ppc * chapterIndex + ppc * pagePercent
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This function is not a member of the ReaderViewModel
|
||||
* because it should work independently of the ViewModel's lifecycle.
|
||||
*/
|
||||
private fun HistoryRepository.saveStateAsync(manga: Manga, state: ReaderState, percent: Float): Job {
|
||||
return processLifecycleScope.launch(Dispatchers.Default) {
|
||||
runCatchingCancellable {
|
||||
addOrUpdate(
|
||||
manga = manga,
|
||||
chapterId = state.chapterId,
|
||||
page = state.page,
|
||||
scroll = state.scroll,
|
||||
percent = percent,
|
||||
)
|
||||
}.onFailure {
|
||||
it.printStackTraceDebug()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package org.koitharu.kotatsu.reader.ui
|
||||
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.prefs.observeAsFlow
|
||||
import kotlin.math.roundToLong
|
||||
|
||||
private const val MAX_DELAY = 20L
|
||||
private const val MAX_SWITCH_DELAY = 10_000L
|
||||
private const val INTERACTION_SKIP_MS = 2_000L
|
||||
private const val SPEED_FACTOR_DELTA = 0.02f
|
||||
|
||||
class ScrollTimer @AssistedInject constructor(
|
||||
@Assisted private val listener: ReaderControlDelegate.OnInteractionListener,
|
||||
@Assisted lifecycleOwner: LifecycleOwner,
|
||||
settings: AppSettings,
|
||||
) {
|
||||
|
||||
private val coroutineScope = lifecycleOwner.lifecycleScope
|
||||
private var job: Job? = null
|
||||
private var delayMs: Long = 10L
|
||||
private var pageSwitchDelay: Long = 100L
|
||||
private var resumeAt = 0L
|
||||
|
||||
var isEnabled: Boolean = false
|
||||
set(value) {
|
||||
if (field != value) {
|
||||
field = value
|
||||
restartJob()
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
settings.observeAsFlow(AppSettings.KEY_READER_AUTOSCROLL_SPEED) {
|
||||
readerAutoscrollSpeed
|
||||
}.flowOn(Dispatchers.Default)
|
||||
.onEach {
|
||||
onSpeedChanged(it)
|
||||
}.launchIn(coroutineScope)
|
||||
}
|
||||
|
||||
fun onUserInteraction() {
|
||||
resumeAt = System.currentTimeMillis() + INTERACTION_SKIP_MS
|
||||
}
|
||||
|
||||
private fun onSpeedChanged(speed: Float) {
|
||||
if (speed <= 0f) {
|
||||
delayMs = 0L
|
||||
pageSwitchDelay = 0L
|
||||
} else {
|
||||
val speedFactor = 1 - speed
|
||||
delayMs = (MAX_DELAY * speedFactor).roundToLong()
|
||||
pageSwitchDelay = (MAX_SWITCH_DELAY * speedFactor).roundToLong()
|
||||
}
|
||||
if ((job == null) != (delayMs == 0L)) {
|
||||
restartJob()
|
||||
}
|
||||
}
|
||||
|
||||
private fun restartJob() {
|
||||
job?.cancel()
|
||||
resumeAt = 0L
|
||||
if (!isEnabled || delayMs == 0L) {
|
||||
job = null
|
||||
return
|
||||
}
|
||||
job = coroutineScope.launch {
|
||||
var accumulator = 0L
|
||||
var speedFactor = 1f
|
||||
while (isActive) {
|
||||
if (isPaused()) {
|
||||
speedFactor = (speedFactor - SPEED_FACTOR_DELTA).coerceAtLeast(0f)
|
||||
} else if (speedFactor < 1f) {
|
||||
speedFactor = (speedFactor + SPEED_FACTOR_DELTA).coerceAtMost(1f)
|
||||
}
|
||||
if (speedFactor == 1f) {
|
||||
delay(delayMs)
|
||||
} else if (speedFactor == 0f) {
|
||||
delayUntilResumed()
|
||||
continue
|
||||
} else {
|
||||
delay((delayMs * (1f + speedFactor * 2)).toLong())
|
||||
}
|
||||
if (!listener.isReaderResumed()) {
|
||||
continue
|
||||
}
|
||||
if (!listener.scrollBy(1)) {
|
||||
accumulator += delayMs
|
||||
}
|
||||
if (accumulator >= pageSwitchDelay) {
|
||||
listener.switchPageBy(1)
|
||||
accumulator -= pageSwitchDelay
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun isPaused(): Boolean {
|
||||
return resumeAt > System.currentTimeMillis()
|
||||
}
|
||||
|
||||
private suspend fun delayUntilResumed() {
|
||||
while (isPaused()) {
|
||||
delay(resumeAt - System.currentTimeMillis())
|
||||
}
|
||||
}
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
|
||||
fun create(
|
||||
lifecycleOwner: LifecycleOwner,
|
||||
listener: ReaderControlDelegate.OnInteractionListener,
|
||||
): ScrollTimer
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package org.koitharu.kotatsu.reader.ui.colorfilter
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.res.Resources
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.activity.viewModels
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.core.view.updateLayoutParams
|
||||
import androidx.core.view.updatePadding
|
||||
import coil.ImageLoader
|
||||
import coil.request.ImageRequest
|
||||
import coil.size.Scale
|
||||
import coil.size.ViewSizeResolver
|
||||
import com.google.android.material.slider.LabelFormatter
|
||||
import com.google.android.material.slider.Slider
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
|
||||
import org.koitharu.kotatsu.core.model.parcelable.ParcelableMangaPages
|
||||
import org.koitharu.kotatsu.core.ui.BaseActivity
|
||||
import org.koitharu.kotatsu.core.util.ext.decodeRegion
|
||||
import org.koitharu.kotatsu.core.util.ext.enqueueWith
|
||||
import org.koitharu.kotatsu.core.util.ext.indicator
|
||||
import org.koitharu.kotatsu.core.util.ext.setValueRounded
|
||||
import org.koitharu.kotatsu.databinding.ActivityColorFilterBinding
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||
import org.koitharu.kotatsu.parsers.util.format
|
||||
import org.koitharu.kotatsu.reader.domain.ReaderColorFilter
|
||||
import javax.inject.Inject
|
||||
import com.google.android.material.R as materialR
|
||||
|
||||
@AndroidEntryPoint
|
||||
class ColorFilterConfigActivity :
|
||||
BaseActivity<ActivityColorFilterBinding>(),
|
||||
Slider.OnChangeListener,
|
||||
View.OnClickListener {
|
||||
|
||||
@Inject
|
||||
lateinit var coil: ImageLoader
|
||||
|
||||
private val viewModel: ColorFilterConfigViewModel by viewModels()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(ActivityColorFilterBinding.inflate(layoutInflater))
|
||||
supportActionBar?.run {
|
||||
setDisplayHomeAsUpEnabled(true)
|
||||
setHomeAsUpIndicator(materialR.drawable.abc_ic_clear_material)
|
||||
}
|
||||
viewBinding.sliderBrightness.addOnChangeListener(this)
|
||||
viewBinding.sliderContrast.addOnChangeListener(this)
|
||||
val formatter = PercentLabelFormatter(resources)
|
||||
viewBinding.sliderContrast.setLabelFormatter(formatter)
|
||||
viewBinding.sliderBrightness.setLabelFormatter(formatter)
|
||||
viewBinding.buttonDone.setOnClickListener(this)
|
||||
viewBinding.buttonReset.setOnClickListener(this)
|
||||
|
||||
onBackPressedDispatcher.addCallback(ColorFilterConfigBackPressedDispatcher(this, viewModel))
|
||||
|
||||
viewModel.colorFilter.observe(this, this::onColorFilterChanged)
|
||||
viewModel.isLoading.observe(this, this::onLoadingChanged)
|
||||
viewModel.preview.observe(this, this::onPreviewChanged)
|
||||
viewModel.onDismiss.observe(this) {
|
||||
finishAfterTransition()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onValueChange(slider: Slider, value: Float, fromUser: Boolean) {
|
||||
if (fromUser) {
|
||||
when (slider.id) {
|
||||
R.id.slider_brightness -> viewModel.setBrightness(value)
|
||||
R.id.slider_contrast -> viewModel.setContrast(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onClick(v: View) {
|
||||
when (v.id) {
|
||||
R.id.button_done -> viewModel.save()
|
||||
R.id.button_reset -> viewModel.reset()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onWindowInsetsChanged(insets: Insets) {
|
||||
viewBinding.root.updatePadding(
|
||||
left = insets.left,
|
||||
right = insets.right,
|
||||
)
|
||||
viewBinding.scrollView.updatePadding(
|
||||
bottom = insets.bottom,
|
||||
)
|
||||
viewBinding.toolbar.updateLayoutParams<ViewGroup.MarginLayoutParams> {
|
||||
topMargin = insets.top
|
||||
}
|
||||
}
|
||||
|
||||
private fun onColorFilterChanged(readerColorFilter: ReaderColorFilter?) {
|
||||
viewBinding.sliderBrightness.setValueRounded(readerColorFilter?.brightness ?: 0f)
|
||||
viewBinding.sliderContrast.setValueRounded(readerColorFilter?.contrast ?: 0f)
|
||||
viewBinding.imageViewAfter.colorFilter = readerColorFilter?.toColorFilter()
|
||||
}
|
||||
|
||||
private fun onPreviewChanged(preview: MangaPage?) {
|
||||
if (preview == null) return
|
||||
ImageRequest.Builder(this@ColorFilterConfigActivity)
|
||||
.data(preview.url)
|
||||
.scale(Scale.FILL)
|
||||
.decodeRegion()
|
||||
.tag(preview.source)
|
||||
.indicator(listOf(viewBinding.progressBefore, viewBinding.progressAfter))
|
||||
.error(R.drawable.ic_error_placeholder)
|
||||
.size(ViewSizeResolver(viewBinding.imageViewBefore))
|
||||
.allowRgb565(false)
|
||||
.target(ShadowViewTarget(viewBinding.imageViewBefore, viewBinding.imageViewAfter))
|
||||
.enqueueWith(coil)
|
||||
}
|
||||
|
||||
private fun onLoadingChanged(isLoading: Boolean) {
|
||||
viewBinding.sliderContrast.isEnabled = !isLoading
|
||||
viewBinding.sliderBrightness.isEnabled = !isLoading
|
||||
viewBinding.buttonDone.isEnabled = !isLoading
|
||||
}
|
||||
|
||||
private class PercentLabelFormatter(resources: Resources) : LabelFormatter {
|
||||
|
||||
private val pattern = resources.getString(R.string.percent_string_pattern)
|
||||
|
||||
override fun getFormattedValue(value: Float): String {
|
||||
val percent = ((value + 1f) * 100).format(0)
|
||||
return pattern.format(percent)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
const val EXTRA_PAGES = "pages"
|
||||
const val EXTRA_MANGA = "manga_id"
|
||||
|
||||
fun newIntent(context: Context, manga: Manga, page: MangaPage) =
|
||||
Intent(context, ColorFilterConfigActivity::class.java)
|
||||
.putExtra(EXTRA_MANGA, ParcelableManga(manga, false))
|
||||
.putExtra(EXTRA_PAGES, ParcelableMangaPages(listOf(page)))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package org.koitharu.kotatsu.reader.ui.colorfilter
|
||||
|
||||
import android.content.Context
|
||||
import android.content.DialogInterface
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import org.koitharu.kotatsu.R
|
||||
|
||||
class ColorFilterConfigBackPressedDispatcher(
|
||||
private val context: Context,
|
||||
private val viewModel: ColorFilterConfigViewModel,
|
||||
) : OnBackPressedCallback(true), DialogInterface.OnClickListener {
|
||||
|
||||
override fun handleOnBackPressed() {
|
||||
if (viewModel.isChanged) {
|
||||
showConfirmation()
|
||||
} else {
|
||||
viewModel.onDismiss.call(Unit)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onClick(dialog: DialogInterface, which: Int) {
|
||||
when (which) {
|
||||
DialogInterface.BUTTON_NEGATIVE -> viewModel.onDismiss.call(Unit)
|
||||
DialogInterface.BUTTON_NEUTRAL -> dialog.dismiss()
|
||||
DialogInterface.BUTTON_POSITIVE -> viewModel.save()
|
||||
}
|
||||
}
|
||||
|
||||
private fun showConfirmation() {
|
||||
MaterialAlertDialogBuilder(context)
|
||||
.setTitle(R.string.color_correction)
|
||||
.setMessage(R.string.text_unsaved_changes_prompt)
|
||||
.setNegativeButton(R.string.discard, this)
|
||||
.setNeutralButton(android.R.string.cancel, this)
|
||||
.setPositiveButton(R.string.save, this)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package org.koitharu.kotatsu.reader.ui.colorfilter
|
||||
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
|
||||
import org.koitharu.kotatsu.core.model.parcelable.ParcelableMangaPages
|
||||
import org.koitharu.kotatsu.core.parser.MangaDataRepository
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.core.ui.BaseViewModel
|
||||
import org.koitharu.kotatsu.core.util.SingleLiveEvent
|
||||
import org.koitharu.kotatsu.core.util.ext.emitValue
|
||||
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||
import org.koitharu.kotatsu.reader.domain.ReaderColorFilter
|
||||
import org.koitharu.kotatsu.reader.ui.colorfilter.ColorFilterConfigActivity.Companion.EXTRA_MANGA
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class ColorFilterConfigViewModel @Inject constructor(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
private val mangaRepositoryFactory: MangaRepository.Factory,
|
||||
private val mangaDataRepository: MangaDataRepository,
|
||||
) : BaseViewModel() {
|
||||
|
||||
private val manga = checkNotNull(savedStateHandle.get<ParcelableManga>(EXTRA_MANGA)?.manga)
|
||||
|
||||
private var initialColorFilter: ReaderColorFilter? = null
|
||||
val colorFilter = MutableLiveData<ReaderColorFilter?>(null)
|
||||
val onDismiss = SingleLiveEvent<Unit>()
|
||||
val preview = MutableLiveData<MangaPage?>(null)
|
||||
|
||||
val isChanged: Boolean
|
||||
get() = colorFilter.value != initialColorFilter
|
||||
|
||||
init {
|
||||
val page = checkNotNull(
|
||||
savedStateHandle.get<ParcelableMangaPages>(ColorFilterConfigActivity.EXTRA_PAGES)?.pages?.firstOrNull(),
|
||||
)
|
||||
launchLoadingJob {
|
||||
initialColorFilter = mangaDataRepository.getColorFilter(manga.id)
|
||||
colorFilter.value = initialColorFilter
|
||||
}
|
||||
launchLoadingJob(Dispatchers.Default) {
|
||||
val repository = mangaRepositoryFactory.create(page.source)
|
||||
val url = repository.getPageUrl(page)
|
||||
preview.emitValue(
|
||||
MangaPage(
|
||||
id = page.id,
|
||||
url = url,
|
||||
preview = page.preview,
|
||||
source = page.source,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun setBrightness(brightness: Float) {
|
||||
val cf = colorFilter.value
|
||||
colorFilter.value = ReaderColorFilter(brightness, cf?.contrast ?: 0f).takeUnless { it.isEmpty }
|
||||
}
|
||||
|
||||
fun setContrast(contrast: Float) {
|
||||
val cf = colorFilter.value
|
||||
colorFilter.value = ReaderColorFilter(cf?.brightness ?: 0f, contrast).takeUnless { it.isEmpty }
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
colorFilter.value = null
|
||||
}
|
||||
|
||||
fun save() {
|
||||
launchLoadingJob(Dispatchers.Default) {
|
||||
mangaDataRepository.saveColorFilter(manga, colorFilter.value)
|
||||
onDismiss.emitCall(Unit)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package org.koitharu.kotatsu.reader.ui.colorfilter
|
||||
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.widget.ImageView
|
||||
import coil.target.ImageViewTarget
|
||||
|
||||
class ShadowViewTarget(
|
||||
view: ImageView,
|
||||
private val shadowView: ImageView,
|
||||
) : ImageViewTarget(view) {
|
||||
|
||||
override var drawable: Drawable?
|
||||
get() = super.drawable
|
||||
set(value) {
|
||||
super.drawable = value
|
||||
shadowView.setImageDrawable(value?.constantState?.newDrawable())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
package org.koitharu.kotatsu.reader.ui.config
|
||||
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.CompoundButton
|
||||
import androidx.activity.result.ActivityResultCallback
|
||||
import androidx.core.view.isGone
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.google.android.material.button.MaterialButtonToggleGroup
|
||||
import com.google.android.material.slider.Slider
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.prefs.ReaderMode
|
||||
import org.koitharu.kotatsu.core.prefs.observeAsLiveData
|
||||
import org.koitharu.kotatsu.core.ui.BaseBottomSheet
|
||||
import org.koitharu.kotatsu.core.util.ScreenOrientationHelper
|
||||
import org.koitharu.kotatsu.core.util.ext.viewLifecycleScope
|
||||
import org.koitharu.kotatsu.core.util.ext.withArgs
|
||||
import org.koitharu.kotatsu.databinding.SheetReaderConfigBinding
|
||||
import org.koitharu.kotatsu.reader.ui.PageSaveContract
|
||||
import org.koitharu.kotatsu.reader.ui.ReaderViewModel
|
||||
import org.koitharu.kotatsu.reader.ui.colorfilter.ColorFilterConfigActivity
|
||||
import org.koitharu.kotatsu.settings.SettingsActivity
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class ReaderConfigBottomSheet :
|
||||
BaseBottomSheet<SheetReaderConfigBinding>(),
|
||||
ActivityResultCallback<Uri?>,
|
||||
View.OnClickListener,
|
||||
MaterialButtonToggleGroup.OnButtonCheckedListener,
|
||||
Slider.OnChangeListener, CompoundButton.OnCheckedChangeListener {
|
||||
|
||||
private val viewModel by activityViewModels<ReaderViewModel>()
|
||||
private val savePageRequest = registerForActivityResult(PageSaveContract(), this)
|
||||
private var orientationHelper: ScreenOrientationHelper? = null
|
||||
private lateinit var mode: ReaderMode
|
||||
|
||||
@Inject
|
||||
lateinit var settings: AppSettings
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
mode = arguments?.getInt(ARG_MODE)
|
||||
?.let { ReaderMode.valueOf(it) }
|
||||
?: ReaderMode.STANDARD
|
||||
}
|
||||
|
||||
override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): SheetReaderConfigBinding {
|
||||
return SheetReaderConfigBinding.inflate(inflater, container, false)
|
||||
}
|
||||
|
||||
override fun onViewBindingCreated(binding: SheetReaderConfigBinding, savedInstanceState: Bundle?) {
|
||||
super.onViewBindingCreated(binding, savedInstanceState)
|
||||
observeScreenOrientation()
|
||||
binding.buttonStandard.isChecked = mode == ReaderMode.STANDARD
|
||||
binding.buttonReversed.isChecked = mode == ReaderMode.REVERSED
|
||||
binding.buttonWebtoon.isChecked = mode == ReaderMode.WEBTOON
|
||||
|
||||
binding.checkableGroup.addOnButtonCheckedListener(this)
|
||||
binding.buttonSavePage.setOnClickListener(this)
|
||||
binding.buttonScreenRotate.setOnClickListener(this)
|
||||
binding.buttonSettings.setOnClickListener(this)
|
||||
binding.buttonColorFilter.setOnClickListener(this)
|
||||
binding.sliderTimer.addOnChangeListener(this)
|
||||
binding.switchScrollTimer.setOnCheckedChangeListener(this)
|
||||
|
||||
settings.observeAsLiveData(
|
||||
context = lifecycleScope.coroutineContext + Dispatchers.Default,
|
||||
key = AppSettings.KEY_READER_AUTOSCROLL_SPEED,
|
||||
valueProducer = { readerAutoscrollSpeed },
|
||||
).observe(viewLifecycleOwner) {
|
||||
binding.sliderTimer.value = it.coerceIn(
|
||||
binding.sliderTimer.valueFrom,
|
||||
binding.sliderTimer.valueTo,
|
||||
)
|
||||
}
|
||||
findCallback()?.run {
|
||||
binding.switchScrollTimer.isChecked = isAutoScrollEnabled
|
||||
}
|
||||
}
|
||||
|
||||
override fun onClick(v: View) {
|
||||
when (v.id) {
|
||||
R.id.button_settings -> {
|
||||
startActivity(SettingsActivity.newReaderSettingsIntent(v.context))
|
||||
dismissAllowingStateLoss()
|
||||
}
|
||||
|
||||
R.id.button_save_page -> {
|
||||
val page = viewModel.getCurrentPage() ?: return
|
||||
viewModel.saveCurrentPage(page, savePageRequest)
|
||||
}
|
||||
|
||||
R.id.button_screen_rotate -> {
|
||||
orientationHelper?.toggleOrientation()
|
||||
}
|
||||
|
||||
R.id.button_color_filter -> {
|
||||
val page = viewModel.getCurrentPage() ?: return
|
||||
val manga = viewModel.manga ?: return
|
||||
startActivity(ColorFilterConfigActivity.newIntent(v.context, manga, page))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCheckedChanged(buttonView: CompoundButton, isChecked: Boolean) {
|
||||
when (buttonView.id) {
|
||||
R.id.switch_scroll_timer -> {
|
||||
findCallback()?.isAutoScrollEnabled = isChecked
|
||||
requireViewBinding().labelTimer.isVisible = isChecked
|
||||
requireViewBinding().sliderTimer.isVisible = isChecked
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onButtonChecked(group: MaterialButtonToggleGroup?, checkedId: Int, isChecked: Boolean) {
|
||||
if (!isChecked) {
|
||||
return
|
||||
}
|
||||
val newMode = when (checkedId) {
|
||||
R.id.button_standard -> ReaderMode.STANDARD
|
||||
R.id.button_webtoon -> ReaderMode.WEBTOON
|
||||
R.id.button_reversed -> ReaderMode.REVERSED
|
||||
else -> return
|
||||
}
|
||||
if (newMode == mode) {
|
||||
return
|
||||
}
|
||||
findCallback()?.onReaderModeChanged(newMode) ?: return
|
||||
mode = newMode
|
||||
}
|
||||
|
||||
override fun onValueChange(slider: Slider, value: Float, fromUser: Boolean) {
|
||||
if (fromUser) {
|
||||
settings.readerAutoscrollSpeed = value
|
||||
}
|
||||
}
|
||||
|
||||
override fun onActivityResult(result: Uri?) {
|
||||
viewModel.onActivityResult(result)
|
||||
dismissAllowingStateLoss()
|
||||
}
|
||||
|
||||
private fun observeScreenOrientation() {
|
||||
val helper = ScreenOrientationHelper(requireActivity())
|
||||
orientationHelper = helper
|
||||
helper.observeAutoOrientation()
|
||||
.onEach {
|
||||
requireViewBinding().buttonScreenRotate.isGone = it
|
||||
}.launchIn(viewLifecycleScope)
|
||||
}
|
||||
|
||||
private fun findCallback(): Callback? {
|
||||
return (parentFragment as? Callback) ?: (activity as? Callback)
|
||||
}
|
||||
|
||||
interface Callback {
|
||||
|
||||
var isAutoScrollEnabled: Boolean
|
||||
|
||||
fun onReaderModeChanged(mode: ReaderMode)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
private const val TAG = "ReaderConfigBottomSheet"
|
||||
private const val ARG_MODE = "mode"
|
||||
|
||||
fun show(fm: FragmentManager, mode: ReaderMode) = ReaderConfigBottomSheet().withArgs(1) {
|
||||
putInt(ARG_MODE, mode.id)
|
||||
}.show(fm, TAG)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package org.koitharu.kotatsu.reader.ui.config
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import androidx.lifecycle.MediatorLiveData
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.FlowCollector
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koitharu.kotatsu.core.model.ZoomMode
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.reader.domain.ReaderColorFilter
|
||||
|
||||
class ReaderSettings(
|
||||
private val parentScope: CoroutineScope,
|
||||
private val settings: AppSettings,
|
||||
private val colorFilterFlow: StateFlow<ReaderColorFilter?>,
|
||||
) : MediatorLiveData<ReaderSettings>() {
|
||||
|
||||
private val internalObserver = InternalObserver()
|
||||
private var collectJob: Job? = null
|
||||
|
||||
val zoomMode: ZoomMode
|
||||
get() = settings.zoomMode
|
||||
|
||||
val colorFilter: ReaderColorFilter?
|
||||
get() = colorFilterFlow.value
|
||||
|
||||
val isPagesNumbersEnabled: Boolean
|
||||
get() = settings.isPagesNumbersEnabled
|
||||
|
||||
override fun onInactive() {
|
||||
super.onInactive()
|
||||
settings.unsubscribe(internalObserver)
|
||||
collectJob?.cancel()
|
||||
collectJob = null
|
||||
}
|
||||
|
||||
override fun onActive() {
|
||||
super.onActive()
|
||||
settings.subscribe(internalObserver)
|
||||
collectJob?.cancel()
|
||||
collectJob = parentScope.launch {
|
||||
colorFilterFlow.collect(internalObserver)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getValue() = this
|
||||
|
||||
private fun notifyChanged() {
|
||||
value = value
|
||||
}
|
||||
|
||||
private inner class InternalObserver :
|
||||
FlowCollector<ReaderColorFilter?>,
|
||||
SharedPreferences.OnSharedPreferenceChangeListener {
|
||||
|
||||
override suspend fun emit(value: ReaderColorFilter?) {
|
||||
withContext(Dispatchers.Main.immediate) {
|
||||
notifyChanged()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) {
|
||||
if (key == AppSettings.KEY_ZOOM_MODE || key == AppSettings.KEY_PAGES_NUMBERS || key == AppSettings.KEY_WEBTOON_ZOOM) {
|
||||
notifyChanged()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package org.koitharu.kotatsu.reader.ui.pager
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.CallSuper
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.viewbinding.ViewBinding
|
||||
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
|
||||
import org.koitharu.kotatsu.core.os.NetworkState
|
||||
import org.koitharu.kotatsu.databinding.LayoutPageInfoBinding
|
||||
import org.koitharu.kotatsu.reader.domain.PageLoader
|
||||
import org.koitharu.kotatsu.reader.ui.config.ReaderSettings
|
||||
|
||||
abstract class BasePageHolder<B : ViewBinding>(
|
||||
protected val binding: B,
|
||||
loader: PageLoader,
|
||||
settings: ReaderSettings,
|
||||
networkState: NetworkState,
|
||||
exceptionResolver: ExceptionResolver,
|
||||
) : RecyclerView.ViewHolder(binding.root), PageHolderDelegate.Callback {
|
||||
|
||||
@Suppress("LeakingThis")
|
||||
protected val delegate = PageHolderDelegate(loader, settings, this, networkState, exceptionResolver)
|
||||
protected val bindingInfo = LayoutPageInfoBinding.bind(binding.root)
|
||||
|
||||
val context: Context
|
||||
get() = itemView.context
|
||||
|
||||
var boundData: ReaderPage? = null
|
||||
private set
|
||||
|
||||
fun requireData(): ReaderPage {
|
||||
return checkNotNull(boundData) { "Calling requireData() before bind()" }
|
||||
}
|
||||
|
||||
fun bind(data: ReaderPage) {
|
||||
boundData = data
|
||||
onBind(data)
|
||||
}
|
||||
|
||||
protected abstract fun onBind(data: ReaderPage)
|
||||
|
||||
@CallSuper
|
||||
open fun onAttachedToWindow() {
|
||||
delegate.onAttachedToWindow()
|
||||
}
|
||||
|
||||
@CallSuper
|
||||
open fun onDetachedFromWindow() {
|
||||
delegate.onDetachedFromWindow()
|
||||
}
|
||||
|
||||
@CallSuper
|
||||
open fun onRecycled() {
|
||||
delegate.onRecycle()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package org.koitharu.kotatsu.reader.ui.pager
|
||||
|
||||
import android.view.ViewGroup
|
||||
import androidx.recyclerview.widget.AsyncListDiffer
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
|
||||
import org.koitharu.kotatsu.core.os.NetworkState
|
||||
import org.koitharu.kotatsu.core.util.ext.resetTransformations
|
||||
import org.koitharu.kotatsu.reader.domain.PageLoader
|
||||
import org.koitharu.kotatsu.reader.ui.config.ReaderSettings
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
|
||||
@Suppress("LeakingThis")
|
||||
abstract class BaseReaderAdapter<H : BasePageHolder<*>>(
|
||||
private val loader: PageLoader,
|
||||
private val readerSettings: ReaderSettings,
|
||||
private val networkState: NetworkState,
|
||||
private val exceptionResolver: ExceptionResolver,
|
||||
) : RecyclerView.Adapter<H>() {
|
||||
|
||||
private val differ = AsyncListDiffer(this, DiffCallback())
|
||||
|
||||
init {
|
||||
stateRestorationPolicy = StateRestorationPolicy.PREVENT
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: H, position: Int) {
|
||||
holder.bind(differ.currentList[position])
|
||||
}
|
||||
|
||||
override fun onViewRecycled(holder: H) {
|
||||
holder.onRecycled()
|
||||
holder.itemView.resetTransformations()
|
||||
super.onViewRecycled(holder)
|
||||
}
|
||||
|
||||
override fun onViewAttachedToWindow(holder: H) {
|
||||
super.onViewAttachedToWindow(holder)
|
||||
holder.onAttachedToWindow()
|
||||
}
|
||||
|
||||
override fun onViewDetachedFromWindow(holder: H) {
|
||||
holder.onDetachedFromWindow()
|
||||
super.onViewDetachedFromWindow(holder)
|
||||
}
|
||||
|
||||
open fun getItem(position: Int): ReaderPage = differ.currentList[position]
|
||||
|
||||
open fun getItemOrNull(position: Int) = differ.currentList.getOrNull(position)
|
||||
|
||||
final override fun getItemCount() = differ.currentList.size
|
||||
|
||||
final override fun onCreateViewHolder(
|
||||
parent: ViewGroup,
|
||||
viewType: Int,
|
||||
): H = onCreateViewHolder(parent, loader, readerSettings, networkState, exceptionResolver)
|
||||
|
||||
suspend fun setItems(items: List<ReaderPage>) = suspendCoroutine { cont ->
|
||||
differ.submitList(items) {
|
||||
cont.resume(Unit)
|
||||
}
|
||||
}
|
||||
|
||||
protected abstract fun onCreateViewHolder(
|
||||
parent: ViewGroup,
|
||||
loader: PageLoader,
|
||||
settings: ReaderSettings,
|
||||
networkState: NetworkState,
|
||||
exceptionResolver: ExceptionResolver,
|
||||
): H
|
||||
|
||||
private class DiffCallback : DiffUtil.ItemCallback<ReaderPage>() {
|
||||
|
||||
override fun areItemsTheSame(oldItem: ReaderPage, newItem: ReaderPage): Boolean {
|
||||
return oldItem.id == newItem.id && oldItem.chapterId == newItem.chapterId
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(oldItem: ReaderPage, newItem: ReaderPage): Boolean {
|
||||
return oldItem == newItem
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package org.koitharu.kotatsu.reader.ui.pager
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.viewbinding.ViewBinding
|
||||
import org.koitharu.kotatsu.core.ui.BaseFragment
|
||||
import org.koitharu.kotatsu.core.util.ext.getParcelableCompat
|
||||
import org.koitharu.kotatsu.reader.ui.ReaderState
|
||||
import org.koitharu.kotatsu.reader.ui.ReaderViewModel
|
||||
|
||||
private const val KEY_STATE = "state"
|
||||
|
||||
abstract class BaseReaderFragment<B : ViewBinding> : BaseFragment<B>() {
|
||||
|
||||
protected val viewModel by activityViewModels<ReaderViewModel>()
|
||||
private var stateToSave: ReaderState? = null
|
||||
|
||||
override fun onViewBindingCreated(binding: B, savedInstanceState: Bundle?) {
|
||||
super.onViewBindingCreated(binding, savedInstanceState)
|
||||
var restoredState = savedInstanceState?.getParcelableCompat<ReaderState>(KEY_STATE)
|
||||
|
||||
viewModel.content.observe(viewLifecycleOwner) {
|
||||
onPagesChanged(it.pages, restoredState ?: it.state)
|
||||
restoredState = null
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
viewModel.saveCurrentState(getCurrentState())
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
stateToSave = getCurrentState()
|
||||
super.onDestroyView()
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
super.onSaveInstanceState(outState)
|
||||
getCurrentState()?.let {
|
||||
stateToSave = it
|
||||
}
|
||||
outState.putParcelable(KEY_STATE, stateToSave)
|
||||
}
|
||||
|
||||
override fun onWindowInsetsChanged(insets: Insets) = Unit
|
||||
|
||||
abstract fun switchPageBy(delta: Int)
|
||||
|
||||
abstract fun switchPageTo(position: Int, smooth: Boolean)
|
||||
|
||||
open fun scrollBy(delta: Int): Boolean = false
|
||||
|
||||
abstract fun getCurrentState(): ReaderState?
|
||||
|
||||
protected abstract fun onPagesChanged(pages: List<ReaderPage>, pendingState: ReaderState?)
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package org.koitharu.kotatsu.reader.ui.pager
|
||||
|
||||
interface OnBoundsScrollListener {
|
||||
|
||||
fun onScrolledToStart()
|
||||
|
||||
fun onScrolledToEnd()
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
package org.koitharu.kotatsu.reader.ui.pager
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.core.net.toUri
|
||||
import androidx.lifecycle.Observer
|
||||
import com.davemorrissey.labs.subscaleview.DefaultOnImageEventListener
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.cancelAndJoin
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.plus
|
||||
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
|
||||
import org.koitharu.kotatsu.core.os.NetworkState
|
||||
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||
import org.koitharu.kotatsu.reader.domain.PageLoader
|
||||
import org.koitharu.kotatsu.reader.ui.config.ReaderSettings
|
||||
import org.koitharu.kotatsu.util.ext.printStackTraceDebug
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
|
||||
class PageHolderDelegate(
|
||||
private val loader: PageLoader,
|
||||
private val readerSettings: ReaderSettings,
|
||||
private val callback: Callback,
|
||||
private val networkState: NetworkState,
|
||||
private val exceptionResolver: ExceptionResolver,
|
||||
) : DefaultOnImageEventListener, Observer<ReaderSettings> {
|
||||
|
||||
private val scope = loader.loaderScope + Dispatchers.Main.immediate
|
||||
private var state = State.EMPTY
|
||||
private var job: Job? = null
|
||||
private var file: File? = null
|
||||
private var error: Throwable? = null
|
||||
|
||||
fun onBind(page: MangaPage) {
|
||||
val prevJob = job
|
||||
job = scope.launch {
|
||||
prevJob?.cancelAndJoin()
|
||||
doLoad(page, force = false)
|
||||
}
|
||||
}
|
||||
|
||||
fun retry(page: MangaPage) {
|
||||
val prevJob = job
|
||||
job = scope.launch {
|
||||
prevJob?.cancelAndJoin()
|
||||
val e = error
|
||||
if (e != null && ExceptionResolver.canResolve(e)) {
|
||||
exceptionResolver.resolve(e)
|
||||
}
|
||||
doLoad(page, force = true)
|
||||
}
|
||||
}
|
||||
|
||||
fun showErrorDetails(url: String?) {
|
||||
val e = error ?: return
|
||||
exceptionResolver.showDetails(e, url)
|
||||
}
|
||||
|
||||
fun onAttachedToWindow() {
|
||||
readerSettings.observeForever(this)
|
||||
}
|
||||
|
||||
fun onDetachedFromWindow() {
|
||||
readerSettings.removeObserver(this)
|
||||
}
|
||||
|
||||
fun onRecycle() {
|
||||
state = State.EMPTY
|
||||
file = null
|
||||
error = null
|
||||
job?.cancel()
|
||||
}
|
||||
|
||||
override fun onReady() {
|
||||
state = State.SHOWING
|
||||
error = null
|
||||
callback.onImageShowing(readerSettings)
|
||||
}
|
||||
|
||||
override fun onImageLoaded() {
|
||||
state = State.SHOWN
|
||||
error = null
|
||||
callback.onImageShown()
|
||||
}
|
||||
|
||||
override fun onImageLoadError(e: Throwable) {
|
||||
e.printStackTraceDebug()
|
||||
val file = this.file
|
||||
error = e
|
||||
if (state == State.LOADED && e is IOException && file != null && file.exists()) {
|
||||
tryConvert(file, e)
|
||||
} else {
|
||||
state = State.ERROR
|
||||
callback.onError(e)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onChanged(value: ReaderSettings) {
|
||||
if (state == State.SHOWN) {
|
||||
callback.onImageShowing(readerSettings)
|
||||
}
|
||||
}
|
||||
|
||||
private fun tryConvert(file: File, e: Exception) {
|
||||
val prevJob = job
|
||||
job = scope.launch {
|
||||
prevJob?.join()
|
||||
state = State.CONVERTING
|
||||
try {
|
||||
loader.convertInPlace(file)
|
||||
state = State.CONVERTED
|
||||
callback.onImageReady(file.toUri())
|
||||
} catch (ce: CancellationException) {
|
||||
throw ce
|
||||
} catch (e2: Throwable) {
|
||||
e.addSuppressed(e2)
|
||||
state = State.ERROR
|
||||
callback.onError(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun doLoad(data: MangaPage, force: Boolean) {
|
||||
state = State.LOADING
|
||||
error = null
|
||||
callback.onLoadingStarted()
|
||||
try {
|
||||
val task = loader.loadPageAsync(data, force)
|
||||
file = coroutineScope {
|
||||
val progressObserver = observeProgress(this, task.progressAsFlow())
|
||||
val file = task.await()
|
||||
progressObserver.cancel()
|
||||
file
|
||||
}
|
||||
state = State.LOADED
|
||||
callback.onImageReady(checkNotNull(file).toUri())
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Throwable) {
|
||||
e.printStackTraceDebug()
|
||||
state = State.ERROR
|
||||
error = e
|
||||
callback.onError(e)
|
||||
if (e is IOException && !networkState.value) {
|
||||
networkState.awaitForConnection()
|
||||
retry(data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun observeProgress(scope: CoroutineScope, progress: Flow<Float>) = progress
|
||||
.debounce(250)
|
||||
.onEach { callback.onProgressChanged((100 * it).toInt()) }
|
||||
.launchIn(scope)
|
||||
|
||||
private enum class State {
|
||||
EMPTY, LOADING, LOADED, CONVERTING, CONVERTED, SHOWING, SHOWN, ERROR
|
||||
}
|
||||
|
||||
interface Callback {
|
||||
|
||||
fun onLoadingStarted()
|
||||
|
||||
fun onError(e: Throwable)
|
||||
|
||||
fun onImageReady(uri: Uri)
|
||||
|
||||
fun onImageShowing(settings: ReaderSettings)
|
||||
|
||||
fun onImageShown()
|
||||
|
||||
fun onProgressChanged(progress: Int)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package org.koitharu.kotatsu.reader.ui.pager
|
||||
|
||||
import android.os.Parcelable
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
|
||||
@Parcelize
|
||||
data class ReaderPage(
|
||||
val id: Long,
|
||||
val url: String,
|
||||
val preview: String?,
|
||||
val chapterId: Long,
|
||||
val index: Int,
|
||||
val source: MangaSource,
|
||||
) : Parcelable {
|
||||
|
||||
constructor(page: MangaPage, index: Int, chapterId: Long) : this(
|
||||
id = page.id,
|
||||
url = page.url,
|
||||
preview = page.preview,
|
||||
chapterId = chapterId,
|
||||
index = index,
|
||||
source = page.source,
|
||||
)
|
||||
|
||||
fun toMangaPage() = MangaPage(
|
||||
id = id,
|
||||
url = url,
|
||||
preview = preview,
|
||||
source = source,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package org.koitharu.kotatsu.reader.ui.pager
|
||||
|
||||
data class ReaderUiState(
|
||||
val mangaName: String?,
|
||||
val chapterName: String?,
|
||||
val chapterNumber: Int,
|
||||
val chaptersTotal: Int,
|
||||
val currentPage: Int,
|
||||
val totalPages: Int,
|
||||
val percent: Float,
|
||||
private val isSliderEnabled: Boolean,
|
||||
) {
|
||||
|
||||
fun isSliderAvailable(): Boolean {
|
||||
return isSliderEnabled && totalPages > 1 && currentPage < totalPages
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package org.koitharu.kotatsu.reader.ui.pager.reversed
|
||||
|
||||
import android.view.View
|
||||
import androidx.viewpager2.widget.ViewPager2
|
||||
|
||||
class ReversedPageAnimTransformer : ViewPager2.PageTransformer {
|
||||
|
||||
override fun transformPage(page: View, position: Float) = with(page) {
|
||||
translationX = -position * width
|
||||
pivotX = width.toFloat()
|
||||
pivotY = height / 2f
|
||||
cameraDistance = 20000f
|
||||
when {
|
||||
position < -1f || position > 1f -> {
|
||||
alpha = 0f
|
||||
rotationY = 0f
|
||||
translationZ = -1f
|
||||
}
|
||||
position <= 0f -> {
|
||||
alpha = 1f
|
||||
rotationY = 0f
|
||||
translationZ = 0f
|
||||
}
|
||||
position > 0f -> {
|
||||
alpha = 1f
|
||||
rotationY = 120 * position
|
||||
translationZ = 2f
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package org.koitharu.kotatsu.reader.ui.pager.reversed
|
||||
|
||||
import android.graphics.PointF
|
||||
import android.view.Gravity
|
||||
import android.widget.FrameLayout
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
|
||||
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
|
||||
import org.koitharu.kotatsu.core.model.ZoomMode
|
||||
import org.koitharu.kotatsu.core.os.NetworkState
|
||||
import org.koitharu.kotatsu.databinding.ItemPageBinding
|
||||
import org.koitharu.kotatsu.reader.domain.PageLoader
|
||||
import org.koitharu.kotatsu.reader.ui.config.ReaderSettings
|
||||
import org.koitharu.kotatsu.reader.ui.pager.standard.PageHolder
|
||||
|
||||
class ReversedPageHolder(
|
||||
owner: LifecycleOwner,
|
||||
binding: ItemPageBinding,
|
||||
loader: PageLoader,
|
||||
settings: ReaderSettings,
|
||||
networkState: NetworkState,
|
||||
exceptionResolver: ExceptionResolver,
|
||||
) : PageHolder(owner, binding, loader, settings, networkState, exceptionResolver) {
|
||||
|
||||
init {
|
||||
(binding.textViewNumber.layoutParams as FrameLayout.LayoutParams)
|
||||
.gravity = Gravity.START or Gravity.BOTTOM
|
||||
}
|
||||
|
||||
override fun onImageShowing(settings: ReaderSettings) {
|
||||
with(binding.ssiv) {
|
||||
maxScale = 2f * maxOf(
|
||||
width / sWidth.toFloat(),
|
||||
height / sHeight.toFloat(),
|
||||
)
|
||||
binding.ssiv.colorFilter = settings.colorFilter?.toColorFilter()
|
||||
when (settings.zoomMode) {
|
||||
ZoomMode.FIT_CENTER -> {
|
||||
minimumScaleType = SubsamplingScaleImageView.SCALE_TYPE_CENTER_INSIDE
|
||||
resetScaleAndCenter()
|
||||
}
|
||||
|
||||
ZoomMode.FIT_HEIGHT -> {
|
||||
minimumScaleType = SubsamplingScaleImageView.SCALE_TYPE_CUSTOM
|
||||
minScale = height / sHeight.toFloat()
|
||||
setScaleAndCenter(
|
||||
minScale,
|
||||
PointF(sWidth.toFloat(), sHeight / 2f),
|
||||
)
|
||||
}
|
||||
|
||||
ZoomMode.FIT_WIDTH -> {
|
||||
minimumScaleType = SubsamplingScaleImageView.SCALE_TYPE_CUSTOM
|
||||
minScale = width / sWidth.toFloat()
|
||||
setScaleAndCenter(
|
||||
minScale,
|
||||
PointF(sWidth / 2f, 0f),
|
||||
)
|
||||
}
|
||||
|
||||
ZoomMode.KEEP_START -> {
|
||||
minimumScaleType = SubsamplingScaleImageView.SCALE_TYPE_CENTER_INSIDE
|
||||
setScaleAndCenter(
|
||||
maxScale,
|
||||
PointF(sWidth.toFloat(), 0f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package org.koitharu.kotatsu.reader.ui.pager.reversed
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
|
||||
import org.koitharu.kotatsu.core.os.NetworkState
|
||||
import org.koitharu.kotatsu.databinding.ItemPageBinding
|
||||
import org.koitharu.kotatsu.reader.domain.PageLoader
|
||||
import org.koitharu.kotatsu.reader.ui.config.ReaderSettings
|
||||
import org.koitharu.kotatsu.reader.ui.pager.BaseReaderAdapter
|
||||
|
||||
class ReversedPagesAdapter(
|
||||
private val lifecycleOwner: LifecycleOwner,
|
||||
loader: PageLoader,
|
||||
settings: ReaderSettings,
|
||||
networkState: NetworkState,
|
||||
exceptionResolver: ExceptionResolver,
|
||||
) : BaseReaderAdapter<ReversedPageHolder>(loader, settings, networkState, exceptionResolver) {
|
||||
|
||||
override fun onCreateViewHolder(
|
||||
parent: ViewGroup,
|
||||
loader: PageLoader,
|
||||
settings: ReaderSettings,
|
||||
networkState: NetworkState,
|
||||
exceptionResolver: ExceptionResolver,
|
||||
) = ReversedPageHolder(
|
||||
owner = lifecycleOwner,
|
||||
binding = ItemPageBinding.inflate(LayoutInflater.from(parent.context), parent, false),
|
||||
loader = loader,
|
||||
settings = settings,
|
||||
networkState = networkState,
|
||||
exceptionResolver = exceptionResolver,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package org.koitharu.kotatsu.reader.ui.pager.reversed
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.view.children
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koitharu.kotatsu.core.os.NetworkState
|
||||
import org.koitharu.kotatsu.core.util.ext.doOnPageChanged
|
||||
import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled
|
||||
import org.koitharu.kotatsu.core.util.ext.recyclerView
|
||||
import org.koitharu.kotatsu.core.util.ext.resetTransformations
|
||||
import org.koitharu.kotatsu.core.util.ext.viewLifecycleScope
|
||||
import org.koitharu.kotatsu.databinding.FragmentReaderStandardBinding
|
||||
import org.koitharu.kotatsu.reader.domain.PageLoader
|
||||
import org.koitharu.kotatsu.reader.ui.ReaderState
|
||||
import org.koitharu.kotatsu.reader.ui.pager.BaseReaderAdapter
|
||||
import org.koitharu.kotatsu.reader.ui.pager.BaseReaderFragment
|
||||
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
|
||||
import org.koitharu.kotatsu.reader.ui.pager.standard.PagerReaderFragment
|
||||
import javax.inject.Inject
|
||||
import kotlin.math.absoluteValue
|
||||
|
||||
@AndroidEntryPoint
|
||||
class ReversedReaderFragment : BaseReaderFragment<FragmentReaderStandardBinding>() {
|
||||
|
||||
@Inject
|
||||
lateinit var networkState: NetworkState
|
||||
|
||||
@Inject
|
||||
lateinit var pageLoader: PageLoader
|
||||
|
||||
private var pagerAdapter: ReversedPagesAdapter? = null
|
||||
|
||||
override fun onCreateViewBinding(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
) = FragmentReaderStandardBinding.inflate(inflater, container, false)
|
||||
|
||||
override fun onViewBindingCreated(binding: FragmentReaderStandardBinding, savedInstanceState: Bundle?) {
|
||||
super.onViewBindingCreated(binding, savedInstanceState)
|
||||
pagerAdapter = ReversedPagesAdapter(
|
||||
lifecycleOwner = viewLifecycleOwner,
|
||||
loader = pageLoader,
|
||||
settings = viewModel.readerSettings,
|
||||
networkState = networkState,
|
||||
exceptionResolver = exceptionResolver,
|
||||
)
|
||||
with(binding.pager) {
|
||||
adapter = pagerAdapter
|
||||
offscreenPageLimit = 2
|
||||
doOnPageChanged(::notifyPageChanged)
|
||||
}
|
||||
|
||||
viewModel.readerAnimation.observe(viewLifecycleOwner) {
|
||||
val transformer = if (it) ReversedPageAnimTransformer() else null
|
||||
binding.pager.setPageTransformer(transformer)
|
||||
if (transformer == null) {
|
||||
binding.pager.recyclerView?.children?.forEach { v ->
|
||||
v.resetTransformations()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
pagerAdapter = null
|
||||
requireViewBinding().pager.adapter = null
|
||||
super.onDestroyView()
|
||||
}
|
||||
|
||||
override fun switchPageBy(delta: Int) {
|
||||
with(requireViewBinding().pager) {
|
||||
setCurrentItem(currentItem - delta, context.isAnimationsEnabled)
|
||||
}
|
||||
}
|
||||
|
||||
override fun switchPageTo(position: Int, smooth: Boolean) {
|
||||
with(requireViewBinding().pager) {
|
||||
setCurrentItem(
|
||||
reversed(position),
|
||||
smooth && context.isAnimationsEnabled && (currentItem - position).absoluteValue < PagerReaderFragment.SMOOTH_SCROLL_LIMIT,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPagesChanged(pages: List<ReaderPage>, pendingState: ReaderState?) {
|
||||
val reversedPages = pages.asReversed()
|
||||
viewLifecycleScope.launch {
|
||||
val items = async {
|
||||
pagerAdapter?.setItems(reversedPages)
|
||||
}
|
||||
if (pendingState != null) {
|
||||
val position = reversedPages.indexOfLast {
|
||||
it.chapterId == pendingState.chapterId && it.index == pendingState.page
|
||||
}
|
||||
items.await() ?: return@launch
|
||||
if (position != -1) {
|
||||
requireViewBinding().pager.setCurrentItem(position, false)
|
||||
notifyPageChanged(position)
|
||||
}
|
||||
} else {
|
||||
items.await()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun getCurrentState(): ReaderState? = viewBinding?.run {
|
||||
val adapter = pager.adapter as? BaseReaderAdapter<*>
|
||||
val page = adapter?.getItemOrNull(pager.currentItem) ?: return@run null
|
||||
ReaderState(
|
||||
chapterId = page.chapterId,
|
||||
page = page.index,
|
||||
scroll = 0,
|
||||
)
|
||||
}
|
||||
|
||||
private fun notifyPageChanged(page: Int) {
|
||||
viewModel.onCurrentPageChanged(reversed(page))
|
||||
}
|
||||
|
||||
private fun reversed(position: Int): Int {
|
||||
return ((pagerAdapter?.itemCount ?: 0) - position - 1).coerceAtLeast(0)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package org.koitharu.kotatsu.reader.ui.pager.standard
|
||||
|
||||
import android.view.View
|
||||
import androidx.viewpager2.widget.ViewPager2
|
||||
|
||||
class PageAnimTransformer : ViewPager2.PageTransformer {
|
||||
|
||||
override fun transformPage(page: View, position: Float) = with(page) {
|
||||
translationX = -position * width
|
||||
pivotX = 0f
|
||||
pivotY = height / 2f
|
||||
cameraDistance = 20000f
|
||||
when {
|
||||
position < -1f || position > 1f -> {
|
||||
alpha = 0f
|
||||
rotationY = 0f
|
||||
translationZ = -1f
|
||||
}
|
||||
position > 0f -> {
|
||||
alpha = 1f
|
||||
rotationY = 0f
|
||||
translationZ = 0f
|
||||
}
|
||||
position <= 0f -> {
|
||||
alpha = 1f
|
||||
rotationY = 120 * position
|
||||
translationZ = 2f
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package org.koitharu.kotatsu.reader.ui.pager.standard
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.graphics.PointF
|
||||
import android.net.Uri
|
||||
import android.view.View
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import com.davemorrissey.labs.subscaleview.ImageSource
|
||||
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
|
||||
import org.koitharu.kotatsu.core.model.ZoomMode
|
||||
import org.koitharu.kotatsu.core.os.NetworkState
|
||||
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
|
||||
import org.koitharu.kotatsu.core.util.ext.ifZero
|
||||
import org.koitharu.kotatsu.core.util.ext.isLowRamDevice
|
||||
import org.koitharu.kotatsu.databinding.ItemPageBinding
|
||||
import org.koitharu.kotatsu.reader.domain.PageLoader
|
||||
import org.koitharu.kotatsu.reader.ui.config.ReaderSettings
|
||||
import org.koitharu.kotatsu.reader.ui.pager.BasePageHolder
|
||||
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
|
||||
import org.koitharu.kotatsu.util.ext.*
|
||||
|
||||
open class PageHolder(
|
||||
owner: LifecycleOwner,
|
||||
binding: ItemPageBinding,
|
||||
loader: PageLoader,
|
||||
settings: ReaderSettings,
|
||||
networkState: NetworkState,
|
||||
exceptionResolver: ExceptionResolver,
|
||||
) : BasePageHolder<ItemPageBinding>(binding, loader, settings, networkState, exceptionResolver),
|
||||
View.OnClickListener {
|
||||
|
||||
init {
|
||||
binding.ssiv.bindToLifecycle(owner)
|
||||
binding.ssiv.isEagerLoadingEnabled = !context.isLowRamDevice()
|
||||
binding.ssiv.addOnImageEventListener(delegate)
|
||||
@Suppress("LeakingThis")
|
||||
bindingInfo.buttonRetry.setOnClickListener(this)
|
||||
@Suppress("LeakingThis")
|
||||
bindingInfo.buttonErrorDetails.setOnClickListener(this)
|
||||
binding.textViewNumber.isVisible = settings.isPagesNumbersEnabled
|
||||
}
|
||||
|
||||
@SuppressLint("SetTextI18n")
|
||||
override fun onBind(data: ReaderPage) {
|
||||
delegate.onBind(data.toMangaPage())
|
||||
binding.textViewNumber.text = (data.index + 1).toString()
|
||||
}
|
||||
|
||||
override fun onRecycled() {
|
||||
super.onRecycled()
|
||||
binding.ssiv.recycle()
|
||||
}
|
||||
|
||||
override fun onLoadingStarted() {
|
||||
bindingInfo.layoutError.isVisible = false
|
||||
bindingInfo.progressBar.show()
|
||||
binding.ssiv.recycle()
|
||||
}
|
||||
|
||||
override fun onProgressChanged(progress: Int) {
|
||||
if (progress in 0..100) {
|
||||
bindingInfo.progressBar.isIndeterminate = false
|
||||
bindingInfo.progressBar.setProgressCompat(progress, true)
|
||||
} else {
|
||||
bindingInfo.progressBar.isIndeterminate = true
|
||||
}
|
||||
}
|
||||
|
||||
override fun onImageReady(uri: Uri) {
|
||||
binding.ssiv.setImage(ImageSource.Uri(uri))
|
||||
}
|
||||
|
||||
override fun onImageShowing(settings: ReaderSettings) {
|
||||
binding.ssiv.maxScale = 2f * maxOf(
|
||||
binding.ssiv.width / binding.ssiv.sWidth.toFloat(),
|
||||
binding.ssiv.height / binding.ssiv.sHeight.toFloat(),
|
||||
)
|
||||
binding.ssiv.colorFilter = settings.colorFilter?.toColorFilter()
|
||||
when (settings.zoomMode) {
|
||||
ZoomMode.FIT_CENTER -> {
|
||||
binding.ssiv.minimumScaleType = SubsamplingScaleImageView.SCALE_TYPE_CENTER_INSIDE
|
||||
binding.ssiv.resetScaleAndCenter()
|
||||
}
|
||||
|
||||
ZoomMode.FIT_HEIGHT -> {
|
||||
binding.ssiv.minimumScaleType = SubsamplingScaleImageView.SCALE_TYPE_CUSTOM
|
||||
binding.ssiv.minScale = binding.ssiv.height / binding.ssiv.sHeight.toFloat()
|
||||
binding.ssiv.setScaleAndCenter(
|
||||
binding.ssiv.minScale,
|
||||
PointF(0f, binding.ssiv.sHeight / 2f),
|
||||
)
|
||||
}
|
||||
|
||||
ZoomMode.FIT_WIDTH -> {
|
||||
binding.ssiv.minimumScaleType = SubsamplingScaleImageView.SCALE_TYPE_CUSTOM
|
||||
binding.ssiv.minScale = binding.ssiv.width / binding.ssiv.sWidth.toFloat()
|
||||
binding.ssiv.setScaleAndCenter(
|
||||
binding.ssiv.minScale,
|
||||
PointF(binding.ssiv.sWidth / 2f, 0f),
|
||||
)
|
||||
}
|
||||
|
||||
ZoomMode.KEEP_START -> {
|
||||
binding.ssiv.minimumScaleType = SubsamplingScaleImageView.SCALE_TYPE_CENTER_INSIDE
|
||||
binding.ssiv.setScaleAndCenter(
|
||||
binding.ssiv.maxScale,
|
||||
PointF(0f, 0f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onImageShown() {
|
||||
bindingInfo.progressBar.hide()
|
||||
}
|
||||
|
||||
override fun onClick(v: View) {
|
||||
when (v.id) {
|
||||
R.id.button_retry -> delegate.retry(boundData?.toMangaPage() ?: return)
|
||||
R.id.button_error_details -> delegate.showErrorDetails(boundData?.url)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onError(e: Throwable) {
|
||||
bindingInfo.textViewError.text = e.getDisplayMessage(context.resources)
|
||||
bindingInfo.buttonRetry.setText(
|
||||
ExceptionResolver.getResolveStringId(e).ifZero { R.string.try_again },
|
||||
)
|
||||
bindingInfo.layoutError.isVisible = true
|
||||
bindingInfo.progressBar.hide()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package org.koitharu.kotatsu.reader.ui.pager.standard
|
||||
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.viewpager2.widget.ViewPager2
|
||||
import org.koitharu.kotatsu.reader.ui.pager.OnBoundsScrollListener
|
||||
|
||||
class PagerPaginationListener(
|
||||
private val adapter: RecyclerView.Adapter<*>,
|
||||
private val offset: Int,
|
||||
private val listener: OnBoundsScrollListener
|
||||
) : ViewPager2.OnPageChangeCallback() {
|
||||
|
||||
private var firstItemId: Long = 0
|
||||
private var lastItemId: Long = 0
|
||||
|
||||
override fun onPageSelected(position: Int) {
|
||||
val itemCount = adapter.itemCount
|
||||
if (itemCount == 0) {
|
||||
return
|
||||
}
|
||||
if (position <= offset && adapter.getItemId(0) != firstItemId) {
|
||||
firstItemId = adapter.getItemId(0)
|
||||
listener.onScrolledToStart()
|
||||
} else if (position >= itemCount - offset && adapter.getItemId(itemCount - 1) != lastItemId) {
|
||||
lastItemId = adapter.getItemId(itemCount - 1)
|
||||
listener.onScrolledToEnd()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package org.koitharu.kotatsu.reader.ui.pager.standard
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.view.children
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koitharu.kotatsu.core.os.NetworkState
|
||||
import org.koitharu.kotatsu.core.util.ext.doOnPageChanged
|
||||
import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled
|
||||
import org.koitharu.kotatsu.core.util.ext.recyclerView
|
||||
import org.koitharu.kotatsu.core.util.ext.resetTransformations
|
||||
import org.koitharu.kotatsu.core.util.ext.viewLifecycleScope
|
||||
import org.koitharu.kotatsu.databinding.FragmentReaderStandardBinding
|
||||
import org.koitharu.kotatsu.reader.domain.PageLoader
|
||||
import org.koitharu.kotatsu.reader.ui.ReaderState
|
||||
import org.koitharu.kotatsu.reader.ui.pager.BaseReaderAdapter
|
||||
import org.koitharu.kotatsu.reader.ui.pager.BaseReaderFragment
|
||||
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
|
||||
import javax.inject.Inject
|
||||
import kotlin.math.absoluteValue
|
||||
|
||||
@AndroidEntryPoint
|
||||
class PagerReaderFragment : BaseReaderFragment<FragmentReaderStandardBinding>() {
|
||||
|
||||
@Inject
|
||||
lateinit var networkState: NetworkState
|
||||
|
||||
@Inject
|
||||
lateinit var pageLoader: PageLoader
|
||||
|
||||
private var pagesAdapter: PagesAdapter? = null
|
||||
|
||||
override fun onCreateViewBinding(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
) = FragmentReaderStandardBinding.inflate(inflater, container, false)
|
||||
|
||||
override fun onViewBindingCreated(binding: FragmentReaderStandardBinding, savedInstanceState: Bundle?) {
|
||||
super.onViewBindingCreated(binding, savedInstanceState)
|
||||
pagesAdapter = PagesAdapter(
|
||||
lifecycleOwner = viewLifecycleOwner,
|
||||
loader = pageLoader,
|
||||
settings = viewModel.readerSettings,
|
||||
networkState = networkState,
|
||||
exceptionResolver = exceptionResolver,
|
||||
)
|
||||
with(binding.pager) {
|
||||
adapter = pagesAdapter
|
||||
offscreenPageLimit = 2
|
||||
doOnPageChanged(::notifyPageChanged)
|
||||
}
|
||||
|
||||
viewModel.readerAnimation.observe(viewLifecycleOwner) {
|
||||
val transformer = if (it) PageAnimTransformer() else null
|
||||
binding.pager.setPageTransformer(transformer)
|
||||
if (transformer == null) {
|
||||
binding.pager.recyclerView?.children?.forEach { view ->
|
||||
view.resetTransformations()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
pagesAdapter = null
|
||||
requireViewBinding().pager.adapter = null
|
||||
super.onDestroyView()
|
||||
}
|
||||
|
||||
override fun onPagesChanged(pages: List<ReaderPage>, pendingState: ReaderState?) {
|
||||
viewLifecycleScope.launch {
|
||||
val items = async {
|
||||
pagesAdapter?.setItems(pages)
|
||||
}
|
||||
if (pendingState != null) {
|
||||
val position = pages.indexOfFirst {
|
||||
it.chapterId == pendingState.chapterId && it.index == pendingState.page
|
||||
}
|
||||
items.await() ?: return@launch
|
||||
if (position != -1) {
|
||||
requireViewBinding().pager.setCurrentItem(position, false)
|
||||
notifyPageChanged(position)
|
||||
}
|
||||
} else {
|
||||
items.await()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun switchPageBy(delta: Int) {
|
||||
with(requireViewBinding().pager) {
|
||||
setCurrentItem(currentItem + delta, context.isAnimationsEnabled)
|
||||
}
|
||||
}
|
||||
|
||||
override fun switchPageTo(position: Int, smooth: Boolean) {
|
||||
with(requireViewBinding().pager) {
|
||||
setCurrentItem(
|
||||
position,
|
||||
smooth && context.isAnimationsEnabled && (currentItem - position).absoluteValue < SMOOTH_SCROLL_LIMIT,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getCurrentState(): ReaderState? = viewBinding?.run {
|
||||
val adapter = pager.adapter as? BaseReaderAdapter<*>
|
||||
val page = adapter?.getItemOrNull(pager.currentItem) ?: return@run null
|
||||
ReaderState(
|
||||
chapterId = page.chapterId,
|
||||
page = page.index,
|
||||
scroll = 0,
|
||||
)
|
||||
}
|
||||
|
||||
private fun notifyPageChanged(page: Int) {
|
||||
viewModel.onCurrentPageChanged(page)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
const val SMOOTH_SCROLL_LIMIT = 3
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package org.koitharu.kotatsu.reader.ui.pager.standard
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
|
||||
import org.koitharu.kotatsu.core.os.NetworkState
|
||||
import org.koitharu.kotatsu.databinding.ItemPageBinding
|
||||
import org.koitharu.kotatsu.reader.domain.PageLoader
|
||||
import org.koitharu.kotatsu.reader.ui.config.ReaderSettings
|
||||
import org.koitharu.kotatsu.reader.ui.pager.BaseReaderAdapter
|
||||
|
||||
class PagesAdapter(
|
||||
private val lifecycleOwner: LifecycleOwner,
|
||||
loader: PageLoader,
|
||||
settings: ReaderSettings,
|
||||
networkState: NetworkState,
|
||||
exceptionResolver: ExceptionResolver,
|
||||
) : BaseReaderAdapter<PageHolder>(loader, settings, networkState, exceptionResolver) {
|
||||
|
||||
override fun onCreateViewHolder(
|
||||
parent: ViewGroup,
|
||||
loader: PageLoader,
|
||||
settings: ReaderSettings,
|
||||
networkState: NetworkState,
|
||||
exceptionResolver: ExceptionResolver,
|
||||
) = PageHolder(
|
||||
owner = lifecycleOwner,
|
||||
binding = ItemPageBinding.inflate(LayoutInflater.from(parent.context), parent, false),
|
||||
loader = loader,
|
||||
settings = settings,
|
||||
networkState = networkState,
|
||||
exceptionResolver = exceptionResolver,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package org.koitharu.kotatsu.reader.ui.pager.webtoon
|
||||
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import org.koitharu.kotatsu.reader.ui.pager.OnBoundsScrollListener
|
||||
|
||||
class ListPaginationListener(
|
||||
private val offset: Int,
|
||||
private val listener: OnBoundsScrollListener
|
||||
) : RecyclerView.OnScrollListener() {
|
||||
|
||||
private var firstItemId: Long = 0
|
||||
private var lastItemId: Long = 0
|
||||
|
||||
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
|
||||
val adapter = recyclerView.adapter ?: return
|
||||
val layoutManager = (recyclerView.layoutManager as? LinearLayoutManager) ?: return
|
||||
val firstVisiblePosition = layoutManager.findFirstVisibleItemPosition()
|
||||
val lastVisiblePosition = layoutManager.findLastVisibleItemPosition()
|
||||
val itemCount = adapter.itemCount
|
||||
if (itemCount == 0) {
|
||||
return
|
||||
}
|
||||
if (lastVisiblePosition >= itemCount - offset && adapter.getItemId(itemCount - 1) != lastItemId) {
|
||||
lastItemId = adapter.getItemId(itemCount - 1)
|
||||
listener.onScrolledToEnd()
|
||||
} else if (firstVisiblePosition <= offset && adapter.getItemId(0) != firstItemId) {
|
||||
firstItemId = adapter.getItemId(0)
|
||||
listener.onScrolledToStart()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package org.koitharu.kotatsu.reader.ui.pager.webtoon
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
|
||||
import org.koitharu.kotatsu.core.os.NetworkState
|
||||
import org.koitharu.kotatsu.databinding.ItemPageWebtoonBinding
|
||||
import org.koitharu.kotatsu.reader.domain.PageLoader
|
||||
import org.koitharu.kotatsu.reader.ui.config.ReaderSettings
|
||||
import org.koitharu.kotatsu.reader.ui.pager.BaseReaderAdapter
|
||||
|
||||
class WebtoonAdapter(
|
||||
private val lifecycleOwner: LifecycleOwner,
|
||||
loader: PageLoader,
|
||||
settings: ReaderSettings,
|
||||
networkState: NetworkState,
|
||||
exceptionResolver: ExceptionResolver,
|
||||
) : BaseReaderAdapter<WebtoonHolder>(loader, settings, networkState, exceptionResolver) {
|
||||
|
||||
override fun onCreateViewHolder(
|
||||
parent: ViewGroup,
|
||||
loader: PageLoader,
|
||||
settings: ReaderSettings,
|
||||
networkState: NetworkState,
|
||||
exceptionResolver: ExceptionResolver,
|
||||
) = WebtoonHolder(
|
||||
owner = lifecycleOwner,
|
||||
binding = ItemPageWebtoonBinding.inflate(
|
||||
LayoutInflater.from(parent.context),
|
||||
parent,
|
||||
false,
|
||||
),
|
||||
loader = loader,
|
||||
settings = settings,
|
||||
networkState = networkState,
|
||||
exceptionResolver = exceptionResolver,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package org.koitharu.kotatsu.reader.ui.pager.webtoon
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.widget.FrameLayout
|
||||
import androidx.annotation.AttrRes
|
||||
import org.koitharu.kotatsu.R
|
||||
|
||||
class WebtoonFrameLayout @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
@AttrRes defStyleAttr: Int = 0,
|
||||
) : FrameLayout(context, attrs, defStyleAttr) {
|
||||
|
||||
private val target by lazy(LazyThreadSafetyMode.NONE) {
|
||||
findViewById<WebtoonImageView>(R.id.ssiv)
|
||||
}
|
||||
|
||||
fun dispatchVerticalScroll(dy: Int): Int {
|
||||
if (dy == 0) {
|
||||
return 0
|
||||
}
|
||||
val oldScroll = target.getScroll()
|
||||
target.scrollBy(dy)
|
||||
return target.getScroll() - oldScroll
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package org.koitharu.kotatsu.reader.ui.pager.webtoon
|
||||
|
||||
import android.net.Uri
|
||||
import android.view.View
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import com.davemorrissey.labs.subscaleview.ImageSource
|
||||
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
|
||||
import com.davemorrissey.labs.subscaleview.decoder.SkiaPooledImageRegionDecoder
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
|
||||
import org.koitharu.kotatsu.core.os.NetworkState
|
||||
import org.koitharu.kotatsu.core.util.GoneOnInvisibleListener
|
||||
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
|
||||
import org.koitharu.kotatsu.core.util.ext.ifZero
|
||||
import org.koitharu.kotatsu.databinding.ItemPageWebtoonBinding
|
||||
import org.koitharu.kotatsu.reader.domain.PageLoader
|
||||
import org.koitharu.kotatsu.reader.ui.config.ReaderSettings
|
||||
import org.koitharu.kotatsu.reader.ui.pager.BasePageHolder
|
||||
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
|
||||
|
||||
class WebtoonHolder(
|
||||
owner: LifecycleOwner,
|
||||
binding: ItemPageWebtoonBinding,
|
||||
loader: PageLoader,
|
||||
settings: ReaderSettings,
|
||||
networkState: NetworkState,
|
||||
exceptionResolver: ExceptionResolver,
|
||||
) : BasePageHolder<ItemPageWebtoonBinding>(binding, loader, settings, networkState, exceptionResolver),
|
||||
View.OnClickListener {
|
||||
|
||||
private var scrollToRestore = 0
|
||||
private val goneOnInvisibleListener = GoneOnInvisibleListener(bindingInfo.progressBar)
|
||||
|
||||
init {
|
||||
binding.ssiv.bindToLifecycle(owner)
|
||||
binding.ssiv.regionDecoderFactory = SkiaPooledImageRegionDecoder.Factory()
|
||||
binding.ssiv.addOnImageEventListener(delegate)
|
||||
bindingInfo.buttonRetry.setOnClickListener(this)
|
||||
bindingInfo.buttonErrorDetails.setOnClickListener(this)
|
||||
}
|
||||
|
||||
override fun onBind(data: ReaderPage) {
|
||||
delegate.onBind(data.toMangaPage())
|
||||
}
|
||||
|
||||
override fun onRecycled() {
|
||||
super.onRecycled()
|
||||
binding.ssiv.recycle()
|
||||
}
|
||||
|
||||
override fun onAttachedToWindow() {
|
||||
super.onAttachedToWindow()
|
||||
goneOnInvisibleListener.attach()
|
||||
}
|
||||
|
||||
override fun onDetachedFromWindow() {
|
||||
super.onDetachedFromWindow()
|
||||
goneOnInvisibleListener.detach()
|
||||
}
|
||||
|
||||
override fun onLoadingStarted() {
|
||||
bindingInfo.layoutError.isVisible = false
|
||||
bindingInfo.progressBar.show()
|
||||
binding.ssiv.recycle()
|
||||
}
|
||||
|
||||
override fun onProgressChanged(progress: Int) {
|
||||
if (progress in 0..100) {
|
||||
bindingInfo.progressBar.isIndeterminate = false
|
||||
bindingInfo.progressBar.setProgressCompat(progress, true)
|
||||
} else {
|
||||
bindingInfo.progressBar.isIndeterminate = true
|
||||
}
|
||||
}
|
||||
|
||||
override fun onImageReady(uri: Uri) {
|
||||
binding.ssiv.setImage(ImageSource.Uri(uri))
|
||||
}
|
||||
|
||||
override fun onImageShowing(settings: ReaderSettings) {
|
||||
binding.ssiv.colorFilter = settings.colorFilter?.toColorFilter()
|
||||
with(binding.ssiv) {
|
||||
minimumScaleType = SubsamplingScaleImageView.SCALE_TYPE_CUSTOM
|
||||
minScale = width / sWidth.toFloat()
|
||||
maxScale = minScale
|
||||
scrollTo(
|
||||
when {
|
||||
scrollToRestore != 0 -> scrollToRestore
|
||||
itemView.top < 0 -> getScrollRange()
|
||||
else -> 0
|
||||
},
|
||||
)
|
||||
scrollToRestore = 0
|
||||
}
|
||||
}
|
||||
|
||||
override fun onImageShown() {
|
||||
bindingInfo.progressBar.hide()
|
||||
}
|
||||
|
||||
override fun onClick(v: View) {
|
||||
when (v.id) {
|
||||
R.id.button_retry -> delegate.retry(boundData?.toMangaPage() ?: return)
|
||||
R.id.button_error_details -> delegate.showErrorDetails(boundData?.url)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onError(e: Throwable) {
|
||||
bindingInfo.textViewError.text = e.getDisplayMessage(context.resources)
|
||||
bindingInfo.buttonRetry.setText(
|
||||
ExceptionResolver.getResolveStringId(e).ifZero { R.string.try_again },
|
||||
)
|
||||
bindingInfo.layoutError.isVisible = true
|
||||
bindingInfo.progressBar.hide()
|
||||
}
|
||||
|
||||
fun getScrollY() = binding.ssiv.getScroll()
|
||||
|
||||
fun restoreScroll(scroll: Int) {
|
||||
if (binding.ssiv.isReady) {
|
||||
binding.ssiv.scrollTo(scroll)
|
||||
} else {
|
||||
scrollToRestore = scroll
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package org.koitharu.kotatsu.reader.ui.pager.webtoon
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.PointF
|
||||
import android.util.AttributeSet
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
|
||||
import org.koitharu.kotatsu.core.util.ext.parents
|
||||
import org.koitharu.kotatsu.parsers.util.toIntUp
|
||||
|
||||
private const val SCROLL_UNKNOWN = -1
|
||||
|
||||
class WebtoonImageView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attr: AttributeSet? = null,
|
||||
) : SubsamplingScaleImageView(context, attr) {
|
||||
|
||||
private val ct = PointF()
|
||||
|
||||
private var scrollPos = 0
|
||||
private var scrollRange = SCROLL_UNKNOWN
|
||||
|
||||
fun scrollBy(delta: Int) {
|
||||
val maxScroll = getScrollRange()
|
||||
if (maxScroll == 0) {
|
||||
return
|
||||
}
|
||||
val newScroll = scrollPos + delta
|
||||
scrollToInternal(newScroll.coerceIn(0, maxScroll))
|
||||
}
|
||||
|
||||
fun scrollTo(y: Int) {
|
||||
val maxScroll = getScrollRange()
|
||||
if (maxScroll == 0) {
|
||||
resetScaleAndCenter()
|
||||
return
|
||||
}
|
||||
scrollToInternal(y.coerceIn(0, maxScroll))
|
||||
}
|
||||
|
||||
fun getScroll() = scrollPos
|
||||
|
||||
fun getScrollRange(): Int {
|
||||
if (scrollRange == SCROLL_UNKNOWN) {
|
||||
computeScrollRange()
|
||||
}
|
||||
return scrollRange.coerceAtLeast(0)
|
||||
}
|
||||
|
||||
override fun recycle() {
|
||||
scrollRange = SCROLL_UNKNOWN
|
||||
scrollPos = 0
|
||||
super.recycle()
|
||||
}
|
||||
|
||||
override fun getSuggestedMinimumHeight(): Int {
|
||||
var desiredHeight = super.getSuggestedMinimumHeight()
|
||||
if (sHeight == 0) {
|
||||
val parentHeight = parentHeight()
|
||||
if (desiredHeight < parentHeight) {
|
||||
desiredHeight = parentHeight
|
||||
}
|
||||
}
|
||||
return desiredHeight
|
||||
}
|
||||
|
||||
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
|
||||
val widthSpecMode = MeasureSpec.getMode(widthMeasureSpec)
|
||||
val heightSpecMode = MeasureSpec.getMode(heightMeasureSpec)
|
||||
val parentWidth = MeasureSpec.getSize(widthMeasureSpec)
|
||||
val parentHeight = MeasureSpec.getSize(heightMeasureSpec)
|
||||
val resizeWidth = widthSpecMode != MeasureSpec.EXACTLY
|
||||
val resizeHeight = heightSpecMode != MeasureSpec.EXACTLY
|
||||
var width = parentWidth
|
||||
var height = parentHeight
|
||||
if (sWidth > 0 && sHeight > 0) {
|
||||
if (resizeWidth && resizeHeight) {
|
||||
width = sWidth
|
||||
height = sHeight
|
||||
} else if (resizeHeight) {
|
||||
height = (sHeight.toDouble() / sWidth.toDouble() * width).toInt()
|
||||
} else if (resizeWidth) {
|
||||
width = (sWidth.toDouble() / sHeight.toDouble() * height).toInt()
|
||||
}
|
||||
}
|
||||
width = width.coerceAtLeast(suggestedMinimumWidth)
|
||||
height = height.coerceIn(suggestedMinimumHeight, parentHeight())
|
||||
setMeasuredDimension(width, height)
|
||||
}
|
||||
|
||||
private fun scrollToInternal(pos: Int) {
|
||||
scrollPos = pos
|
||||
ct.set(sWidth / 2f, (height / 2f + pos.toFloat()) / minScale)
|
||||
setScaleAndCenter(minScale, ct)
|
||||
}
|
||||
|
||||
private fun computeScrollRange() {
|
||||
if (!isReady) {
|
||||
return
|
||||
}
|
||||
val totalHeight = (sHeight * minScale).toIntUp()
|
||||
scrollRange = (totalHeight - height).coerceAtLeast(0)
|
||||
}
|
||||
|
||||
private fun parentHeight(): Int {
|
||||
return parents.firstNotNullOfOrNull { it as? RecyclerView }?.height ?: 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package org.koitharu.kotatsu.reader.ui.pager.webtoon
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import kotlin.math.sign
|
||||
|
||||
@Suppress("unused")
|
||||
class WebtoonLayoutManager : LinearLayoutManager {
|
||||
|
||||
private var scrollDirection: Int = 0
|
||||
|
||||
constructor(context: Context) : super(context)
|
||||
constructor(
|
||||
context: Context,
|
||||
orientation: Int,
|
||||
reverseLayout: Boolean,
|
||||
) : super(context, orientation, reverseLayout)
|
||||
|
||||
constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet?,
|
||||
defStyleAttr: Int,
|
||||
defStyleRes: Int,
|
||||
) : super(context, attrs, defStyleAttr, defStyleRes)
|
||||
|
||||
override fun scrollVerticallyBy(dy: Int, recycler: RecyclerView.Recycler?, state: RecyclerView.State): Int {
|
||||
scrollDirection = dy.sign
|
||||
return super.scrollVerticallyBy(dy, recycler, state)
|
||||
}
|
||||
|
||||
override fun calculateExtraLayoutSpace(state: RecyclerView.State, extraLayoutSpace: IntArray) {
|
||||
if (state.hasTargetScrollPosition()) {
|
||||
super.calculateExtraLayoutSpace(state, extraLayoutSpace)
|
||||
return
|
||||
}
|
||||
val pageSize = height
|
||||
extraLayoutSpace[0] = if (scrollDirection < 0) pageSize else 0
|
||||
extraLayoutSpace[1] = if (scrollDirection < 0) 0 else pageSize
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package org.koitharu.kotatsu.reader.ui.pager.webtoon
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import android.view.animation.AccelerateDecelerateInterpolator
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koitharu.kotatsu.core.os.NetworkState
|
||||
import org.koitharu.kotatsu.core.util.ext.findCenterViewPosition
|
||||
import org.koitharu.kotatsu.core.util.ext.firstVisibleItemPosition
|
||||
import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled
|
||||
import org.koitharu.kotatsu.core.util.ext.viewLifecycleScope
|
||||
import org.koitharu.kotatsu.databinding.FragmentReaderWebtoonBinding
|
||||
import org.koitharu.kotatsu.reader.domain.PageLoader
|
||||
import org.koitharu.kotatsu.reader.ui.ReaderState
|
||||
import org.koitharu.kotatsu.reader.ui.pager.BaseReaderAdapter
|
||||
import org.koitharu.kotatsu.reader.ui.pager.BaseReaderFragment
|
||||
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class WebtoonReaderFragment : BaseReaderFragment<FragmentReaderWebtoonBinding>() {
|
||||
|
||||
@Inject
|
||||
lateinit var networkState: NetworkState
|
||||
|
||||
@Inject
|
||||
lateinit var pageLoader: PageLoader
|
||||
|
||||
private val scrollInterpolator = AccelerateDecelerateInterpolator()
|
||||
private var webtoonAdapter: WebtoonAdapter? = null
|
||||
|
||||
override fun onCreateViewBinding(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
) = FragmentReaderWebtoonBinding.inflate(inflater, container, false)
|
||||
|
||||
override fun onViewBindingCreated(binding: FragmentReaderWebtoonBinding, savedInstanceState: Bundle?) {
|
||||
super.onViewBindingCreated(binding, savedInstanceState)
|
||||
webtoonAdapter = WebtoonAdapter(
|
||||
lifecycleOwner = viewLifecycleOwner,
|
||||
loader = pageLoader,
|
||||
settings = viewModel.readerSettings,
|
||||
networkState = networkState,
|
||||
exceptionResolver = exceptionResolver,
|
||||
)
|
||||
with(binding.recyclerView) {
|
||||
setHasFixedSize(true)
|
||||
adapter = webtoonAdapter
|
||||
addOnPageScrollListener(PageScrollListener())
|
||||
}
|
||||
|
||||
viewModel.isWebtoonZoomEnabled.observe(viewLifecycleOwner) {
|
||||
binding.frame.isZoomEnable = it
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
webtoonAdapter = null
|
||||
requireViewBinding().recyclerView.adapter = null
|
||||
super.onDestroyView()
|
||||
}
|
||||
|
||||
override fun onPagesChanged(pages: List<ReaderPage>, pendingState: ReaderState?) {
|
||||
viewLifecycleScope.launch {
|
||||
val setItems = async { webtoonAdapter?.setItems(pages) }
|
||||
if (pendingState != null) {
|
||||
val position = pages.indexOfFirst {
|
||||
it.chapterId == pendingState.chapterId && it.index == pendingState.page
|
||||
}
|
||||
setItems.await() ?: return@launch
|
||||
if (position != -1) {
|
||||
with(requireViewBinding().recyclerView) {
|
||||
firstVisibleItemPosition = position
|
||||
post {
|
||||
(findViewHolderForAdapterPosition(position) as? WebtoonHolder)
|
||||
?.restoreScroll(pendingState.scroll)
|
||||
}
|
||||
}
|
||||
notifyPageChanged(position)
|
||||
}
|
||||
} else {
|
||||
setItems.await()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun getCurrentState(): ReaderState? = viewBinding?.run {
|
||||
val currentItem = recyclerView.findCenterViewPosition()
|
||||
val adapter = recyclerView.adapter as? BaseReaderAdapter<*>
|
||||
val page = adapter?.getItemOrNull(currentItem) ?: return@run null
|
||||
ReaderState(
|
||||
chapterId = page.chapterId,
|
||||
page = page.index,
|
||||
scroll = (recyclerView.findViewHolderForAdapterPosition(currentItem) as? WebtoonHolder)
|
||||
?.getScrollY() ?: 0,
|
||||
)
|
||||
}
|
||||
|
||||
private fun notifyPageChanged(page: Int) {
|
||||
viewModel.onCurrentPageChanged(page)
|
||||
}
|
||||
|
||||
override fun switchPageBy(delta: Int) {
|
||||
with(requireViewBinding().recyclerView) {
|
||||
if (context.isAnimationsEnabled) {
|
||||
smoothScrollBy(0, (height * 0.9).toInt() * delta, scrollInterpolator)
|
||||
} else {
|
||||
nestedScrollBy(0, (height * 0.9).toInt() * delta)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun switchPageTo(position: Int, smooth: Boolean) {
|
||||
requireViewBinding().recyclerView.firstVisibleItemPosition = position
|
||||
}
|
||||
|
||||
override fun scrollBy(delta: Int): Boolean {
|
||||
requireViewBinding().recyclerView.nestedScrollBy(0, delta)
|
||||
return true
|
||||
}
|
||||
|
||||
private inner class PageScrollListener : WebtoonRecyclerView.OnPageScrollListener() {
|
||||
|
||||
override fun onPageChanged(recyclerView: WebtoonRecyclerView, index: Int) {
|
||||
super.onPageChanged(recyclerView, index)
|
||||
notifyPageChanged(index)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package org.koitharu.kotatsu.reader.ui.pager.webtoon
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import androidx.core.view.ViewCompat.TYPE_TOUCH
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import org.koitharu.kotatsu.core.util.ext.findCenterViewPosition
|
||||
import java.util.LinkedList
|
||||
|
||||
class WebtoonRecyclerView @JvmOverloads constructor(
|
||||
context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
|
||||
) : RecyclerView(context, attrs, defStyleAttr) {
|
||||
|
||||
private var onPageScrollListeners: MutableList<OnPageScrollListener>? = null
|
||||
|
||||
override fun startNestedScroll(axes: Int) = startNestedScroll(axes, TYPE_TOUCH)
|
||||
|
||||
override fun startNestedScroll(axes: Int, type: Int): Boolean = true
|
||||
|
||||
override fun dispatchNestedPreScroll(
|
||||
dx: Int,
|
||||
dy: Int,
|
||||
consumed: IntArray?,
|
||||
offsetInWindow: IntArray?
|
||||
) = dispatchNestedPreScroll(dx, dy, consumed, offsetInWindow, TYPE_TOUCH)
|
||||
|
||||
override fun dispatchNestedPreScroll(
|
||||
dx: Int,
|
||||
dy: Int,
|
||||
consumed: IntArray?,
|
||||
offsetInWindow: IntArray?,
|
||||
type: Int
|
||||
): Boolean {
|
||||
val consumedY = consumeVerticalScroll(dy)
|
||||
if (consumed != null) {
|
||||
consumed[0] = 0
|
||||
consumed[1] = consumedY
|
||||
}
|
||||
notifyScrollChanged(dy)
|
||||
return consumedY != 0 || dy == 0
|
||||
}
|
||||
|
||||
private fun consumeVerticalScroll(dy: Int): Int {
|
||||
if (childCount == 0) {
|
||||
return 0
|
||||
}
|
||||
when {
|
||||
dy > 0 -> {
|
||||
val child = getChildAt(0) as WebtoonFrameLayout
|
||||
var consumedByChild = child.dispatchVerticalScroll(dy)
|
||||
if (consumedByChild < dy) {
|
||||
if (childCount > 1) {
|
||||
val nextChild = getChildAt(1) as WebtoonFrameLayout
|
||||
val unconsumed =
|
||||
dy - consumedByChild - nextChild.top //will be consumed by scroll
|
||||
if (unconsumed > 0) {
|
||||
consumedByChild += nextChild.dispatchVerticalScroll(unconsumed)
|
||||
}
|
||||
}
|
||||
}
|
||||
return consumedByChild
|
||||
}
|
||||
|
||||
dy < 0 -> {
|
||||
val child = getChildAt(childCount - 1) as WebtoonFrameLayout
|
||||
var consumedByChild = child.dispatchVerticalScroll(dy)
|
||||
if (consumedByChild > dy) {
|
||||
if (childCount > 1) {
|
||||
val nextChild = getChildAt(childCount - 2) as WebtoonFrameLayout
|
||||
val unconsumed =
|
||||
dy - consumedByChild + (height - nextChild.bottom) //will be consumed by scroll
|
||||
if (unconsumed < 0) {
|
||||
consumedByChild += nextChild.dispatchVerticalScroll(unconsumed)
|
||||
}
|
||||
}
|
||||
}
|
||||
return consumedByChild
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
fun addOnPageScrollListener(listener: OnPageScrollListener) {
|
||||
val list = onPageScrollListeners ?: LinkedList<OnPageScrollListener>().also { onPageScrollListeners = it }
|
||||
list.add(listener)
|
||||
}
|
||||
|
||||
fun removeOnPageScrollListener(listener: OnPageScrollListener) {
|
||||
onPageScrollListeners?.remove(listener)
|
||||
}
|
||||
|
||||
private fun notifyScrollChanged(dy: Int) {
|
||||
val listeners = onPageScrollListeners
|
||||
if (listeners.isNullOrEmpty()) {
|
||||
return
|
||||
}
|
||||
val centerPosition = findCenterViewPosition()
|
||||
listeners.forEach { it.dispatchScroll(this, dy, centerPosition) }
|
||||
}
|
||||
|
||||
abstract class OnPageScrollListener {
|
||||
|
||||
private var lastPosition = NO_POSITION
|
||||
|
||||
fun dispatchScroll(recyclerView: WebtoonRecyclerView, dy: Int, centerPosition: Int) {
|
||||
onScroll(recyclerView, dy)
|
||||
if (centerPosition != NO_POSITION && centerPosition != lastPosition) {
|
||||
lastPosition = centerPosition
|
||||
onPageChanged(recyclerView, centerPosition)
|
||||
}
|
||||
}
|
||||
|
||||
open fun onScroll(recyclerView: WebtoonRecyclerView, dy: Int) = Unit
|
||||
|
||||
open fun onPageChanged(recyclerView: WebtoonRecyclerView, index: Int) = Unit
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
package org.koitharu.kotatsu.reader.ui.pager.webtoon
|
||||
|
||||
import android.animation.ObjectAnimator
|
||||
import android.content.Context
|
||||
import android.graphics.Matrix
|
||||
import android.graphics.Rect
|
||||
import android.graphics.RectF
|
||||
import android.util.AttributeSet
|
||||
import android.view.GestureDetector
|
||||
import android.view.MotionEvent
|
||||
import android.view.ScaleGestureDetector
|
||||
import android.view.animation.AccelerateDecelerateInterpolator
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.OverScroller
|
||||
import androidx.core.view.GestureDetectorCompat
|
||||
|
||||
private const val MAX_SCALE = 2.5f
|
||||
private const val MIN_SCALE = 1f // under-scaling disabled due to buggy nested scroll
|
||||
|
||||
class WebtoonScalingFrame @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyles: Int = 0,
|
||||
) : FrameLayout(context, attrs, defStyles), ScaleGestureDetector.OnScaleGestureListener {
|
||||
|
||||
private val targetChild by lazy(LazyThreadSafetyMode.NONE) { getChildAt(0) }
|
||||
|
||||
private val scaleDetector = ScaleGestureDetector(context, this)
|
||||
private val gestureDetector = GestureDetectorCompat(context, GestureListener())
|
||||
private val overScroller = OverScroller(context, AccelerateDecelerateInterpolator())
|
||||
private val transformMatrix = Matrix()
|
||||
private val matrixValues = FloatArray(9)
|
||||
private val scale
|
||||
get() = matrixValues[Matrix.MSCALE_X]
|
||||
private val transX
|
||||
get() = halfWidth * (scale - 1f) + matrixValues[Matrix.MTRANS_X]
|
||||
private val transY
|
||||
get() = halfHeight * (scale - 1f) + matrixValues[Matrix.MTRANS_Y]
|
||||
private var halfWidth = 0f
|
||||
private var halfHeight = 0f
|
||||
private val translateBounds = RectF()
|
||||
private val targetHitRect = Rect()
|
||||
private var pendingScroll = 0
|
||||
|
||||
var isZoomEnable = true
|
||||
set(value) {
|
||||
field = value
|
||||
if (scale != 1f) {
|
||||
scaleChild(1f, halfWidth, halfHeight)
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
syncMatrixValues()
|
||||
}
|
||||
|
||||
override fun dispatchTouchEvent(ev: MotionEvent?): Boolean {
|
||||
if (!isZoomEnable || ev == null) {
|
||||
return super.dispatchTouchEvent(ev)
|
||||
}
|
||||
|
||||
if (ev.action == MotionEvent.ACTION_DOWN && overScroller.computeScrollOffset()) {
|
||||
overScroller.forceFinished(true)
|
||||
}
|
||||
|
||||
gestureDetector.onTouchEvent(ev)
|
||||
scaleDetector.onTouchEvent(ev)
|
||||
|
||||
// Offset event to inside the child view
|
||||
if (scale < 1 && !targetHitRect.contains(ev.x.toInt(), ev.y.toInt())) {
|
||||
ev.offsetLocation(halfWidth - ev.x + targetHitRect.width() / 3, 0f)
|
||||
}
|
||||
|
||||
// Send action cancel to avoid recycler jump when scale end
|
||||
if (scaleDetector.isInProgress) {
|
||||
ev.action = MotionEvent.ACTION_CANCEL
|
||||
}
|
||||
return super.dispatchTouchEvent(ev)
|
||||
}
|
||||
|
||||
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
|
||||
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
|
||||
halfWidth = measuredWidth / 2f
|
||||
halfHeight = measuredHeight / 2f
|
||||
}
|
||||
|
||||
private fun invalidateTarget() {
|
||||
adjustBounds()
|
||||
targetChild.run {
|
||||
scaleX = scale
|
||||
scaleY = scale
|
||||
translationX = transX
|
||||
translationY = transY
|
||||
}
|
||||
|
||||
val newHeight = if (scale < 1f) (height / scale).toInt() else height
|
||||
if (newHeight != targetChild.height) {
|
||||
targetChild.layoutParams.height = newHeight
|
||||
targetChild.requestLayout()
|
||||
}
|
||||
|
||||
if (scale < 1) {
|
||||
targetChild.getHitRect(targetHitRect)
|
||||
targetChild.scrollBy(0, pendingScroll)
|
||||
pendingScroll = 0
|
||||
}
|
||||
}
|
||||
|
||||
private fun syncMatrixValues() {
|
||||
transformMatrix.getValues(matrixValues)
|
||||
}
|
||||
|
||||
private fun adjustBounds() {
|
||||
syncMatrixValues()
|
||||
val dx = when {
|
||||
transX < translateBounds.left -> translateBounds.left - transX
|
||||
transX > translateBounds.right -> translateBounds.right - transX
|
||||
else -> 0f
|
||||
}
|
||||
|
||||
val dy = when {
|
||||
transY < translateBounds.top -> translateBounds.top - transY
|
||||
transY > translateBounds.bottom -> translateBounds.bottom - transY
|
||||
else -> 0f
|
||||
}
|
||||
|
||||
pendingScroll = dy.toInt()
|
||||
transformMatrix.postTranslate(dx, dy)
|
||||
syncMatrixValues()
|
||||
}
|
||||
|
||||
private fun scaleChild(newScale: Float, focusX: Float, focusY: Float) {
|
||||
val factor = newScale / scale
|
||||
if (newScale > 1) {
|
||||
translateBounds.set(
|
||||
halfWidth * (1 - newScale),
|
||||
halfHeight * (1 - newScale),
|
||||
halfWidth * (newScale - 1),
|
||||
halfHeight * (newScale - 1),
|
||||
)
|
||||
} else {
|
||||
translateBounds.set(
|
||||
0f,
|
||||
halfHeight - halfHeight / newScale,
|
||||
0f,
|
||||
halfHeight - halfHeight / newScale,
|
||||
)
|
||||
}
|
||||
transformMatrix.postScale(factor, factor, focusX, focusY)
|
||||
invalidateTarget()
|
||||
}
|
||||
|
||||
|
||||
override fun onScale(detector: ScaleGestureDetector): Boolean {
|
||||
val newScale = (scale * detector.scaleFactor).coerceIn(MIN_SCALE, MAX_SCALE)
|
||||
scaleChild(newScale, detector.focusX, detector.focusY)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onScaleBegin(detector: ScaleGestureDetector): Boolean = true
|
||||
|
||||
override fun onScaleEnd(p0: ScaleGestureDetector) {
|
||||
pendingScroll = 0
|
||||
}
|
||||
|
||||
|
||||
private inner class GestureListener : GestureDetector.SimpleOnGestureListener(), Runnable {
|
||||
override fun onScroll(e1: MotionEvent, e2: MotionEvent, distanceX: Float, distanceY: Float): Boolean {
|
||||
if (scale <= 1f) return false
|
||||
transformMatrix.postTranslate(-distanceX, -distanceY)
|
||||
invalidateTarget()
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onDoubleTap(e: MotionEvent): Boolean {
|
||||
val newScale = if (scale != 1f) 1f else MAX_SCALE * 0.8f
|
||||
ObjectAnimator.ofFloat(scale, newScale).run {
|
||||
interpolator = AccelerateDecelerateInterpolator()
|
||||
duration = 300
|
||||
addUpdateListener {
|
||||
scaleChild(it.animatedValue as Float, e.x, e.y)
|
||||
}
|
||||
start()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onFling(e1: MotionEvent, e2: MotionEvent, velocityX: Float, velocityY: Float): Boolean {
|
||||
if (scale <= 1) return false
|
||||
|
||||
overScroller.fling(
|
||||
transX.toInt(),
|
||||
transY.toInt(),
|
||||
velocityX.toInt(),
|
||||
velocityY.toInt(),
|
||||
translateBounds.left.toInt(),
|
||||
translateBounds.right.toInt(),
|
||||
translateBounds.top.toInt(),
|
||||
translateBounds.bottom.toInt(),
|
||||
)
|
||||
postOnAnimation(this)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun run() {
|
||||
if (overScroller.computeScrollOffset()) {
|
||||
transformMatrix.postTranslate(overScroller.currX - transX, overScroller.currY - transY)
|
||||
invalidateTarget()
|
||||
postOnAnimation(this)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package org.koitharu.kotatsu.reader.ui.thumbnails
|
||||
|
||||
import android.content.Context
|
||||
import androidx.core.net.toUri
|
||||
import coil.ImageLoader
|
||||
import coil.decode.DataSource
|
||||
import coil.decode.ImageSource
|
||||
import coil.fetch.FetchResult
|
||||
import coil.fetch.Fetcher
|
||||
import coil.fetch.SourceResult
|
||||
import coil.request.Options
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import okhttp3.OkHttpClient
|
||||
import okio.Path.Companion.toOkioPath
|
||||
import okio.buffer
|
||||
import okio.source
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.local.data.CbzFilter
|
||||
import org.koitharu.kotatsu.local.data.PagesCache
|
||||
import org.koitharu.kotatsu.local.data.util.withExtraCloseable
|
||||
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||
import org.koitharu.kotatsu.parsers.util.await
|
||||
import org.koitharu.kotatsu.parsers.util.mimeType
|
||||
import org.koitharu.kotatsu.reader.domain.PageLoader
|
||||
import java.util.zip.ZipFile
|
||||
|
||||
class MangaPageFetcher(
|
||||
private val context: Context,
|
||||
private val okHttpClient: OkHttpClient,
|
||||
private val pagesCache: PagesCache,
|
||||
private val options: Options,
|
||||
private val page: MangaPage,
|
||||
private val mangaRepositoryFactory: MangaRepository.Factory,
|
||||
) : Fetcher {
|
||||
|
||||
override suspend fun fetch(): FetchResult {
|
||||
val repo = mangaRepositoryFactory.create(page.source)
|
||||
val pageUrl = repo.getPageUrl(page)
|
||||
pagesCache.get(pageUrl)?.let { file ->
|
||||
return SourceResult(
|
||||
source = ImageSource(
|
||||
file = file.toOkioPath(),
|
||||
metadata = MangaPageMetadata(page),
|
||||
),
|
||||
mimeType = null,
|
||||
dataSource = DataSource.DISK,
|
||||
)
|
||||
}
|
||||
return loadPage(pageUrl)
|
||||
}
|
||||
|
||||
private suspend fun loadPage(pageUrl: String): SourceResult {
|
||||
val uri = pageUrl.toUri()
|
||||
return if (CbzFilter.isUriSupported(uri)) {
|
||||
val zip = runInterruptible(Dispatchers.IO) { ZipFile(uri.schemeSpecificPart) }
|
||||
val entry = runInterruptible(Dispatchers.IO) { zip.getEntry(uri.fragment) }
|
||||
return SourceResult(
|
||||
source = ImageSource(
|
||||
source = zip.getInputStream(entry).source().withExtraCloseable(zip).buffer(),
|
||||
context = context,
|
||||
metadata = MangaPageMetadata(page),
|
||||
),
|
||||
mimeType = null,
|
||||
dataSource = DataSource.DISK,
|
||||
)
|
||||
} else {
|
||||
val request = PageLoader.createPageRequest(page, pageUrl)
|
||||
okHttpClient.newCall(request).await().use { response ->
|
||||
check(response.isSuccessful) {
|
||||
"Invalid response: ${response.code} ${response.message} at $pageUrl"
|
||||
}
|
||||
val body = checkNotNull(response.body) {
|
||||
"Null response"
|
||||
}
|
||||
val mimeType = response.mimeType
|
||||
val file = body.use {
|
||||
pagesCache.put(pageUrl, it.source())
|
||||
}
|
||||
SourceResult(
|
||||
source = ImageSource(
|
||||
file = file.toOkioPath(),
|
||||
metadata = MangaPageMetadata(page),
|
||||
),
|
||||
mimeType = mimeType,
|
||||
dataSource = DataSource.NETWORK,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class Factory(
|
||||
private val context: Context,
|
||||
private val okHttpClient: OkHttpClient,
|
||||
private val pagesCache: PagesCache,
|
||||
private val mangaRepositoryFactory: MangaRepository.Factory,
|
||||
) : Fetcher.Factory<MangaPage> {
|
||||
|
||||
override fun create(data: MangaPage, options: Options, imageLoader: ImageLoader): Fetcher {
|
||||
return MangaPageFetcher(
|
||||
okHttpClient = okHttpClient,
|
||||
pagesCache = pagesCache,
|
||||
options = options,
|
||||
page = data,
|
||||
context = context,
|
||||
mangaRepositoryFactory = mangaRepositoryFactory,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
class MangaPageMetadata(val page: MangaPage) : ImageSource.Metadata()
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package org.koitharu.kotatsu.reader.ui.thumbnails
|
||||
|
||||
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
|
||||
|
||||
fun interface OnPageSelectListener {
|
||||
|
||||
fun onPageSelected(page: ReaderPage)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package org.koitharu.kotatsu.reader.ui.thumbnails
|
||||
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
|
||||
|
||||
class PageThumbnail(
|
||||
val isCurrent: Boolean,
|
||||
val repository: MangaRepository,
|
||||
val page: ReaderPage,
|
||||
) : ListModel {
|
||||
|
||||
val number
|
||||
get() = page.index + 1
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
other as PageThumbnail
|
||||
|
||||
if (isCurrent != other.isCurrent) return false
|
||||
if (repository != other.repository) return false
|
||||
return page == other.page
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = isCurrent.hashCode()
|
||||
result = 31 * result + repository.hashCode()
|
||||
result = 31 * result + page.hashCode()
|
||||
return result
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
package org.koitharu.kotatsu.reader.ui.thumbnails
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import coil.ImageLoader
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
|
||||
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.ui.BaseBottomSheet
|
||||
import org.koitharu.kotatsu.core.ui.list.BoundsScrollListener
|
||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||
import org.koitharu.kotatsu.core.ui.list.ScrollListenerInvalidationObserver
|
||||
import org.koitharu.kotatsu.core.ui.list.decor.SpacingItemDecoration
|
||||
import org.koitharu.kotatsu.core.ui.widgets.BottomSheetHeaderBar
|
||||
import org.koitharu.kotatsu.core.util.ext.scaleUpActivityOptionsOf
|
||||
import org.koitharu.kotatsu.core.util.ext.withArgs
|
||||
import org.koitharu.kotatsu.databinding.SheetPagesBinding
|
||||
import org.koitharu.kotatsu.list.ui.MangaListSpanResolver
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.reader.ui.ReaderActivity
|
||||
import org.koitharu.kotatsu.reader.ui.ReaderState
|
||||
import org.koitharu.kotatsu.reader.ui.thumbnails.adapter.PageThumbnailAdapter
|
||||
import org.koitharu.kotatsu.reader.ui.thumbnails.adapter.TargetScrollObserver
|
||||
import org.koitharu.kotatsu.util.LoggingAdapterDataObserver
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class PagesThumbnailsSheet :
|
||||
BaseBottomSheet<SheetPagesBinding>(),
|
||||
OnListItemClickListener<PageThumbnail>,
|
||||
BottomSheetHeaderBar.OnExpansionChangeListener {
|
||||
|
||||
private val viewModel by viewModels<PagesThumbnailsViewModel>()
|
||||
|
||||
@Inject
|
||||
lateinit var coil: ImageLoader
|
||||
|
||||
@Inject
|
||||
lateinit var settings: AppSettings
|
||||
|
||||
private var thumbnailsAdapter: PageThumbnailAdapter? = null
|
||||
private var spanResolver: MangaListSpanResolver? = null
|
||||
private var scrollListener: ScrollListener? = null
|
||||
|
||||
private val spanSizeLookup = SpanSizeLookup()
|
||||
private val listCommitCallback = Runnable {
|
||||
spanSizeLookup.invalidateCache()
|
||||
}
|
||||
|
||||
override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): SheetPagesBinding {
|
||||
return SheetPagesBinding.inflate(inflater, container, false)
|
||||
}
|
||||
|
||||
override fun onViewBindingCreated(binding: SheetPagesBinding, savedInstanceState: Bundle?) {
|
||||
super.onViewBindingCreated(binding, savedInstanceState)
|
||||
spanResolver = MangaListSpanResolver(binding.root.resources)
|
||||
with(binding.headerBar) {
|
||||
title = viewModel.title
|
||||
subtitle = null
|
||||
addOnExpansionChangeListener(this@PagesThumbnailsSheet)
|
||||
}
|
||||
thumbnailsAdapter = PageThumbnailAdapter(
|
||||
coil = coil,
|
||||
lifecycleOwner = viewLifecycleOwner,
|
||||
clickListener = this@PagesThumbnailsSheet,
|
||||
)
|
||||
with(binding.recyclerView) {
|
||||
addItemDecoration(
|
||||
SpacingItemDecoration(resources.getDimensionPixelOffset(R.dimen.grid_spacing)),
|
||||
)
|
||||
adapter = thumbnailsAdapter
|
||||
addOnLayoutChangeListener(spanResolver)
|
||||
spanResolver?.setGridSize(settings.gridSize / 100f, this)
|
||||
addOnScrollListener(ScrollListener().also { scrollListener = it })
|
||||
(layoutManager as GridLayoutManager).spanSizeLookup = spanSizeLookup
|
||||
thumbnailsAdapter?.registerAdapterDataObserver(
|
||||
ScrollListenerInvalidationObserver(this, checkNotNull(scrollListener)),
|
||||
)
|
||||
thumbnailsAdapter?.registerAdapterDataObserver(TargetScrollObserver(this))
|
||||
thumbnailsAdapter?.registerAdapterDataObserver(LoggingAdapterDataObserver("THUMB"))
|
||||
}
|
||||
viewModel.thumbnails.observe(viewLifecycleOwner) {
|
||||
thumbnailsAdapter?.setItems(it, listCommitCallback)
|
||||
}
|
||||
viewModel.branch.observe(viewLifecycleOwner) {
|
||||
onExpansionStateChanged(binding.headerBar, binding.headerBar.isExpanded)
|
||||
}
|
||||
viewModel.onError.observe(viewLifecycleOwner, SnackbarErrorObserver(binding.recyclerView, this))
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
spanResolver = null
|
||||
scrollListener = null
|
||||
thumbnailsAdapter = null
|
||||
spanSizeLookup.invalidateCache()
|
||||
super.onDestroyView()
|
||||
}
|
||||
|
||||
override fun onItemClick(item: PageThumbnail, view: View) {
|
||||
val listener = (parentFragment as? OnPageSelectListener) ?: (activity as? OnPageSelectListener)
|
||||
if (listener != null) {
|
||||
listener.onPageSelected(item.page)
|
||||
} else {
|
||||
val state = ReaderState(item.page.chapterId, item.page.index, 0)
|
||||
val intent = ReaderActivity.newIntent(view.context, viewModel.manga, state)
|
||||
startActivity(intent, scaleUpActivityOptionsOf(view).toBundle())
|
||||
}
|
||||
dismiss()
|
||||
}
|
||||
|
||||
override fun onExpansionStateChanged(headerBar: BottomSheetHeaderBar, isExpanded: Boolean) {
|
||||
if (isExpanded) {
|
||||
headerBar.subtitle = viewModel.branch.value
|
||||
} else {
|
||||
headerBar.subtitle = null
|
||||
}
|
||||
}
|
||||
|
||||
private inner class ScrollListener : BoundsScrollListener(3, 3) {
|
||||
|
||||
override fun onScrolledToStart(recyclerView: RecyclerView) {
|
||||
viewModel.loadPrevChapter()
|
||||
}
|
||||
|
||||
override fun onScrolledToEnd(recyclerView: RecyclerView) {
|
||||
viewModel.loadNextChapter()
|
||||
}
|
||||
}
|
||||
|
||||
private inner class SpanSizeLookup : GridLayoutManager.SpanSizeLookup() {
|
||||
|
||||
init {
|
||||
isSpanIndexCacheEnabled = true
|
||||
isSpanGroupIndexCacheEnabled = true
|
||||
}
|
||||
|
||||
override fun getSpanSize(position: Int): Int {
|
||||
val total = (viewBinding?.recyclerView?.layoutManager as? GridLayoutManager)?.spanCount ?: return 1
|
||||
return when (thumbnailsAdapter?.getItemViewType(position)) {
|
||||
PageThumbnailAdapter.ITEM_TYPE_THUMBNAIL -> 1
|
||||
else -> total
|
||||
}
|
||||
}
|
||||
|
||||
fun invalidateCache() {
|
||||
invalidateSpanGroupIndexCache()
|
||||
invalidateSpanIndexCache()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
const val ARG_MANGA = "manga"
|
||||
const val ARG_CURRENT_PAGE = "current"
|
||||
const val ARG_CHAPTER_ID = "chapter_id"
|
||||
|
||||
private const val TAG = "PagesThumbnailsSheet"
|
||||
|
||||
fun show(fm: FragmentManager, manga: Manga, chapterId: Long, currentPage: Int = -1) {
|
||||
PagesThumbnailsSheet().withArgs(3) {
|
||||
putParcelable(ARG_MANGA, ParcelableManga(manga, true))
|
||||
putLong(ARG_CHAPTER_ID, chapterId)
|
||||
putInt(ARG_CURRENT_PAGE, currentPage)
|
||||
}.show(fm, TAG)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package org.koitharu.kotatsu.reader.ui.thumbnails
|
||||
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.core.ui.BaseViewModel
|
||||
import org.koitharu.kotatsu.core.util.ext.emitValue
|
||||
import org.koitharu.kotatsu.list.ui.model.ListHeader
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
import org.koitharu.kotatsu.list.ui.model.LoadingFooter
|
||||
import org.koitharu.kotatsu.parsers.util.SuspendLazy
|
||||
import org.koitharu.kotatsu.reader.data.filterChapters
|
||||
import org.koitharu.kotatsu.reader.domain.ChaptersLoader
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class PagesThumbnailsViewModel @Inject constructor(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
mangaRepositoryFactory: MangaRepository.Factory,
|
||||
private val chaptersLoader: ChaptersLoader,
|
||||
) : BaseViewModel() {
|
||||
|
||||
private val currentPageIndex: Int = savedStateHandle[PagesThumbnailsSheet.ARG_CURRENT_PAGE] ?: -1
|
||||
private val initialChapterId: Long = savedStateHandle[PagesThumbnailsSheet.ARG_CHAPTER_ID] ?: 0L
|
||||
val manga = requireNotNull(savedStateHandle.get<ParcelableManga>(PagesThumbnailsSheet.ARG_MANGA)).manga
|
||||
|
||||
private val repository = mangaRepositoryFactory.create(manga.source)
|
||||
private val mangaDetails = SuspendLazy {
|
||||
repository.getDetails(manga).let {
|
||||
chaptersLoader.chapters.clear()
|
||||
val b = manga.chapters?.find { ch -> ch.id == initialChapterId }?.branch
|
||||
branch.emitValue(b)
|
||||
it.getChapters(b)?.forEach { ch ->
|
||||
chaptersLoader.chapters.put(ch.id, ch)
|
||||
}
|
||||
it.filterChapters(b)
|
||||
}
|
||||
}
|
||||
private var loadingJob: Job? = null
|
||||
private var loadingPrevJob: Job? = null
|
||||
private var loadingNextJob: Job? = null
|
||||
|
||||
val thumbnails = MutableLiveData<List<ListModel>>()
|
||||
val branch = MutableLiveData<String?>()
|
||||
val title = manga.title
|
||||
|
||||
init {
|
||||
loadingJob = launchJob(Dispatchers.Default) {
|
||||
chaptersLoader.loadSingleChapter(mangaDetails.get(), initialChapterId)
|
||||
updateList()
|
||||
}
|
||||
}
|
||||
|
||||
fun loadPrevChapter() {
|
||||
if (loadingJob?.isActive == true || loadingPrevJob?.isActive == true) {
|
||||
return
|
||||
}
|
||||
loadingPrevJob = loadPrevNextChapter(isNext = false)
|
||||
}
|
||||
|
||||
fun loadNextChapter() {
|
||||
if (loadingJob?.isActive == true || loadingNextJob?.isActive == true) {
|
||||
return
|
||||
}
|
||||
loadingNextJob = loadPrevNextChapter(isNext = true)
|
||||
}
|
||||
|
||||
private fun loadPrevNextChapter(isNext: Boolean): Job = launchLoadingJob(Dispatchers.Default) {
|
||||
val currentId = (if (isNext) chaptersLoader.last() else chaptersLoader.first()).chapterId
|
||||
chaptersLoader.loadPrevNextChapter(mangaDetails.get(), currentId, isNext)
|
||||
updateList()
|
||||
}
|
||||
|
||||
private suspend fun updateList() {
|
||||
val snapshot = chaptersLoader.snapshot()
|
||||
val mangaChapters = mangaDetails.tryGet().getOrNull()?.chapters.orEmpty()
|
||||
val hasPrevChapter = snapshot.firstOrNull()?.chapterId != mangaChapters.firstOrNull()?.id
|
||||
val hasNextChapter = snapshot.lastOrNull()?.chapterId != mangaChapters.lastOrNull()?.id
|
||||
val pages = buildList(snapshot.size + chaptersLoader.chapters.size() + 2) {
|
||||
if (hasPrevChapter) {
|
||||
add(LoadingFooter(-1))
|
||||
}
|
||||
var previousChapterId = 0L
|
||||
for (page in snapshot) {
|
||||
if (page.chapterId != previousChapterId) {
|
||||
chaptersLoader.chapters[page.chapterId]?.let {
|
||||
add(ListHeader(it.name, 0, null))
|
||||
}
|
||||
previousChapterId = page.chapterId
|
||||
}
|
||||
this += PageThumbnail(
|
||||
isCurrent = page.chapterId == initialChapterId && page.index == currentPageIndex,
|
||||
repository = repository,
|
||||
page = page,
|
||||
)
|
||||
}
|
||||
if (hasNextChapter) {
|
||||
add(LoadingFooter(1))
|
||||
}
|
||||
}
|
||||
thumbnails.emitValue(pages)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package org.koitharu.kotatsu.reader.ui.thumbnails.adapter
|
||||
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import coil.ImageLoader
|
||||
import coil.size.Scale
|
||||
import coil.size.Size
|
||||
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter
|
||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||
import org.koitharu.kotatsu.core.util.ext.decodeRegion
|
||||
import org.koitharu.kotatsu.core.util.ext.disposeImageRequest
|
||||
import org.koitharu.kotatsu.core.util.ext.enqueueWith
|
||||
import org.koitharu.kotatsu.core.util.ext.newImageRequest
|
||||
import org.koitharu.kotatsu.core.util.ext.setTextColorAttr
|
||||
import org.koitharu.kotatsu.core.util.ext.source
|
||||
import org.koitharu.kotatsu.databinding.ItemPageThumbBinding
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
import org.koitharu.kotatsu.reader.ui.thumbnails.PageThumbnail
|
||||
import com.google.android.material.R as materialR
|
||||
|
||||
fun pageThumbnailAD(
|
||||
coil: ImageLoader,
|
||||
lifecycleOwner: LifecycleOwner,
|
||||
clickListener: OnListItemClickListener<PageThumbnail>,
|
||||
) = adapterDelegateViewBinding<PageThumbnail, ListModel, ItemPageThumbBinding>(
|
||||
{ inflater, parent -> ItemPageThumbBinding.inflate(inflater, parent, false) },
|
||||
) {
|
||||
|
||||
val gridWidth = itemView.context.resources.getDimensionPixelSize(R.dimen.preferred_grid_width)
|
||||
val thumbSize = Size(
|
||||
width = gridWidth,
|
||||
height = (gridWidth / 13f * 18f).toInt(),
|
||||
)
|
||||
|
||||
val clickListenerAdapter = AdapterDelegateClickListenerAdapter(this, clickListener)
|
||||
binding.root.setOnClickListener(clickListenerAdapter)
|
||||
binding.root.setOnLongClickListener(clickListenerAdapter)
|
||||
|
||||
bind {
|
||||
val data: Any = item.page.preview?.takeUnless { it.isEmpty() } ?: item.page.toMangaPage()
|
||||
binding.imageViewThumb.newImageRequest(lifecycleOwner, data)?.run {
|
||||
placeholder(R.drawable.ic_placeholder)
|
||||
fallback(R.drawable.ic_placeholder)
|
||||
error(R.drawable.ic_error_placeholder)
|
||||
size(thumbSize)
|
||||
scale(Scale.FILL)
|
||||
allowRgb565(true)
|
||||
decodeRegion(0)
|
||||
source(item.page.source)
|
||||
enqueueWith(coil)
|
||||
}
|
||||
with(binding.textViewNumber) {
|
||||
setBackgroundResource(if (item.isCurrent) R.drawable.bg_badge_accent else R.drawable.bg_badge_empty)
|
||||
setTextColorAttr(if (item.isCurrent) materialR.attr.colorOnTertiary else android.R.attr.textColorPrimary)
|
||||
text = (item.number).toString()
|
||||
}
|
||||
}
|
||||
|
||||
onViewRecycled {
|
||||
binding.imageViewThumb.disposeImageRequest()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package org.koitharu.kotatsu.reader.ui.thumbnails.adapter
|
||||
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import coil.ImageLoader
|
||||
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
|
||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||
import org.koitharu.kotatsu.list.ui.adapter.listHeaderAD
|
||||
import org.koitharu.kotatsu.list.ui.adapter.loadingFooterAD
|
||||
import org.koitharu.kotatsu.list.ui.model.ListHeader
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
import org.koitharu.kotatsu.list.ui.model.LoadingFooter
|
||||
import org.koitharu.kotatsu.reader.ui.thumbnails.PageThumbnail
|
||||
|
||||
class PageThumbnailAdapter(
|
||||
coil: ImageLoader,
|
||||
lifecycleOwner: LifecycleOwner,
|
||||
clickListener: OnListItemClickListener<PageThumbnail>,
|
||||
) : AsyncListDifferDelegationAdapter<ListModel>(DiffCallback()) {
|
||||
|
||||
init {
|
||||
delegatesManager.addDelegate(ITEM_TYPE_THUMBNAIL, pageThumbnailAD(coil, lifecycleOwner, clickListener))
|
||||
.addDelegate(ITEM_TYPE_HEADER, listHeaderAD(null))
|
||||
.addDelegate(ITEM_LOADING, loadingFooterAD())
|
||||
}
|
||||
|
||||
private class DiffCallback : DiffUtil.ItemCallback<ListModel>() {
|
||||
|
||||
override fun areItemsTheSame(oldItem: ListModel, newItem: ListModel): Boolean {
|
||||
return when {
|
||||
oldItem is PageThumbnail && newItem is PageThumbnail -> {
|
||||
oldItem.page == newItem.page
|
||||
}
|
||||
|
||||
oldItem is ListHeader && newItem is ListHeader -> {
|
||||
oldItem.textRes == newItem.textRes &&
|
||||
oldItem.text == newItem.text &&
|
||||
oldItem.dateTimeAgo == newItem.dateTimeAgo
|
||||
}
|
||||
|
||||
oldItem is LoadingFooter && newItem is LoadingFooter -> {
|
||||
oldItem.key == newItem.key
|
||||
}
|
||||
|
||||
else -> oldItem.javaClass == newItem.javaClass
|
||||
}
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(oldItem: ListModel, newItem: ListModel): Boolean {
|
||||
return oldItem == newItem
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
const val ITEM_TYPE_THUMBNAIL = 0
|
||||
const val ITEM_TYPE_HEADER = 1
|
||||
const val ITEM_LOADING = 2
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package org.koitharu.kotatsu.reader.ui.thumbnails.adapter
|
||||
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
|
||||
import org.koitharu.kotatsu.reader.ui.thumbnails.PageThumbnail
|
||||
|
||||
class TargetScrollObserver(
|
||||
private val recyclerView: RecyclerView,
|
||||
) : RecyclerView.AdapterDataObserver() {
|
||||
|
||||
private var isScrollToCurrentPending = true
|
||||
|
||||
private val layoutManager: LinearLayoutManager
|
||||
get() = recyclerView.layoutManager as LinearLayoutManager
|
||||
|
||||
override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {
|
||||
if (isScrollToCurrentPending) {
|
||||
postScroll()
|
||||
}
|
||||
}
|
||||
|
||||
private fun postScroll() {
|
||||
recyclerView.post {
|
||||
scrollToTarget()
|
||||
}
|
||||
}
|
||||
|
||||
private fun scrollToTarget() {
|
||||
val adapter = recyclerView.adapter ?: return
|
||||
if (recyclerView.computeVerticalScrollRange() == 0) {
|
||||
return
|
||||
}
|
||||
val snapshot = (adapter as? AsyncListDifferDelegationAdapter<*>)?.items ?: return
|
||||
val target = snapshot.indexOfFirst { it is PageThumbnail && it.isCurrent }
|
||||
if (target in snapshot.indices) {
|
||||
layoutManager.scrollToPositionWithOffset(target, 0)
|
||||
isScrollToCurrentPending = false
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user