Move sources from java to kotlin dir

This commit is contained in:
Koitharu
2023-05-22 18:16:50 +03:00
parent a8f5714b35
commit c3216871ed
711 changed files with 1 additions and 0 deletions

View File

@@ -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,
)

View File

@@ -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)
}
}

View File

@@ -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)
}
}
}

View File

@@ -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()
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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)
}
}
}

View File

@@ -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?
)

View File

@@ -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
}
}

View File

@@ -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)
}

View File

@@ -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)
}
}
}

View File

@@ -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))
}
}

View File

@@ -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,
)
}

View File

@@ -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)
}
}

View File

@@ -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()
}
}
}

View File

@@ -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
}
}

View File

@@ -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)))
}
}

View File

@@ -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()
}
}

View File

@@ -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)
}
}
}

View File

@@ -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())
}
}

View File

@@ -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)
}
}

View File

@@ -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()
}
}
}
}

View File

@@ -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()
}
}

View File

@@ -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
}
}
}

View File

@@ -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?)
}

View File

@@ -0,0 +1,8 @@
package org.koitharu.kotatsu.reader.ui.pager
interface OnBoundsScrollListener {
fun onScrolledToStart()
fun onScrolledToEnd()
}

View File

@@ -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)
}
}

View File

@@ -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,
)
}

View File

@@ -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
}
}

View File

@@ -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
}
}
}
}

View File

@@ -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),
)
}
}
}
}
}

View File

@@ -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,
)
}

View File

@@ -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)
}
}

View File

@@ -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
}
}
}
}

View File

@@ -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()
}
}

View File

@@ -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()
}
}
}

View File

@@ -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
}
}

View File

@@ -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,
)
}

View File

@@ -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()
}
}
}

View File

@@ -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,
)
}

View File

@@ -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
}
}

View File

@@ -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
}
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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)
}
}
}

View File

@@ -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
}
}

View File

@@ -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)
}
}
}
}

View File

@@ -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()
}

View File

@@ -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)
}

View File

@@ -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
}
}

View File

@@ -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)
}
}
}

View File

@@ -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)
}
}

View File

@@ -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()
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}
}