From 77e393ae48280f34dcc54627e0b3ec245a39328d Mon Sep 17 00:00:00 2001 From: Koitharu Date: Sat, 29 Jun 2024 15:56:32 +0300 Subject: [PATCH] Pages crop proof-of-concept --- app/build.gradle | 12 +-- .../kotatsu/core/prefs/AppSettings.kt | 15 +++- .../reader/domain/DetectReaderModeUseCase.kt | 1 + .../kotatsu/reader/domain/PageLoader.kt | 10 +++ .../reader/domain/WhitespaceDetector.kt | 79 +++++++++++++++++++ .../reader/ui/config/ReaderSettings.kt | 5 ++ .../kotatsu/reader/ui/pager/BasePageHolder.kt | 12 ++- .../reader/ui/pager/PageHolderDelegate.kt | 22 +++++- .../reader/ui/pager/standard/PageHolder.kt | 15 ++-- .../reader/ui/pager/webtoon/WebtoonHolder.kt | 15 ++-- .../settings/ReaderSettingsFragment.kt | 5 ++ app/src/main/res/values/arrays.xml | 4 + app/src/main/res/values/constants.xml | 4 + app/src/main/res/xml/pref_reader.xml | 6 ++ 14 files changed, 182 insertions(+), 23 deletions(-) create mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/reader/domain/WhitespaceDetector.kt diff --git a/app/build.gradle b/app/build.gradle index faa3a2bf8..a2af9bac0 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -93,7 +93,7 @@ dependencies { implementation 'androidx.appcompat:appcompat:1.7.0' implementation 'androidx.core:core-ktx:1.13.1' implementation 'androidx.activity:activity-ktx:1.9.0' - implementation 'androidx.fragment:fragment-ktx:1.8.0' + implementation 'androidx.fragment:fragment-ktx:1.8.1' implementation 'androidx.transition:transition-ktx:1.5.0' implementation 'androidx.collection:collection-ktx:1.4.0' implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.2' @@ -136,7 +136,7 @@ dependencies { implementation 'io.coil-kt:coil-base:2.6.0' implementation 'io.coil-kt:coil-svg:2.6.0' - implementation 'com.github.KotatsuApp:subsampling-scale-image-view:8cafac256e' + implementation 'com.github.KotatsuApp:subsampling-scale-image-view:882bc0620c' implementation 'com.github.solkin:disk-lru-cache:1.4' implementation 'io.noties.markwon:core:4.6.2' @@ -154,10 +154,10 @@ dependencies { testImplementation 'org.json:json:20240303' testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1' - androidTestImplementation 'androidx.test:runner:1.5.2' - androidTestImplementation 'androidx.test:rules:1.5.0' - androidTestImplementation 'androidx.test:core-ktx:1.5.0' - androidTestImplementation 'androidx.test.ext:junit-ktx:1.1.5' + androidTestImplementation 'androidx.test:runner:1.6.1' + androidTestImplementation 'androidx.test:rules:1.6.1' + androidTestImplementation 'androidx.test:core-ktx:1.6.1' + androidTestImplementation 'androidx.test.ext:junit-ktx:1.2.1' androidTestImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1' diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/AppSettings.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/AppSettings.kt index 4f652cfe6..fdbd623d8 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/AppSettings.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/AppSettings.kt @@ -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 } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/domain/DetectReaderModeUseCase.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/domain/DetectReaderModeUseCase.kt index cb7ec0917..1be842980 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/domain/DetectReaderModeUseCase.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/domain/DetectReaderModeUseCase.kt @@ -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) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/domain/PageLoader.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/domain/PageLoader.kt index ec6e41640..c0ffb3d70 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/domain/PageLoader.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/domain/PageLoader.kt @@ -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() 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) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/domain/WhitespaceDetector.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/domain/WhitespaceDetector.kt new file mode 100644 index 000000000..06e32e7c5 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/domain/WhitespaceDetector.kt @@ -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 Bitmap.use(block: (Bitmap) -> R) = try { + block(this) + } finally { + recycle() + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/config/ReaderSettings.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/config/ReaderSettings.kt index 60708cfcc..f93822540 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/config/ReaderSettings.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/config/ReaderSettings.kt @@ -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 diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/BasePageHolder.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/BasePageHolder.kt index 001cfdf0d..a85d98616 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/BasePageHolder.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/BasePageHolder.kt @@ -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( protected val binding: B, @@ -24,7 +25,14 @@ abstract class BasePageHolder( ) : 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( delegate.onRecycle() } - protected fun SubsamplingScaleImageView.applyDownsampling(isForeground: Boolean) { + protected fun SubsamplingScaleImageView.applyDownSampling(isForeground: Boolean) { downSampling = when { isForeground || !settings.isReaderOptimizationEnabled -> 1 context.isLowRamDevice() -> 8 diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/PageHolderDelegate.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/PageHolderDelegate.kt index 0b58a3c31..3cfcea351 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/PageHolderDelegate.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/PageHolderDelegate.kt @@ -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 { 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) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/standard/PageHolder.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/standard/PageHolder.kt index c4b9d6b80..e724884b4 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/standard/PageHolder.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/standard/PageHolder.kt @@ -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) { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonHolder.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonHolder.kt index 48111be7b..745a57971 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonHolder.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonHolder.kt @@ -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) { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/ReaderSettingsFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/ReaderSettingsFragment.kt index be4e8be6c..d46843db9 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/ReaderSettingsFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/ReaderSettingsFragment.kt @@ -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(AppSettings.KEY_READER_CROP)?.run { + summaryProvider = MultiSummaryProvider(R.string.disabled) + } findPreference(AppSettings.KEY_WEBTOON_ZOOM_OUT)?.summaryProvider = PercentSummaryProvider() updateReaderModeDependency() } diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml index 06552452b..2107a6fe3 100644 --- a/app/src/main/res/values/arrays.xml +++ b/app/src/main/res/values/arrays.xml @@ -97,4 +97,8 @@ @string/system_default @string/more_frequently + + @string/pages + @string/webtoon + diff --git a/app/src/main/res/values/constants.xml b/app/src/main/res/values/constants.xml index 8c22f632a..5fbd6afaa 100644 --- a/app/src/main/res/values/constants.xml +++ b/app/src/main/res/values/constants.xml @@ -68,4 +68,8 @@ 1 2 + + 1 + 2 + diff --git a/app/src/main/res/xml/pref_reader.xml b/app/src/main/res/xml/pref_reader.xml index e13444949..a991989ab 100644 --- a/app/src/main/res/xml/pref_reader.xml +++ b/app/src/main/res/xml/pref_reader.xml @@ -88,6 +88,12 @@ android:summary="@string/reader_optimize_summary" android:title="@string/reader_optimize" /> + +