From 88b56121a34e613f103bbbfb77c8e66e86f825f6 Mon Sep 17 00:00:00 2001 From: AntsyLich <59261191+AntsyLich@users.noreply.github.com> Date: Sat, 27 Aug 2022 21:41:18 +0600 Subject: [PATCH] Long Strip Split for Webtoon (#5759) * Long Strip Split for Webtoon * Review Changes * Review Changes 2 + Rebase --- .../data/preference/PreferencesHelper.kt | 6 +- .../tachiyomi/ui/reader/model/StencilPage.kt | 15 +++ .../setting/ReaderReadingModeSettings.kt | 5 +- .../reader/viewer/webtoon/WebtoonAdapter.kt | 26 ++++ .../ui/reader/viewer/webtoon/WebtoonConfig.kt | 14 ++ .../viewer/webtoon/WebtoonPageHolder.kt | 36 ++++- .../ui/reader/viewer/webtoon/WebtoonViewer.kt | 18 +++ .../ui/setting/SettingsReaderController.kt | 5 + .../kanade/tachiyomi/util/system/ImageUtil.kt | 126 +++++++++++++----- .../res/layout/reader_webtoon_settings.xml | 9 ++ app/src/main/res/values/strings.xml | 2 + 11 files changed, 218 insertions(+), 44 deletions(-) create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/reader/model/StencilPage.kt diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt b/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt index ee5aaf8114..8f7c68f455 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt @@ -83,12 +83,14 @@ class PreferencesHelper(val context: Context) { fun dualPageSplitPaged() = flowPrefs.getBoolean("pref_dual_page_split", false) - fun dualPageSplitWebtoon() = flowPrefs.getBoolean("pref_dual_page_split_webtoon", false) - fun dualPageInvertPaged() = flowPrefs.getBoolean("pref_dual_page_invert", false) + fun dualPageSplitWebtoon() = flowPrefs.getBoolean("pref_dual_page_split_webtoon", false) + fun dualPageInvertWebtoon() = flowPrefs.getBoolean("pref_dual_page_invert_webtoon", false) + fun longStripSplitWebtoon() = flowPrefs.getBoolean("pref_long_strip_split_webtoon", true) + fun showReadingMode() = prefs.getBoolean(Keys.showReadingMode, true) fun trueColor() = flowPrefs.getBoolean("pref_true_color_key", false) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/model/StencilPage.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/model/StencilPage.kt new file mode 100644 index 0000000000..fb07bfab46 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/model/StencilPage.kt @@ -0,0 +1,15 @@ +package eu.kanade.tachiyomi.ui.reader.model + +import eu.kanade.tachiyomi.util.system.ImageUtil + +class StencilPage( + parent: ReaderPage, + val splitData: ImageUtil.SplitData, +) : ReaderPage(parent.index, parent.url, parent.imageUrl) { + + override var chapter: ReaderChapter = parent.chapter + + init { + stream = parent.stream + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/setting/ReaderReadingModeSettings.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/setting/ReaderReadingModeSettings.kt index 23df4fcdca..22b17c1b7c 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/setting/ReaderReadingModeSettings.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/setting/ReaderReadingModeSettings.kt @@ -86,8 +86,8 @@ class ReaderReadingModeSettings @JvmOverloads constructor(context: Context, attr binding.pagerPrefsGroup.cropBorders.bindToPreference(preferences.cropBorders()) binding.pagerPrefsGroup.navigatePan.bindToPreference(preferences.navigateToPan()) - // Makes so that dual page invert gets hidden away when turning of dual page split binding.pagerPrefsGroup.dualPageSplit.bindToPreference(preferences.dualPageSplitPaged()) + // Makes it so that dual page invert gets hidden away when dual page split is turned off preferences.dualPageSplitPaged() .asHotFlow { binding.pagerPrefsGroup.dualPageInvert.isVisible = it } .launchIn((context as ReaderActivity).lifecycleScope) @@ -110,11 +110,12 @@ class ReaderReadingModeSettings @JvmOverloads constructor(context: Context, attr binding.webtoonPrefsGroup.cropBordersWebtoon.bindToPreference(preferences.cropBordersWebtoon()) binding.webtoonPrefsGroup.webtoonSidePadding.bindToIntPreference(preferences.webtoonSidePadding(), R.array.webtoon_side_padding_values) - // Makes so that dual page invert gets hidden away when turning of dual page split binding.webtoonPrefsGroup.dualPageSplit.bindToPreference(preferences.dualPageSplitWebtoon()) + // Makes it so that dual page invert gets hidden away when dual page split is turned off preferences.dualPageSplitWebtoon() .asHotFlow { binding.webtoonPrefsGroup.dualPageInvert.isVisible = it } .launchIn((context as ReaderActivity).lifecycleScope) binding.webtoonPrefsGroup.dualPageInvert.bindToPreference(preferences.dualPageInvertWebtoon()) + binding.webtoonPrefsGroup.longStripSplit.bindToPreference(preferences.longStripSplitWebtoon()) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonAdapter.kt index 6877cf602e..4c01839f09 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonAdapter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonAdapter.kt @@ -7,10 +7,12 @@ import androidx.recyclerview.widget.RecyclerView import eu.kanade.tachiyomi.ui.reader.model.ChapterTransition import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter import eu.kanade.tachiyomi.ui.reader.model.ReaderPage +import eu.kanade.tachiyomi.ui.reader.model.StencilPage import eu.kanade.tachiyomi.ui.reader.model.ViewerChapters import eu.kanade.tachiyomi.ui.reader.viewer.ReaderPageImageView import eu.kanade.tachiyomi.ui.reader.viewer.hasMissingChapters import eu.kanade.tachiyomi.util.system.createReaderThemeContext +import eu.kanade.tachiyomi.util.system.logcat /** * RecyclerView Adapter used by this [viewer] to where [ViewerChapters] updates are posted. @@ -25,6 +27,26 @@ class WebtoonAdapter(val viewer: WebtoonViewer) : RecyclerView.Adapter) { + if (currentStrip is StencilPage) return + + val placeAtIndex = items.indexOf(currentStrip) + 1 + // Stop constantly adding split images + if (items[placeAtIndex] is StencilPage) return + + val updatedItems = items.toMutableList() + updatedItems.addAll(placeAtIndex, newStrips) + updateItems(updatedItems) + logcat { "New adapter item count is $itemCount" } + } + + fun cleanupSplitStrips() { + if (items.any { it is StencilPage }) { + val updatedItems = items.filterNot { it is StencilPage } + updateItems(updatedItems) + } + } + /** * Context that has been wrapped to use the correct theme values based on the * current app theme and reader background color @@ -79,6 +101,10 @@ class WebtoonAdapter(val viewer: WebtoonViewer) : RecyclerView.Adapter) { val result = DiffUtil.calculateDiff(Callback(items, newItems)) items = newItems result.dispatchUpdatesTo(this) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonConfig.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonConfig.kt index dceb8e8f4d..07964b8f97 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonConfig.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonConfig.kt @@ -32,6 +32,11 @@ class WebtoonConfig( var sidePadding = 0 private set + var longStripSplit = false + private set + + var longStripSplitChangedListener: ((Boolean) -> Unit)? = null + val theme = preferences.readerTheme().get() init { @@ -57,6 +62,15 @@ class WebtoonConfig( preferences.dualPageInvertWebtoon() .register({ dualPageInvert = it }, { imagePropertyChangedListener?.invoke() }) + preferences.longStripSplitWebtoon() + .register( + { longStripSplit = it }, + { + imagePropertyChangedListener?.invoke() + longStripSplitChangedListener?.invoke(it) + }, + ) + preferences.readerTheme().asFlow() .drop(1) .distinctUntilChanged() diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonPageHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonPageHolder.kt index 3463eafd62..0d92d9890b 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonPageHolder.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonPageHolder.kt @@ -14,10 +14,12 @@ import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView import eu.kanade.tachiyomi.databinding.ReaderErrorBinding import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.ui.reader.model.ReaderPage +import eu.kanade.tachiyomi.ui.reader.model.StencilPage import eu.kanade.tachiyomi.ui.reader.viewer.ReaderPageImageView import eu.kanade.tachiyomi.ui.reader.viewer.ReaderProgressIndicator import eu.kanade.tachiyomi.ui.webview.WebViewActivity import eu.kanade.tachiyomi.util.system.ImageUtil +import eu.kanade.tachiyomi.util.system.ImageUtil.SplitData import eu.kanade.tachiyomi.util.system.dpToPx import rx.Observable import rx.Subscription @@ -274,17 +276,37 @@ class WebtoonPageHolder( } private fun process(imageStream: BufferedInputStream): InputStream { - if (!viewer.config.dualPageSplit) { - return imageStream + if (viewer.config.dualPageSplit) { + val isDoublePage = ImageUtil.isWideImage(imageStream) + if (isDoublePage) { + val upperSide = if (viewer.config.dualPageInvert) ImageUtil.Side.LEFT else ImageUtil.Side.RIGHT + return ImageUtil.splitAndMerge(imageStream, upperSide) + } } - val isDoublePage = ImageUtil.isWideImage(imageStream) - if (!isDoublePage) { - return imageStream + if (viewer.config.longStripSplit) { + if (page is StencilPage) { + val splitData = (page as StencilPage).splitData + return ImageUtil.splitStrip(imageStream, splitData) + } + + val isStripSplitNeeded = ImageUtil.isStripSplitNeeded(imageStream) + if (isStripSplitNeeded) { + val splitData = onStripSplit(imageStream) + splitData?.let { return ImageUtil.splitStrip(imageStream, it) } + } } - val upperSide = if (viewer.config.dualPageInvert) ImageUtil.Side.LEFT else ImageUtil.Side.RIGHT - return ImageUtil.splitAndMerge(imageStream, upperSide) + return imageStream + } + + private fun onStripSplit(imageStream: BufferedInputStream): SplitData? { + val page = page ?: return null + val splitData = ImageUtil.getSplitDataForStream(imageStream).toMutableList() + val toReturn = splitData.removeFirstOrNull() + val newPages = splitData.map { StencilPage(page, it) } + viewer.onLongStripSplit(page, newPages) + return toReturn } /** diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonViewer.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonViewer.kt index e6bf62d4e3..60cda71e4e 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonViewer.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonViewer.kt @@ -16,6 +16,7 @@ import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.ui.reader.ReaderActivity import eu.kanade.tachiyomi.ui.reader.model.ChapterTransition import eu.kanade.tachiyomi.ui.reader.model.ReaderPage +import eu.kanade.tachiyomi.ui.reader.model.StencilPage import eu.kanade.tachiyomi.ui.reader.model.ViewerChapters import eu.kanade.tachiyomi.ui.reader.viewer.BaseViewer import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation.NavigationRegion @@ -154,6 +155,12 @@ class WebtoonViewer(val activity: ReaderActivity, val isContinuous: Boolean = tr activity.binding.navigationOverlay.setNavigation(config.navigator, showOnStart) } + config.longStripSplitChangedListener = { enabled -> + if (!enabled) { + cleanupSplitStrips() + } + } + frame.layoutParams = ViewGroup.LayoutParams(MATCH_PARENT, MATCH_PARENT) frame.addView(recycler) } @@ -354,4 +361,15 @@ class WebtoonViewer(val activity: ReaderActivity, val isContinuous: Boolean = tr min(position + 3, adapter.itemCount - 1), ) } + + fun onLongStripSplit(currentStrip: Any?, newStrips: List) { + activity.runOnUiThread { + // Need to insert on UI thread else images will go blank + adapter.onLongStripSplit(currentStrip, newStrips) + } + } + + private fun cleanupSplitStrips() { + adapter.cleanupSplitStrips() + } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsReaderController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsReaderController.kt index a67f72831c..37d6883f04 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsReaderController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsReaderController.kt @@ -289,6 +289,11 @@ class SettingsReaderController : SettingsController() { summaryRes = R.string.pref_dual_page_invert_summary visibleIf(preferences.dualPageSplitWebtoon()) { it } } + switchPreference { + bindTo(preferences.longStripSplitWebtoon()) + titleRes = R.string.pref_long_strip_split + summaryRes = R.string.pref_long_strip_split_summary + } } preferenceCategory { diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/system/ImageUtil.kt b/app/src/main/java/eu/kanade/tachiyomi/util/system/ImageUtil.kt index 2f1d995cbd..d465491f5f 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/util/system/ImageUtil.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/system/ImageUtil.kt @@ -206,35 +206,6 @@ object ImageUtil { return true } - val options = extractImageOptions(imageFile.openInputStream(), resetAfterExtraction = false).apply { inJustDecodeBounds = false } - // Values are stored as they get modified during split loop - val imageHeight = options.outHeight - val imageWidth = options.outWidth - - val splitHeight = (getDisplayMaxHeightInPx * 1.5).toInt() - // -1 so it doesn't try to split when imageHeight = getDisplayHeightInPx - val partCount = (imageHeight - 1) / splitHeight + 1 - - val optimalSplitHeight = imageHeight / partCount - - val splitDataList = (0 until partCount).fold(mutableListOf()) { list, index -> - list.apply { - // Only continue if the list is empty or there is image remaining - if (isEmpty() || imageHeight > last().bottomOffset) { - val topOffset = index * optimalSplitHeight - var outputImageHeight = min(optimalSplitHeight, imageHeight - topOffset) - - val remainingHeight = imageHeight - (topOffset + outputImageHeight) - // If remaining height is smaller or equal to 1/3th of - // optimal split height then include it in current page - if (remainingHeight <= (optimalSplitHeight / 3)) { - outputImageHeight += remainingHeight - } - add(SplitData(index, topOffset, outputImageHeight)) - } - } - } - val bitmapRegionDecoder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { BitmapRegionDecoder.newInstance(imageFile.openInputStream()) } else { @@ -247,10 +218,12 @@ object ImageUtil { return false } - logcat { - "Splitting image with height of $imageHeight into $partCount part " + - "with estimated ${optimalSplitHeight}px height per split" - } + val options = extractImageOptions(imageFile.openInputStream(), resetAfterExtraction = false).apply { inJustDecodeBounds = false } + + // Values are stored as they get modified during split loop + val imageWidth = options.outWidth + + val splitDataList = getSplitDataForOptions(options) return try { splitDataList.forEach { splitData -> @@ -285,6 +258,93 @@ object ImageUtil { private fun splitImagePath(imageFilePath: String, index: Int) = imageFilePath.substringBeforeLast(".") + "__${"%03d".format(index + 1)}.jpg" + /** + * Check whether the image is a long Strip that needs splitting + * @return true if the image is not animated and it's height is greater than image width and screen height + */ + fun isStripSplitNeeded(imageStream: BufferedInputStream): Boolean { + if (isAnimatedAndSupported(imageStream)) return false + val options = extractImageOptions(imageStream) + + val imageHeightIsBiggerThanWidth = options.outHeight > options.outWidth + val imageHeightBiggerThanScreenHeight = options.outHeight > getDisplayMaxHeightInPx + return imageHeightIsBiggerThanWidth && imageHeightBiggerThanScreenHeight + } + + /** + * Split the imageStream according to the provided splitData + */ + fun splitStrip(imageStream: InputStream, splitData: SplitData): InputStream { + val bitmapRegionDecoder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + BitmapRegionDecoder.newInstance(imageStream) + } else { + @Suppress("DEPRECATION") + BitmapRegionDecoder.newInstance(imageStream, false) + } + + if (bitmapRegionDecoder == null) { + throw Exception("Failed to create new instance of BitmapRegionDecoder") + } + + logcat { + "WebtoonSplit #${splitData.index} with topOffset=${splitData.topOffset} " + + "outputImageHeight=${splitData.outputImageHeight} bottomOffset=${splitData.bottomOffset}" + } + + val options = extractImageOptions(imageStream).apply { inJustDecodeBounds = false } + + val region = Rect(0, splitData.topOffset, splitData.outputImageHeight, splitData.bottomOffset) + + try { + val splitBitmap = bitmapRegionDecoder.decodeRegion(region, options) + val outputStream = ByteArrayOutputStream() + splitBitmap.compress(Bitmap.CompressFormat.JPEG, 100, outputStream) + return ByteArrayInputStream(outputStream.toByteArray()) + } catch (e: Throwable) { + throw e + } finally { + bitmapRegionDecoder.recycle() + } + } + + fun getSplitDataForStream(imageStream: InputStream): List { + val options = extractImageOptions(imageStream) + return getSplitDataForOptions(options) + } + + private fun getSplitDataForOptions(options: BitmapFactory.Options): List { + val imageHeight = options.outHeight + + val splitHeight = (getDisplayMaxHeightInPx * 1.5).toInt() + // -1 so it doesn't try to split when imageHeight = splitHeight + val partCount = (imageHeight - 1) / splitHeight + 1 + + val optimalSplitHeight = imageHeight / partCount + + logcat { + "Generating SplitData for image with height of $imageHeight. " + + "Estimated $partCount part and ${optimalSplitHeight}px height per part" + } + + return mutableListOf().apply { + for (index in (0 until partCount)) { + // Only continue if the list is empty or there is image remaining + if (isNotEmpty() && imageHeight <= last().bottomOffset) break + + val topOffset = index * optimalSplitHeight + var outputImageHeight = min(optimalSplitHeight, imageHeight - topOffset) + + val remainingHeight = imageHeight - (topOffset + outputImageHeight) + // If remaining height is smaller or equal to 1/10th of + // optimal split height then include it in current page + if (remainingHeight <= (optimalSplitHeight / 10)) { + outputImageHeight += remainingHeight + } + add(SplitData(index, topOffset, outputImageHeight)) + } + } + } + data class SplitData( val index: Int, val topOffset: Int, diff --git a/app/src/main/res/layout/reader_webtoon_settings.xml b/app/src/main/res/layout/reader_webtoon_settings.xml index 0eb6fe97ab..b80b1991fa 100644 --- a/app/src/main/res/layout/reader_webtoon_settings.xml +++ b/app/src/main/res/layout/reader_webtoon_settings.xml @@ -66,6 +66,15 @@ android:visibility="gone" tools:visibility="visible" /> + + Dual page split Invert dual page split placement If the placement of the dual page split doesn\'t match reading direction + Split tall images (Alpha) + Improves reader performance Show content in cutout area Animate page transitions Double tap animation speed