Pages crop proof-of-concept

This commit is contained in:
Koitharu
2024-06-29 15:56:32 +03:00
parent 77bb5c2fcd
commit 77e393ae48
14 changed files with 182 additions and 23 deletions

View File

@@ -33,7 +33,6 @@ import org.koitharu.kotatsu.reader.domain.ReaderColorFilter
import java.io.File
import java.net.Proxy
import java.util.EnumSet
import java.util.Locale
import javax.inject.Inject
import javax.inject.Singleton
@@ -485,6 +484,15 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
val isAutoLocalChaptersCleanupEnabled: Boolean
get() = prefs.getBoolean(KEY_CHAPTERS_CLEAR_AUTO, false)
fun isPagesCropEnabled(mode: ReaderMode): Boolean {
val rawValue = prefs.getStringSet(KEY_READER_CROP, emptySet())
if (rawValue.isNullOrEmpty()) {
return false
}
val needle = if (mode == ReaderMode.WEBTOON) READER_CROP_WEBTOON else READER_CROP_PAGED
return needle.toString() in rawValue
}
fun isTipEnabled(tip: String): Boolean {
return prefs.getStringSet(KEY_TIPS_CLOSED, emptySet())?.contains(tip) != true
}
@@ -597,6 +605,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_READER_ANIMATION = "reader_animation2"
const val KEY_READER_MODE = "reader_mode"
const val KEY_READER_MODE_DETECT = "reader_mode_detect"
const val KEY_READER_CROP = "reader_crop"
const val KEY_APP_PASSWORD = "app_password"
const val KEY_APP_PASSWORD_NUMERIC = "app_password_num"
const val KEY_PROTECT_APP = "protect_app"
@@ -698,5 +707,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
// old keys are for migration only
private const val KEY_IMAGES_PROXY_OLD = "images_proxy"
// values
private const val READER_CROP_PAGED = 1
private const val READER_CROP_WEBTOON = 2
}
}

View File

@@ -61,6 +61,7 @@ class DetectReaderModeUseCase @Inject constructor(
val page = requireNotNull(pages.getOrNull(pageIndex)) { "No pages" }
val url = repository.getPageUrl(page)
val uri = Uri.parse(url)
// TODO file support
val size = if (uri.scheme == "cbz") {
runInterruptible(Dispatchers.IO) {
val zip = ZipFile(uri.schemeSpecificPart)

View File

@@ -2,12 +2,14 @@ package org.koitharu.kotatsu.reader.domain
import android.content.Context
import android.graphics.BitmapFactory
import android.graphics.Rect
import android.net.Uri
import androidx.annotation.AnyThread
import androidx.collection.LongSparseArray
import androidx.collection.set
import androidx.core.net.toFile
import androidx.core.net.toUri
import com.davemorrissey.labs.subscaleview.ImageSource
import dagger.hilt.android.ActivityRetainedLifecycle
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.scopes.ActivityRetainedScoped
@@ -51,6 +53,7 @@ import org.koitharu.kotatsu.local.data.isFileUri
import org.koitharu.kotatsu.local.data.isZipUri
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
import java.util.LinkedList
import java.util.concurrent.atomic.AtomicInteger
@@ -83,6 +86,7 @@ class PageLoader @Inject constructor(
private val prefetchQueue = LinkedList<MangaPage>()
private val counter = AtomicInteger(0)
private var prefetchQueueLimit = PREFETCH_LIMIT_DEFAULT // TODO adaptive
private val whitespaceDetector = WhitespaceDetector(context)
fun isPrefetchApplicable(): Boolean {
return repository is RemoteMangaRepository
@@ -154,6 +158,12 @@ class PageLoader @Inject constructor(
}
}
suspend fun getTrimmedBounds(uri: Uri): Rect? = runCatchingCancellable {
whitespaceDetector.getBounds(ImageSource.Uri(uri))
}.onFailure { error ->
error.printStackTraceDebug()
}.getOrNull()
suspend fun getPageUrl(page: MangaPage): String {
return getRepository(page.source).getPageUrl(page)
}

View File

@@ -0,0 +1,79 @@
package org.koitharu.kotatsu.reader.domain
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Point
import android.graphics.Rect
import androidx.core.graphics.get
import com.davemorrissey.labs.subscaleview.ImageSource
import com.davemorrissey.labs.subscaleview.decoder.ImageRegionDecoder
import com.davemorrissey.labs.subscaleview.decoder.SkiaImageRegionDecoder
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlin.math.abs
class WhitespaceDetector(
private val context: Context
) {
private val mutex = Mutex()
suspend fun getBounds(imageSource: ImageSource): Rect? = mutex.withLock {
runInterruptible(Dispatchers.IO) {
val decoder = SkiaImageRegionDecoder(Bitmap.Config.RGB_565)
try {
val size = decoder.init(context, imageSource)
detectWhitespaces(decoder, size)
} finally {
decoder.recycle()
}
}
}
// TODO
private fun detectWhitespaces(decoder: ImageRegionDecoder, size: Point): Rect? {
val result = Rect(0, 0, size.x, size.y)
val window = Rect()
val windowSize = 200
var baseColor = -1
window.set(0, 0, windowSize, windowSize)
decoder.decodeRegion(window, 1).use { bitmap ->
baseColor = bitmap[0, 0]
outerTop@ for (x in 1 until bitmap.width / 2) {
for (y in 1 until bitmap.height / 2) {
if (isSameColor(baseColor, bitmap[x, y])) {
result.left = x
result.top = y
} else {
break@outerTop
}
}
}
}
window.set(size.x - windowSize - 1, size.y - windowSize - 1, size.x - 1, size.y - 1)
decoder.decodeRegion(window, 1).use { bitmap ->
outerBottom@ for (x in (bitmap.width / 2 until bitmap.width).reversed()) {
for (y in (bitmap.height / 2 until bitmap.height).reversed()) {
if (isSameColor(baseColor, bitmap[x, y])) {
result.right = size.x - x
result.bottom = size.y - y
} else {
break@outerBottom
}
}
}
}
return result.takeUnless { it.isEmpty || (it.width() == size.x && it.height() == size.y) }
}
private fun isSameColor(a: Int, b: Int) = abs(a - b) <= 4 // TODO
private inline fun <R> Bitmap.use(block: (Bitmap) -> R) = try {
block(this)
} finally {
recycle()
}
}

View File

@@ -18,6 +18,7 @@ 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.core.prefs.ReaderMode
import org.koitharu.kotatsu.core.util.ext.isLowRamDevice
import org.koitharu.kotatsu.reader.domain.ReaderColorFilter
@@ -54,6 +55,10 @@ class ReaderSettings(
view.background = bg.resolve(view.context)
}
fun isPagesCropEnabled(isWebtoon: Boolean) = settings.isPagesCropEnabled(
if (isWebtoon) ReaderMode.WEBTOON else ReaderMode.STANDARD,
)
@CheckResult
fun applyBitmapConfig(ssiv: SubsamplingScaleImageView): Boolean {
val config = bitmapConfig

View File

@@ -13,6 +13,7 @@ import org.koitharu.kotatsu.databinding.LayoutPageInfoBinding
import org.koitharu.kotatsu.reader.domain.PageLoader
import org.koitharu.kotatsu.reader.ui.config.ReaderSettings
import org.koitharu.kotatsu.reader.ui.pager.PageHolderDelegate.State
import org.koitharu.kotatsu.reader.ui.pager.webtoon.WebtoonHolder
abstract class BasePageHolder<B : ViewBinding>(
protected val binding: B,
@@ -24,7 +25,14 @@ abstract class BasePageHolder<B : ViewBinding>(
) : LifecycleAwareViewHolder(binding.root, lifecycleOwner), PageHolderDelegate.Callback {
@Suppress("LeakingThis")
protected val delegate = PageHolderDelegate(loader, settings, this, networkState, exceptionResolver)
protected val delegate = PageHolderDelegate(
loader = loader,
readerSettings = settings,
callback = this,
networkState = networkState,
exceptionResolver = exceptionResolver,
isWebtoon = this is WebtoonHolder,
)
protected val bindingInfo = LayoutPageInfoBinding.bind(binding.root)
val context: Context
@@ -70,7 +78,7 @@ abstract class BasePageHolder<B : ViewBinding>(
delegate.onRecycle()
}
protected fun SubsamplingScaleImageView.applyDownsampling(isForeground: Boolean) {
protected fun SubsamplingScaleImageView.applyDownSampling(isForeground: Boolean) {
downSampling = when {
isForeground || !settings.isReaderOptimizationEnabled -> 1
context.isLowRamDevice() -> 8

View File

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.reader.ui.pager
import android.graphics.Rect
import android.net.Uri
import androidx.lifecycle.Observer
import com.davemorrissey.labs.subscaleview.DefaultOnImageEventListener
@@ -32,6 +33,7 @@ class PageHolderDelegate(
private val callback: Callback,
private val networkState: NetworkState,
private val exceptionResolver: ExceptionResolver,
private val isWebtoon: Boolean,
) : DefaultOnImageEventListener, Observer<ReaderSettings> {
private val scope = loader.loaderScope + Dispatchers.Main.immediate
@@ -39,6 +41,7 @@ class PageHolderDelegate(
private set
private var job: Job? = null
private var uri: Uri? = null
private var cachedBounds: Rect? = null
private var error: Throwable? = null
init {
@@ -88,6 +91,7 @@ class PageHolderDelegate(
fun onRecycle() {
state = State.EMPTY
uri = null
cachedBounds = null
error = null
job?.cancel()
}
@@ -95,7 +99,7 @@ class PageHolderDelegate(
fun reload() {
if (state == State.SHOWN) {
uri?.let {
callback.onImageReady(it)
callback.onImageReady(it, cachedBounds)
}
}
}
@@ -138,8 +142,13 @@ class PageHolderDelegate(
state = State.CONVERTING
try {
val newUri = loader.convertBimap(uri)
cachedBounds = if (readerSettings.isPagesCropEnabled(isWebtoon)) {
loader.getTrimmedBounds(newUri)
} else {
null
}
state = State.CONVERTED
callback.onImageReady(newUri)
callback.onImageReady(newUri, cachedBounds)
} catch (ce: CancellationException) {
throw ce
} catch (e2: Throwable) {
@@ -166,7 +175,12 @@ class PageHolderDelegate(
file
}
state = State.LOADED
callback.onImageReady(checkNotNull(uri))
cachedBounds = if (readerSettings.isPagesCropEnabled(isWebtoon)) {
loader.getTrimmedBounds(checkNotNull(uri))
} else {
null
}
callback.onImageReady(checkNotNull(uri), cachedBounds)
} catch (e: CancellationException) {
throw e
} catch (e: Throwable) {
@@ -196,7 +210,7 @@ class PageHolderDelegate(
fun onError(e: Throwable)
fun onImageReady(uri: Uri)
fun onImageReady(uri: Uri, bounds: Rect?)
fun onImageShowing(settings: ReaderSettings)

View File

@@ -2,6 +2,7 @@ package org.koitharu.kotatsu.reader.ui.pager.standard
import android.annotation.SuppressLint
import android.graphics.PointF
import android.graphics.Rect
import android.net.Uri
import android.view.View
import android.view.animation.DecelerateInterpolator
@@ -46,12 +47,12 @@ open class PageHolder(
override fun onResume() {
super.onResume()
binding.ssiv.applyDownsampling(isForeground = true)
binding.ssiv.applyDownSampling(isForeground = true)
}
override fun onPause() {
super.onPause()
binding.ssiv.applyDownsampling(isForeground = false)
binding.ssiv.applyDownSampling(isForeground = false)
}
override fun onConfigChanged() {
@@ -59,7 +60,7 @@ open class PageHolder(
if (settings.applyBitmapConfig(binding.ssiv)) {
delegate.reload()
}
binding.ssiv.applyDownsampling(isResumed())
binding.ssiv.applyDownSampling(isResumed())
binding.textViewNumber.isVisible = settings.isPagesNumbersEnabled
}
@@ -89,8 +90,12 @@ open class PageHolder(
}
}
override fun onImageReady(uri: Uri) {
binding.ssiv.setImage(ImageSource.Uri(uri))
override fun onImageReady(uri: Uri, bounds: Rect?) {
val source = ImageSource.Uri(uri)
if (bounds != null) {
source.region(bounds)
}
binding.ssiv.setImage(source)
}
override fun onImageShowing(settings: ReaderSettings) {

View File

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.reader.ui.pager.webtoon
import android.graphics.Rect
import android.net.Uri
import android.view.View
import androidx.core.view.isVisible
@@ -39,12 +40,12 @@ class WebtoonHolder(
override fun onResume() {
super.onResume()
binding.ssiv.applyDownsampling(isForeground = true)
binding.ssiv.applyDownSampling(isForeground = true)
}
override fun onPause() {
super.onPause()
binding.ssiv.applyDownsampling(isForeground = false)
binding.ssiv.applyDownSampling(isForeground = false)
}
override fun onConfigChanged() {
@@ -52,7 +53,7 @@ class WebtoonHolder(
if (settings.applyBitmapConfig(binding.ssiv)) {
delegate.reload()
}
binding.ssiv.applyDownsampling(isResumed())
binding.ssiv.applyDownSampling(isResumed())
}
override fun onBind(data: ReaderPage) {
@@ -89,8 +90,12 @@ class WebtoonHolder(
}
}
override fun onImageReady(uri: Uri) {
binding.ssiv.setImage(ImageSource.Uri(uri))
override fun onImageReady(uri: Uri, bounds: Rect?) {
val source = ImageSource.Uri(uri)
if (bounds != null) {
source.region(bounds)
}
binding.ssiv.setImage(source)
}
override fun onImageShowing(settings: ReaderSettings) {

View File

@@ -5,6 +5,7 @@ import android.content.SharedPreferences
import android.os.Bundle
import android.view.View
import androidx.preference.ListPreference
import androidx.preference.MultiSelectListPreference
import androidx.preference.Preference
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
@@ -17,6 +18,7 @@ import org.koitharu.kotatsu.core.ui.BasePreferenceFragment
import org.koitharu.kotatsu.core.util.ext.setDefaultValueCompat
import org.koitharu.kotatsu.parsers.util.names
import org.koitharu.kotatsu.settings.reader.ReaderTapGridConfigActivity
import org.koitharu.kotatsu.settings.utils.MultiSummaryProvider
import org.koitharu.kotatsu.settings.utils.PercentSummaryProvider
import org.koitharu.kotatsu.settings.utils.SliderPreference
@@ -48,6 +50,9 @@ class ReaderSettingsFragment :
entryValues = ZoomMode.entries.names()
setDefaultValueCompat(ZoomMode.FIT_CENTER.name)
}
findPreference<MultiSelectListPreference>(AppSettings.KEY_READER_CROP)?.run {
summaryProvider = MultiSummaryProvider(R.string.disabled)
}
findPreference<SliderPreference>(AppSettings.KEY_WEBTOON_ZOOM_OUT)?.summaryProvider = PercentSummaryProvider()
updateReaderModeDependency()
}

View File

@@ -97,4 +97,8 @@
<item>@string/system_default</item>
<item>@string/more_frequently</item>
</string-array>
<string-array name="reader_crop" translatable="false">
<item>@string/pages</item>
<item>@string/webtoon</item>
</string-array>
</resources>

View File

@@ -68,4 +68,8 @@
<item>1</item>
<item>2</item>
</string-array>
<string-array name="values_reader_crop" translatable="false">
<item>1</item>
<item>2</item>
</string-array>
</resources>

View File

@@ -88,6 +88,12 @@
android:summary="@string/reader_optimize_summary"
android:title="@string/reader_optimize" />
<MultiSelectListPreference
android:entries="@array/reader_crop"
android:entryValues="@array/values_reader_crop"
android:key="reader_crop"
android:title="Crop pages (beta)" />
<SwitchPreferenceCompat
android:defaultValue="true"
android:key="reader_fullscreen"