Pages crop proof-of-concept
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user